mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:03:15 +08:00
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>
459 lines
16 KiB
Python
459 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any, Callable
|
|
|
|
|
|
def _project_root() -> Path:
|
|
return Path(__file__).resolve().parents[3]
|
|
|
|
|
|
def build_plugin_config_schema(*_args, **_kwargs) -> dict[str, Any]:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"wiki_root": {
|
|
"type": "string",
|
|
"description": "Wiki root directory relative to workspace root.",
|
|
"default": "docs/memory-system/wiki",
|
|
},
|
|
"max_search_results": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": 200,
|
|
"default": 20,
|
|
},
|
|
"max_get_lines": {
|
|
"type": "integer",
|
|
"minimum": 20,
|
|
"maximum": 5000,
|
|
"default": 800,
|
|
},
|
|
},
|
|
"additionalProperties": True,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WikiRuntime:
|
|
wiki_root: Path
|
|
max_search_results: int
|
|
max_get_lines: int
|
|
|
|
|
|
def _resolve_runtime(api: Any) -> WikiRuntime:
|
|
cfg = dict(getattr(api, "plugin_config", {}) or {})
|
|
root_cfg = str(cfg.get("wiki_root") or "docs/memory-system/wiki").strip()
|
|
if not root_cfg:
|
|
root_cfg = "docs/memory-system/wiki"
|
|
root = Path(root_cfg)
|
|
if not root.is_absolute():
|
|
root = (_project_root() / root).resolve()
|
|
max_search_results = int(cfg.get("max_search_results") or 20)
|
|
max_get_lines = int(cfg.get("max_get_lines") or 800)
|
|
max_search_results = max(1, min(max_search_results, 200))
|
|
max_get_lines = max(20, min(max_get_lines, 5000))
|
|
return WikiRuntime(wiki_root=root, max_search_results=max_search_results, max_get_lines=max_get_lines)
|
|
|
|
|
|
def _safe_path(rt: WikiRuntime, rel_path: str) -> Path:
|
|
raw = str(rel_path or "").strip()
|
|
raw = raw.replace("\\", "/")
|
|
raw = raw.lstrip("./")
|
|
if not raw:
|
|
raise ValueError("path_required")
|
|
|
|
root = rt.wiki_root.resolve()
|
|
|
|
# 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
|
|
|
|
|
|
def _list_md_files(root: Path) -> list[Path]:
|
|
if not root.exists():
|
|
return []
|
|
return sorted([p for p in root.rglob("*.md") if p.is_file()])
|
|
|
|
|
|
def _read_lines(path: Path) -> list[str]:
|
|
text = path.read_text(encoding="utf-8")
|
|
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)
|
|
return {
|
|
"ok": True,
|
|
"wiki_root": str(rt.wiki_root),
|
|
"exists": bool(rt.wiki_root.exists()),
|
|
"file_count": len(files),
|
|
"max_search_results": rt.max_search_results,
|
|
"max_get_lines": rt.max_get_lines,
|
|
}
|
|
|
|
|
|
def _wiki_get(rt: WikiRuntime, args: dict[str, Any]) -> dict[str, Any]:
|
|
path = _safe_path(rt, str(args.get("path") or ""))
|
|
if not path.exists():
|
|
return {"ok": False, "error_code": "wiki_not_found", "error": f"file not found: {path}"}
|
|
start = int(args.get("start_line") or 1)
|
|
end = int(args.get("end_line") or 0)
|
|
lines = _read_lines(path)
|
|
n = len(lines)
|
|
start = max(1, min(start, n if n > 0 else 1))
|
|
if end <= 0:
|
|
end = min(n, start + rt.max_get_lines - 1)
|
|
end = max(start, min(end, n))
|
|
out_lines = lines[start - 1 : end]
|
|
return {
|
|
"ok": True,
|
|
"path": str(path.relative_to(rt.wiki_root)),
|
|
"start_line": start,
|
|
"end_line": end,
|
|
"content": "\n".join(out_lines),
|
|
}
|
|
|
|
|
|
def _wiki_search(rt: WikiRuntime, args: dict[str, Any]) -> dict[str, Any]:
|
|
query = str(args.get("query") or "").strip()
|
|
if not query:
|
|
return {"ok": False, "error_code": "query_required", "error": "query is required"}
|
|
case_sensitive = bool(args.get("case_sensitive"))
|
|
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
|
|
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]:
|
|
target = str(args.get("path") or "").strip()
|
|
files = [_safe_path(rt, target)] if target else _list_md_files(rt.wiki_root)
|
|
issues: list[dict[str, Any]] = []
|
|
for fp in files:
|
|
if not fp.exists():
|
|
issues.append({"path": str(fp), "line": 1, "level": "error", "code": "wiki_not_found"})
|
|
continue
|
|
rel = str(fp.relative_to(rt.wiki_root)).replace("\\", "/")
|
|
lines = _read_lines(fp)
|
|
h1_count = 0
|
|
prev_level = 0
|
|
for idx, line in enumerate(lines, start=1):
|
|
m = re.match(r"^\s*(#{1,6})\s+\S+", line)
|
|
if m:
|
|
level = len(m.group(1))
|
|
if level == 1:
|
|
h1_count += 1
|
|
if prev_level > 0 and level > prev_level + 1:
|
|
issues.append(
|
|
{
|
|
"path": rel,
|
|
"line": idx,
|
|
"level": "warn",
|
|
"code": "heading_jump",
|
|
"message": f"heading jump h{prev_level} -> h{level}",
|
|
}
|
|
)
|
|
prev_level = level
|
|
if line.rstrip(" \t") != line:
|
|
issues.append({"path": rel, "line": idx, "level": "warn", "code": "trailing_whitespace"})
|
|
if h1_count > 1:
|
|
issues.append({"path": rel, "line": 1, "level": "warn", "code": "multiple_h1", "count": h1_count})
|
|
errors = [x for x in issues if str(x.get("level")) == "error"]
|
|
return {"ok": len(errors) == 0, "issue_count": len(issues), "issues": issues}
|
|
|
|
|
|
def _wiki_apply(rt: WikiRuntime, args: dict[str, Any]) -> dict[str, Any]:
|
|
action = str(args.get("action") or "write").strip().lower()
|
|
path = _safe_path(rt, str(args.get("path") or ""))
|
|
content = str(args.get("content") or "")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if action == "delete":
|
|
if not path.exists():
|
|
return {"ok": False, "error_code": "wiki_not_found", "error": f"file not found: {path}"}
|
|
path.unlink()
|
|
return {"ok": True, "action": "delete", "path": str(path.relative_to(rt.wiki_root))}
|
|
if action == "append":
|
|
old = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
sep = "" if not old or old.endswith("\n") else "\n"
|
|
path.write_text(old + sep + content, encoding="utf-8")
|
|
return {"ok": True, "action": "append", "path": str(path.relative_to(rt.wiki_root))}
|
|
if action == "write":
|
|
path.write_text(content, encoding="utf-8")
|
|
return {"ok": True, "action": "write", "path": str(path.relative_to(rt.wiki_root))}
|
|
return {"ok": False, "error_code": "invalid_action", "error": f"unsupported action: {action}"}
|
|
|
|
|
|
def build_wiki_tool_specs(api: Any) -> list[dict[str, Any]]:
|
|
rt = _resolve_runtime(api)
|
|
|
|
def _wrap(fn: Callable[[WikiRuntime, dict[str, Any]], dict[str, Any]]) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
|
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
|
try:
|
|
return fn(rt, dict(args or {}))
|
|
except ValueError as exc:
|
|
return {"ok": False, "error_code": "invalid_arguments", "error": str(exc)}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"ok": False, "error_code": "wiki_runtime_error", "error": f"{type(exc).__name__}: {exc}"}
|
|
|
|
return _handler
|
|
|
|
base_obj = {"type": "object", "additionalProperties": False}
|
|
return [
|
|
{
|
|
"name": "wiki_status",
|
|
"description": "Show wiki plugin status and basic file counts.",
|
|
"parameters": {**base_obj, "properties": {}},
|
|
"handler": _wrap(_wiki_status),
|
|
"tags": ["memory", "wiki", "read"],
|
|
"read_only": True,
|
|
},
|
|
{
|
|
"name": "wiki_get",
|
|
"description": "Read a markdown file from wiki root with optional line range.",
|
|
"parameters": {
|
|
**base_obj,
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"start_line": {"type": "integer"},
|
|
"end_line": {"type": "integer"},
|
|
},
|
|
"required": ["path"],
|
|
},
|
|
"handler": _wrap(_wiki_get),
|
|
"tags": ["memory", "wiki", "read"],
|
|
"read_only": True,
|
|
},
|
|
{
|
|
"name": "wiki_search",
|
|
"description": "Search markdown files under wiki root.",
|
|
"parameters": {
|
|
**base_obj,
|
|
"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"],
|
|
},
|
|
"handler": _wrap(_wiki_search),
|
|
"tags": ["memory", "wiki", "read"],
|
|
"read_only": True,
|
|
},
|
|
{
|
|
"name": "wiki_lint",
|
|
"description": "Lint wiki markdown files (headings and formatting checks).",
|
|
"parameters": {
|
|
**base_obj,
|
|
"properties": {"path": {"type": "string"}},
|
|
},
|
|
"handler": _wrap(_wiki_lint),
|
|
"tags": ["memory", "wiki", "read"],
|
|
"read_only": True,
|
|
},
|
|
{
|
|
"name": "wiki_apply",
|
|
"description": "Apply write/append/delete on a wiki markdown file.",
|
|
"parameters": {
|
|
**base_obj,
|
|
"properties": {
|
|
"action": {"type": "string", "enum": ["write", "append", "delete"]},
|
|
"path": {"type": "string"},
|
|
"content": {"type": "string"},
|
|
},
|
|
"required": ["action", "path"],
|
|
},
|
|
"handler": _wrap(_wiki_apply),
|
|
"tags": ["memory", "wiki", "write"],
|
|
"read_only": False,
|
|
"risk_level": "high",
|
|
},
|
|
]
|