feat(memory): improve wiki search observability and retrieval flow

Expand memory wiki search with context-rich paginated results and bounded query expansion, and add default three-stage retrieval guidance for agents using memory_wiki_search.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-06 23:19:28 +08:00
parent d731288ffa
commit 5fc0805c69
4 changed files with 246 additions and 10 deletions

View file

@ -128,6 +128,78 @@ def _read_lines(path: Path) -> list[str]:
return text.splitlines()
def _clip_text(s: str, *, max_chars: int = 240) -> str:
t = str(s or "").strip()
if len(t) <= max_chars:
return t
return t[: max(1, max_chars - 15)] + "...<truncated>"
def _tokens_for_query(raw_query: str) -> list[str]:
q = str(raw_query or "").strip()
if not q:
return []
# Keep CJK runs, alpha words, and numbers.
parts = re.findall(r"[\u4e00-\u9fff]+|[A-Za-z]+|\d+", q)
out: list[str] = []
seen: set[str] = set()
for p in parts:
tok = str(p or "").strip()
key = tok.lower()
if not tok or key in seen:
continue
seen.add(key)
out.append(tok)
return out
def _expand_query_variants(raw_query: str) -> list[str]:
query = str(raw_query or "").strip()
if not query:
return []
variants: list[str] = [query]
synonym_map: dict[str, list[str]] = {
"用户": ["创作者", "项目所有者", "owner", "profile", "identity"],
"身份": ["角色", "identity", "profile"],
"创建者": ["创作者", "所有者", "owner"],
"owner": ["所有者", "项目所有者", "创作者"],
"creator": ["创作者", "创建者", "项目所有者"],
"name": ["名字", "姓名"],
}
toks = _tokens_for_query(query)
for tok in toks:
for syn in synonym_map.get(tok.lower(), []):
if syn not in variants:
variants.append(syn)
if len(toks) > 1:
joined = " ".join(toks)
if joined not in variants:
variants.append(joined)
return variants
def _build_line_hit(
*,
lines: list[str],
line_idx: int,
rel_path: str,
matched_query: str,
context_lines: int,
) -> dict[str, Any]:
before_start = max(0, line_idx - context_lines)
before_lines = lines[before_start:line_idx]
after_end = min(len(lines), line_idx + 1 + context_lines)
after_lines = lines[line_idx + 1 : after_end]
return {
"path": rel_path,
"line": int(line_idx + 1),
"text": _clip_text(lines[line_idx]),
"before": [_clip_text(x) for x in before_lines],
"after": [_clip_text(x) for x in after_lines],
"matched_query": str(matched_query or ""),
}
def _wiki_status(rt: WikiRuntime, args: dict[str, Any]) -> dict[str, Any]:
del args
files = _list_md_files(rt.wiki_root)
@ -171,17 +243,69 @@ def _wiki_search(rt: WikiRuntime, args: dict[str, Any]) -> dict[str, Any]:
is_regex = bool(args.get("is_regex"))
req_limit = int(args.get("limit") or rt.max_search_results)
limit = max(1, min(req_limit, rt.max_search_results))
offset = max(0, int(args.get("offset") or 0))
context_lines = max(0, min(int(args.get("context_lines") or 0), 5))
path_prefix = str(args.get("path_prefix") or "").strip().replace("\\", "/").lstrip("/")
expand_query = bool(args.get("expand_query")) and not is_regex
max_rounds = max(1, min(int(args.get("max_rounds") or 2), 5))
queries = [query]
if expand_query:
queries = _expand_query_variants(query)[:max_rounds]
flags = 0 if case_sensitive else re.IGNORECASE
pattern = re.compile(query if is_regex else re.escape(query), flags=flags)
hits: list[dict[str, Any]] = []
for fp in _list_md_files(rt.wiki_root):
rel = str(fp.relative_to(rt.wiki_root)).replace("\\", "/")
for idx, line in enumerate(_read_lines(fp), start=1):
if pattern.search(line):
hits.append({"path": rel, "line": idx, "text": line.strip()})
if len(hits) >= limit:
return {"ok": True, "query": query, "hits": hits, "truncated": True}
return {"ok": True, "query": query, "hits": hits, "truncated": False}
hits_all: list[dict[str, Any]] = []
seen_keys: set[tuple[str, int]] = set()
files_scanned = 0
for qv in queries:
pattern = re.compile(qv if is_regex else re.escape(qv), flags=flags)
for fp in _list_md_files(rt.wiki_root):
rel = str(fp.relative_to(rt.wiki_root)).replace("\\", "/")
if path_prefix and not rel.startswith(path_prefix):
continue
files_scanned += 1
lines = _read_lines(fp)
for idx, line in enumerate(lines):
if not pattern.search(line):
continue
key = (rel, int(idx + 1))
if key in seen_keys:
continue
seen_keys.add(key)
hits_all.append(
_build_line_hit(
lines=lines,
line_idx=idx,
rel_path=rel,
matched_query=qv,
context_lines=context_lines,
)
)
hits_page = hits_all[offset : offset + limit]
next_offset = offset + len(hits_page)
truncated = next_offset < len(hits_all)
query_used = str(hits_page[0].get("matched_query") or query) if hits_page else query
# Backward-compatible fields remain: ok/query/hits/truncated.
# New fields are additive diagnostics/controls for iterative search flows.
return {
"ok": True,
"query": query,
"hits": hits_page,
"truncated": bool(truncated),
"query_used": query_used,
"queries_attempted": queries,
"total_hits_estimate": len(hits_all),
"offset": offset,
"next_offset": next_offset if truncated else None,
"limit": limit,
"context_lines": context_lines,
"path_prefix": path_prefix,
"files_scanned": files_scanned,
"expanded": bool(expand_query),
}
def _wiki_lint(rt: WikiRuntime, args: dict[str, Any]) -> dict[str, Any]:
@ -290,8 +414,13 @@ def build_wiki_tool_specs(api: Any) -> list[dict[str, Any]]:
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"offset": {"type": "integer"},
"is_regex": {"type": "boolean"},
"case_sensitive": {"type": "boolean"},
"context_lines": {"type": "integer"},
"path_prefix": {"type": "string"},
"expand_query": {"type": "boolean"},
"max_rounds": {"type": "integer"},
},
"required": ["query"],
},

