From 25180921e7f322ff56737683cde0172b63bb2558 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 29 Apr 2026 08:54:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=BC=E5=AE=B9=20memory-wiki=20=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E8=B7=AF=E5=BE=84=E5=B9=B6=E5=BC=BA=E8=B0=83=E7=9B=B8?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memory-wiki 的 wiki_root 路径解析兼容:相对路径 / data/wiki 前缀 / wiki_root 下绝对路径 - 系统提示词强调:模型传相对 wiki 根路径(相对 data/wiki),避免直接给 data/wiki/... 或绝对路径 - 修复并回归:memory-wiki 工具动态加载模块 import-time 错误(sys.modules 注入) - chat.js 增强会话滚动跟随(异步渲染补滚) Made-with: Cursor --- interfaces/admin/static/chat.js | 30 ++++++++++++-- runtime/extensions/memory-wiki/api.py | 57 +++++++++++++++++++++++--- runtime/system_prompt.py | 2 +- tests/test_memory_wiki_tools_loader.py | 22 ++++++++++ 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index f1dc133a..c2e662dc 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -2357,16 +2357,30 @@ async function renderChatUi() { bindChatMermaidViewer(messagesEl); const AUTO_SCROLL_BOTTOM_GAP_PX = 56; let shouldFollowMessages = true; + let autoScrollRaf = 0; + let autoScrollTimer = 0; const isNearBottom = () => { const remaining = messagesEl.scrollHeight - (messagesEl.scrollTop + messagesEl.clientHeight); return remaining <= AUTO_SCROLL_BOTTOM_GAP_PX; }; - const scrollMessagesToBottom = (force = false) => { + const scheduleFollowScroll = (force = false) => { if (!force && !shouldFollowMessages) return; - messagesEl.scrollTop = messagesEl.scrollHeight; - requestAnimationFrame(() => { - if (force || shouldFollowMessages) messagesEl.scrollTop = messagesEl.scrollHeight; + if (autoScrollRaf) cancelAnimationFrame(autoScrollRaf); + if (autoScrollTimer) clearTimeout(autoScrollTimer); + autoScrollRaf = requestAnimationFrame(() => { + messagesEl.scrollTop = messagesEl.scrollHeight; + autoScrollRaf = requestAnimationFrame(() => { + if (force || shouldFollowMessages) messagesEl.scrollTop = messagesEl.scrollHeight; + }); }); + // Some async markdown/image/UI post-processing happens after RAF. + autoScrollTimer = setTimeout(() => { + if (force || shouldFollowMessages) messagesEl.scrollTop = messagesEl.scrollHeight; + autoScrollTimer = 0; + }, 60); + }; + const scrollMessagesToBottom = (force = false) => { + scheduleFollowScroll(force); }; messagesEl.addEventListener("scroll", () => { shouldFollowMessages = isNearBottom(); @@ -2380,6 +2394,14 @@ async function renderChatUi() { }, true, ); + const messagesMutationObserver = new MutationObserver(() => { + scheduleFollowScroll(false); + }); + messagesMutationObserver.observe(messagesEl, { + childList: true, + subtree: true, + characterData: true, + }); const textarea = el("textarea", { class: "chat-composer__field", rows: "1", diff --git a/runtime/extensions/memory-wiki/api.py b/runtime/extensions/memory-wiki/api.py index 01aec477..d41750ba 100644 --- a/runtime/extensions/memory-wiki/api.py +++ b/runtime/extensions/memory-wiki/api.py @@ -59,16 +59,61 @@ def _resolve_runtime(api: Any) -> WikiRuntime: def _safe_path(rt: WikiRuntime, rel_path: str) -> Path: - rp = str(rel_path or "").strip().replace("\\", "/") - rp = rp.lstrip("./") - if not rp: + raw = str(rel_path or "").strip() + raw = raw.replace("\\", "/") + raw = raw.lstrip("./") + if not raw: raise ValueError("path_required") - p = (rt.wiki_root / rp).resolve() + root = rt.wiki_root.resolve() - if p != root and root not in p.parents: - raise ValueError("path_outside_wiki_root") + + # 1) Absolute path: accept if it's under wiki_root. + try: + cand = Path(raw) + if cand.is_absolute(): + p = cand.resolve() + if p.suffix.lower() != ".md": + raise ValueError("only_markdown_supported") + if p != root and root not in p.parents: + raise ValueError("path_outside_wiki_root") + return p + except Exception: + # fall through to relative/normalized handling + pass + + # 2) Relative path: normalize common "data/wiki/..." prefixes to wiki_root-relative paths. + rp = raw.lstrip("/") + + prefixes: list[str] = [] + # Legacy/default prefix. + prefixes.append("data/wiki/") + + # If wiki_root is configured under project root, allow stripping its relative prefix too. + try: + root_rel = rt.wiki_root.resolve().relative_to(_project_root().resolve()).as_posix() + if root_rel: + prefixes.append(str(root_rel).lstrip("/") + "/") + except Exception: + pass + + # Allow stripping the wiki_root folder name (e.g., "wiki/xxx.md") if user passes it. + prefixes.append(f"{root.name}/") + + rp_lower = rp.lower() + stripped = rp + for pref in prefixes: + pref_l = str(pref or "").strip().lower() + if not pref_l: + continue + if rp_lower.startswith(pref_l): + stripped = rp[len(pref) :] + break + + p = (rt.wiki_root / stripped).resolve() if p.suffix.lower() != ".md": raise ValueError("only_markdown_supported") + if p != root and root not in p.parents: + raise ValueError("path_outside_wiki_root") return p diff --git a/runtime/system_prompt.py b/runtime/system_prompt.py index 67de2ec5..8ac33bb8 100644 --- a/runtime/system_prompt.py +++ b/runtime/system_prompt.py @@ -42,7 +42,7 @@ def _unified_skill_policy_guidance() -> str: "- 不要为了“列出技能”而去读取 SKILL.md。只有在你确实需要某个技能的详细使用说明时,才读取对应 SKILL.md。\n" "- 当你需要技能细节时,请按目录中给出的 path 读取对应的 SKILL.md。\n" "- 当对话涉及长期记忆、用户身份/偏好、项目背景延续、复发问题沉淀时,优先启用 wiki-first-autonomy 技能,并优先使用 memory_wiki_search/memory_wiki_get 检索上下文,再执行与回复。\n" - "- memory wiki 默认根路径为 `data/wiki`(例如 `data/wiki/improvement/learnings.md`);未被用户明确覆盖时,统一按该路径读写与检索。\n" + "- memory wiki 请仅传相对 wiki 根的路径(相对 `data/wiki`),例如 `improvement/learnings.md`;不要传 `data/wiki/...` 或绝对路径。\n" "- 当新增事实会影响后续决策时,完成当前任务后使用 memory_wiki_apply 写入结构化记忆,并用 memory_wiki_lint 做质量检查。\n" "- 技能包由说明文档和可选文件组成。运行时不会自动执行技能 `scripts/` 目录下的文件;\n" "- internal hooks 是独立系统,也不会自动执行这些脚本。\n" diff --git a/tests/test_memory_wiki_tools_loader.py b/tests/test_memory_wiki_tools_loader.py index 4f624117..25db6b80 100644 --- a/tests/test_memory_wiki_tools_loader.py +++ b/tests/test_memory_wiki_tools_loader.py @@ -1,5 +1,8 @@ from __future__ import annotations +from pathlib import Path + +from oclaw.platform.config.paths import PROJECT_ROOT from oclaw.runtime.tools.experts.memory.wiki_tools import ( memory_wiki_apply_tool, memory_wiki_get_tool, @@ -35,3 +38,22 @@ def test_memory_wiki_tools_handlers_load_without_dataclass_module_error() -> Non assert "AttributeError" not in str(apply_res.get("error", "")) assert "__dict__" not in str(apply_res.get("error", "")) + +def test_memory_wiki_tools_accept_relative_prefixed_and_absolute_paths() -> None: + wiki_root = (PROJECT_ROOT / "data" / "wiki").resolve() + rel = Path("improvement") / "learnings.md" + abs_path = str((wiki_root / rel).resolve()) + prefixed_rel = str(Path("data/wiki") / rel) + + # Relative + r1 = memory_wiki_get_tool().handler({"path": str(rel)}) + assert r1.get("ok") is True + + # Prefixed by "data/wiki/..." + r2 = memory_wiki_get_tool().handler({"path": prefixed_rel}) + assert r2.get("ok") is True + + # Absolute path under wiki_root + r3 = memory_wiki_get_tool().handler({"path": abs_path}) + assert r3.get("ok") is True +