ReMe/reme/steps/evolve/dream/utils.py
jinliyl a9ec334adc
feat: simplify wikilink semantics and support line anchors (#412)
* feat: simplify local links and support line anchors

* fix: align line anchor tests with CI lint

* fix: preserve local links across file moves

* fix: encode markdown paths when rewriting links

* refactor(read): keep explicit line range parameters

* fix: simplify legacy link predicate compatibility

* docs: align local link behavior with implementation

* fix: skip unsupported markdown destination escapes

* fix: normalize workspace link paths across platforms

* fix: bound markdown link scanning

* fix: keep local link processing linear

* docs: clarify permissive markdown link parsing

* fix: handle local link processing failures

* refactor: limit file links to wikilink syntax

* docs: align wikilink contract with implementation

* fix: normalize dream and neighbor paths on Windows

* fix: resolve workspace path for neighbor expansion
2026-08-05 11:47:50 +08:00

184 lines
6.1 KiB
Python

"""Shared auto-dream helpers."""
import datetime as dt
import re
from pathlib import Path
import yaml
from .._evolve import now
from ...base_step import BaseStep
from ....schema import DreamState
def state_from_context(step: BaseStep) -> DreamState:
"""Get dream state from context."""
assert step.context is not None
raw = step.context.get("dream") or step.context.response.metadata.get("dream") or {}
state = DreamState.model_validate(raw)
if not state.daily_dir:
state.daily_dir = step.config_value("daily_dir")
return state
def store_state(step: BaseStep, state: DreamState) -> None:
"""Store dream state in context."""
assert step.context is not None
data = state.model_dump()
step.context["dream"] = data
step.context.response.metadata["dream"] = data
def workspace_dir(step: BaseStep) -> Path:
"""Get workspace directory."""
vr = getattr(step.file_store, "workspace_path", None)
return Path(vr).resolve() if vr else Path.cwd().resolve()
def daily_dir(step: BaseStep) -> str:
"""Get daily directory."""
return step.config_value("daily_dir")
def today(step: BaseStep, explicit: str = "") -> str:
"""Get today's date."""
if explicit.strip():
return explicit.strip()
tz = step.app_context.app_config.timezone if step.app_context is not None else None
return now(tz).strftime("%Y-%m-%d")
def recent_dates(day: str, n_days: int) -> list[str]:
"""Return the inclusive recent-date window ending at ``day``."""
try:
base = dt.date.fromisoformat(day)
except ValueError:
return [day] if day else []
n = max(int(n_days or 1), 1)
return [(base - dt.timedelta(days=i)).isoformat() for i in range(n - 1, -1, -1)]
def llm_available(step: BaseStep) -> bool:
"""Check if LLM is available."""
try:
return step.as_llm is not None and step.agent_wrapper is not None
except Exception:
return False
def scan_day_files(workspace: Path, day: str, daily: str, interests_name: str = "interests.yaml") -> list[str]:
"""Scan day files."""
out: list[str] = []
day_index = workspace / daily / f"{day}.md"
if day_index.is_file():
out.append(day_index.relative_to(workspace).as_posix())
daily_root = workspace / daily / day
if daily_root.is_dir():
out.extend(p.relative_to(workspace).as_posix() for p in sorted(daily_root.rglob("*.md")) if p.is_file())
return [p for p in out if p != f"{daily}/{day}/{interests_name}"]
def pack_paths(workspace: Path, paths: list[str], *, limit_per_file: int = 60000) -> str:
"""Pack paths into a single string."""
blocks: list[str] = []
for rel in paths:
target = workspace / rel
if not target.is_file():
blocks.append(f"### {rel}\n(file not found)\n")
continue
try:
text = target.read_text(encoding="utf-8")
except Exception as e: # noqa: BLE001
blocks.append(f"### {rel}\n(error reading: {type(e).__name__}: {e})\n")
continue
suffix = "\n\n[truncated]\n" if len(text) > limit_per_file else ""
blocks.append(f"### {rel}\n{text[:limit_per_file]}{suffix}\n")
return "\n".join(blocks)
def clean_paths(raw_paths, allowed: set[str]) -> list[str]:
"""Clean paths."""
if not isinstance(raw_paths, list):
return []
out: list[str] = []
for item in raw_paths:
path = str(item or "").strip()
if path in allowed and path not in out:
out.append(path)
return out
def normalize_topic(text: str) -> str:
"""Normalize topic."""
return re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text.lower()).strip()
def previous_dates(day: str, n_days: int) -> list[str]:
"""Get previous dates."""
try:
base = dt.date.fromisoformat(day)
except ValueError:
return []
return [(base - dt.timedelta(days=i)).isoformat() for i in range(1, max(n_days, 0) + 1)]
def load_yaml_topics(path: Path) -> list[dict]:
"""Load YAML topics."""
if not path.is_file():
return []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception:
return []
topics = data.get("topics") if isinstance(data, dict) else None
if not isinstance(topics, list):
return []
return [cleaned for t in topics if isinstance(t, dict) and (cleaned := clean_topic(t))]
def clean_topic(raw: dict) -> dict:
"""Clean topic."""
title, reason = str(raw.get("title") or "").strip(), str(raw.get("reason") or "").strip()
if not title or not reason:
return {}
keywords = raw.get("keywords") or []
paths = raw.get("paths") or []
return {
"title": title,
"reason": reason,
"evidence": str(raw.get("evidence") or "").strip(),
"keywords": [str(k).strip() for k in keywords if str(k).strip()] if isinstance(keywords, list) else [],
"paths": [str(p).strip() for p in paths if str(p).strip()] if isinstance(paths, list) else [],
}
def write_yaml(path: Path, payload: dict) -> None:
"""Write YAML."""
path.parent.mkdir(parents=True, exist_ok=True)
rendered = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
path.write_text(rendered if rendered.endswith("\n") else f"{rendered}\n", encoding="utf-8")
def parse_structured_reply(text: str) -> dict:
"""Parse a JSON/YAML object from an agent reply, including fenced blocks."""
candidates = [text.strip()]
candidates.extend(m.group(1).strip() for m in re.finditer(r"```(?:json|ya?ml)?\s*(.*?)```", text, re.S | re.I))
for raw in candidates:
if not raw:
continue
try:
data = yaml.safe_load(raw)
except yaml.YAMLError:
data = _parse_scalar_mapping(raw)
if isinstance(data, dict) and data:
return data
return {}
def _parse_scalar_mapping(raw: str) -> dict:
"""Parse a scalar mapping."""
out: dict[str, str] = {}
for line in raw.splitlines():
if match := re.match(r"^\s*(action|target_path|note)\s*:\s*(.+?)\s*$", line):
out[match.group(1)] = match.group(2).strip().strip("\"'")
return out