View file

@ -46,6 +46,9 @@ 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_search 时默认采用三段检索:先 `query` 精确检索;若结果不足再启用 `expand_query=true`;仍不足时加 `path_prefix` 定向到候选目录后重检。\n"
"- 三段检索建议参数:第一段 `limit=5~8`;第二段 `expand_query=true,max_rounds=2~3`;第三段在保留前述参数基础上增加 `path_prefix` 并分页(`offset`/`limit`)。\n"
"- 每段检索后先依据 `queries_attempted`、`total_hits_estimate`、`next_offset` 判断是否继续,避免一次无命中就直接下结论。\n"
"- memory wiki 请仅传相对 wiki 根的路径(相对 `data/wiki`),例如 `improvement/learnings.md`;不要传 `data/wiki/...` 或绝对路径。\n"
"- 当新增事实会影响后续决策时,完成当前任务后使用 memory_wiki_apply 写入结构化记忆,并用 memory_wiki_lint 做质量检查。\n"
"- 技能包由说明文档和可选文件组成。运行时不会自动执行技能 `scripts/` 目录下的文件;\n"

View file

@ -15,6 +15,12 @@
- 质量检查:`memory_wiki_lint`。
- 写入/更新:`memory_wiki_apply`(write/append/delete)。
## wiki 检索默认流程(三段):
- 第1段:先用 `memory_wiki_search` 做 `query` 精确检索(建议 `limit=5~8`)。
- 第2段:若结果不足,启用 `expand_query=true`(建议 `max_rounds=2~3`)做补搜。
- 第3段:若仍不足,结合候选目录设置 `path_prefix` 做定向检索,并使用 `offset`/`limit` 分页补全。
- 每段结束后必须检查 `queries_attempted`、`total_hits_estimate`、`next_offset` 再决定是否继续,不允许“一次没命中就断言不存在”。
## 记忆策略(按需记忆):
- 满足“稳定、可复用、可检索”时才写入;否则不写入并说明原因。
- 写入前先检索相近条目,优先增量更新,避免重复堆砌。

View file

@ -0,0 +1,98 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
from types import SimpleNamespace
from oclaw.platform.config.paths import PROJECT_ROOT
def _wiki_search_handler(tmp_path: Path):
api_path = (PROJECT_ROOT / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve()
spec = importlib.util.spec_from_file_location("memory_wiki_api_test", str(api_path))
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod) # type: ignore[assignment]
wiki_root = tmp_path / "wiki"
wiki_root.mkdir(parents=True, exist_ok=True)
rows = mod.build_wiki_tool_specs(
SimpleNamespace(plugin_config={"wiki_root": str(wiki_root), "max_search_results": 10, "max_get_lines": 200})
)
for r in rows:
if str(r.get("name") or "") == "wiki_search":
return wiki_root, r["handler"]
raise AssertionError("wiki_search handler missing")
def test_wiki_search_returns_context_and_pagination_metadata(tmp_path: Path) -> None:
wiki_root, handler = _wiki_search_handler(tmp_path)
p = wiki_root / "users" / "identity.md"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(
"\n".join(
[
"# Identity",
"creator is Oliver",
"project owner is Oliver",
"session profile line",
]
),
encoding="utf-8",
)
out = handler({"query": "Oliver", "limit": 1, "offset": 0, "context_lines": 1})
assert out.get("ok") is True
assert out.get("query") == "Oliver"
assert out.get("truncated") is True
assert out.get("next_offset") == 1
hits = out.get("hits") or []
assert len(hits) == 1
hit = hits[0]
assert hit.get("path") == "users/identity.md"
assert isinstance(hit.get("before"), list)
assert isinstance(hit.get("after"), list)
assert "matched_query" in hit
def test_wiki_search_offset_paginates_deterministically(tmp_path: Path) -> None:
wiki_root, handler = _wiki_search_handler(tmp_path)
p = wiki_root / "identity.md"
p.write_text("Oliver one\nOliver two\nOliver three\n", encoding="utf-8")
page1 = handler({"query": "Oliver", "limit": 2, "offset": 0})
page2 = handler({"query": "Oliver", "limit": 2, "offset": 2})
hits1 = page1.get("hits") or []
hits2 = page2.get("hits") or []
assert [h.get("line") for h in hits1] == [1, 2]
assert [h.get("line") for h in hits2] == [3]
assert page2.get("truncated") is False
assert page2.get("next_offset") is None
def test_wiki_search_expand_query_reports_attempted_queries(tmp_path: Path) -> None:
wiki_root, handler = _wiki_search_handler(tmp_path)
p = wiki_root / "users" / "identity.md"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("项目所有者:Oliver\n", encoding="utf-8")
out = handler({"query": "用户", "expand_query": True, "max_rounds": 3, "limit": 5})
assert out.get("ok") is True
attempted = out.get("queries_attempted") or []
assert attempted
assert attempted[0] == "用户"
assert len(attempted) <= 3
hits = out.get("hits") or []
assert hits
assert any(h.get("matched_query") != "用户" for h in hits)
def test_wiki_search_keeps_backward_compatible_keys(tmp_path: Path) -> None:
wiki_root, handler = _wiki_search_handler(tmp_path)
(wiki_root / "a.md").write_text("hello\n", encoding="utf-8")
out = handler({"query": "hello"})
assert {"ok", "query", "hits", "truncated"}.issubset(set(out.keys()))