ReMe/reme/steps/evolve/proactive/utils.py
imrewce c0bad3d30e refactor(proactive): expand topic kinds from two to four
Give each kind one job: follow_up reports progress on the user's unfinished
or still-tracked matters, interest_extend recommends knowledge-base content
that grows the wiki network, reminder reviews the user's own schedule plus
must-tell notices and key events from external resources, and new_idea
proposes delegatable work grounded in capabilities the agent already has.
Reminders are the priority kind and always enter today's agenda.

- extract: two-stage round (batched recall, then one adjudication pass per
  kind), on by default and fail-open per kind
- schedules: long-lived registry so an appointment noted weeks ago still
  fires; only evidence-anchored schedule_updates may move a due date
- agenda/plan: separate reminder and discovery budgets, so a reminder-heavy
  day still surfaces discovery items

Includes the fixes surfaced by the smoke runs: ISO coercion of unquoted YAML
dates, per-entry salvage of a malformed truth source, refine move guards for
notice-anchored reminders, no revert of an authoritative reschedule,
re-anchored reminder due dates, resource-path anchors for schedule updates,
pin reasons that name the real cause, empty verdicts read as a clean reply,
and one registration per matter.
2026-10-16 11:09:54 +08:00

880 lines
34 KiB
Python

"""Shared proactive helpers: frozen topic identity, truth-source state, rendering.
Implements the A7 skeleton from PROACTIVE_SPEC.md. ``normalize_topic`` is a
frozen contract (INV-4): any change to it drifts every historical topic id.
"""
import contextlib
import datetime as dt
import hashlib
import os
import re
import tempfile
import time
import unicodedata
from pathlib import Path
import yaml
from ....enumeration import ComponentEnum
from ....schema import ProactiveSchedule, ProactiveStateFile, ProactiveTopic
from ....schema.proactive import (
DEFAULT_KIND,
TOPIC_KINDS,
clamp_confidence,
kind_priority,
)
from ....utils import get_logger
from ...file_io._file_io import get_path_lock
from .._evolve import now
from ..dream.utils import clean_paths, recent_dates, scan_day_files
logger = get_logger(log_to_file=False)
PROACTIVE_STATE_NAME = "_proactive.yaml"
INTERESTS_NAME = "interests.yaml"
EXTRACT_SECTIONS = ("follow_ups", "extends", "reminders", "new_ideas", "updates", "schedule_updates")
# Registry triggers that deserve a reminder; ``later``/``unscheduled``/``closed``
# stay in the prompt view but never become candidates.
def load_yaml_topics(path: Path, *, strict: bool = False) -> list[dict]:
"""Load legacy or current interests YAML topics."""
if not path.is_file():
return []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception as exc:
if strict:
raise ValueError(f"Invalid interests YAML at {path}: {exc}") from exc
return []
if data is None:
if strict:
raise ValueError(f"Invalid interests YAML at {path}: expected an object")
return []
topics = data.get("topics") if isinstance(data, dict) else None
if not isinstance(topics, list):
if strict:
raise ValueError(f"Invalid interests YAML at {path}: topics must be a list")
return []
cleaned_topics = []
for index, topic in enumerate(topics):
if strict:
_validate_topic(topic, path, index)
if isinstance(topic, dict) and (cleaned := _clean_legacy_topic(topic)):
cleaned_topics.append(cleaned)
return cleaned_topics
def _validate_topic(topic: object, path: Path, index: int) -> None:
"""Reject topic data that would otherwise be silently discarded or coerced."""
prefix = f"Invalid interests YAML at {path}: topics[{index}]"
if not isinstance(topic, dict):
raise ValueError(f"{prefix} must be an object")
allowed = {"title", "reason", "evidence", "keywords", "paths"}
if unknown := sorted(set(topic) - allowed):
raise ValueError(f"{prefix} has unknown field(s): {', '.join(str(key) for key in unknown)}")
for field in ("title", "reason"):
value = topic.get(field)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{prefix}.{field} must be a non-empty string")
if "evidence" in topic and not isinstance(topic["evidence"], str):
raise ValueError(f"{prefix}.evidence must be a string")
for field in ("keywords", "paths"):
if field not in topic:
continue
values = topic[field]
if not isinstance(values, list) or any(not isinstance(value, str) or not value.strip() for value in values):
raise ValueError(f"{prefix}.{field} must be a list of non-empty strings")
def _clean_legacy_topic(raw: dict) -> dict:
"""Normalize the v1 topic shape returned by the compatibility reader."""
title = str(raw.get("title") or "").strip()
reason = 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 []),
}
# ---------------------------------------------------------------------------
# Frozen identity contract (A7 / INV-4)
# ---------------------------------------------------------------------------
def normalize_topic(title: str) -> str:
"""NFKC -> casefold -> keep only chars whose category starts with L/N.
Frozen contract (INV-4): removing all whitespace/punctuation means any
modification would drift every historical topic id.
"""
text = unicodedata.normalize("NFKC", title or "").casefold()
return "".join(ch for ch in text if unicodedata.category(ch)[0] in ("L", "N"))
def topic_id(title: str) -> str:
"""Stable topic identity: ``sha1(normalize_topic(title))[:12]``."""
return hashlib.sha1(normalize_topic(title).encode("utf-8")).hexdigest()[:12]
# ---------------------------------------------------------------------------
# Paths and material set M (F2.0)
# ---------------------------------------------------------------------------
def state_file_path(ws: Path, daily: str = "daily") -> Path:
"""Truth-source path ``daily/_proactive.yaml``."""
return ws / daily / PROACTIVE_STATE_NAME
def interests_path_for(ws: Path, daily: str, day: str) -> Path:
"""Exposure-product path ``daily/<day>/interests.yaml``."""
return ws / daily / day / INTERESTS_NAME
def norm_path(rel) -> str:
"""Normalize a workspace-relative path (posix, no leading ./)."""
text = str(rel or "").strip().replace("\\", "/")
while text.startswith("./"):
text = text[2:]
return text
def scan_material_daily(ws: Path, day: str, daily: str, scan_days: int) -> list[str]:
"""M_daily: chunk notes in the scan window, minus day indexes and ``_*`` files (INV-11)."""
out: list[str] = []
for scan_day in recent_dates(day, scan_days):
day_index = f"{daily}/{scan_day}.md"
for rel in scan_day_files(ws, scan_day, daily):
rel = norm_path(rel)
base = rel.rsplit("/", 1)[-1]
if rel == day_index or base.startswith("_"):
continue
if rel not in out:
out.append(rel)
return sorted(out)
# External source records the reminder branch may read directly. Binary sources
# are deliberately absent: ``auto_resource`` interprets them into daily cards,
# which already reach extraction through ``scan_material_daily``.
RESOURCE_NOTICE_SUFFIXES = (
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".csv",
".tsv",
".html",
".eml",
".ics",
)
def _mtime_day(path: Path) -> str:
"""Local date a file last changed, or ``""`` when it cannot be stat'ed."""
try:
return dt.date.fromtimestamp(path.stat().st_mtime).isoformat()
except OSError:
return ""
def scan_material_resource(
ws: Path,
day: str,
scan_days: int,
resource: str = "resource",
) -> tuple[list[str], int]:
"""M_resource: external source records that may carry notices for the user.
Mirrors the ``resource/`` entry-point semantics of Auto Resource: a dated
directory ``resource/<YYYY-MM-DD>/...`` belongs to that date, while a file
directly under ``resource/`` (or under a non-dated folder) belongs to the
day it landed, i.e. its mtime. Returns ``(paths, skipped_binary)``; only
text-readable suffixes are returned, because a binary source has to be
interpreted by ``auto_resource`` before anyone can quote it.
This is the reserved hook for future external source interfaces: whatever
writes into ``resource/`` becomes eligible for the reminder review without
any further change to the proactive chain.
"""
window = set(recent_dates(day, scan_days))
root = ws / resource
if not window or not root.is_dir():
return [], 0
out: list[str] = []
skipped = 0
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = norm_path(path.relative_to(ws).as_posix())
parts = rel.split("/")
if any(part.startswith("_") or part.startswith(".") for part in parts[1:]):
continue
owner = parts[1] if len(parts) > 2 and safe_date(parts[1]) else _mtime_day(path)
if owner not in window:
continue
if path.suffix.lower() not in RESOURCE_NOTICE_SUFFIXES:
skipped += 1
continue
if rel not in out:
out.append(rel)
return sorted(out), skipped
# ---------------------------------------------------------------------------
# Truth-source state file daily/_proactive.yaml (F1.3)
# ---------------------------------------------------------------------------
def load_state(ws: Path, daily: str = "daily") -> tuple[ProactiveStateFile, bool]:
"""Load the truth source; returns ``(state_file, needs_bootstrap)``.
A missing file means first run (fresh workspace or upgrade) and triggers
the one-time F1.4 bootstrap from interests.yaml history. A corrupt or
invalid file rebuilds empty WITHOUT bootstrap (spec F1.3/A2/A5), as does
an existing file that already carries the ``open_topics`` key (an empty
list is a normal state, not a trigger).
"""
path = state_file_path(ws, daily)
if not path.is_file():
logger.info(f"proactive state file missing, first-run bootstrap scheduled: {path}")
return ProactiveStateFile(), True
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("state file is not a mapping")
except Exception as e: # noqa: BLE001
logger.warning(f"proactive state file corrupt, rebuilding empty: {path} ({e})")
return ProactiveStateFile(), False
needs_bootstrap = "open_topics" not in data
try:
state = ProactiveStateFile.model_validate(data)
except Exception as e: # noqa: BLE001
# Salvage entry by entry instead of discarding the file: the schedule
# registry is designed to outlive topics by months, so one malformed
# hand-edited entry must not silently delete every long-lived reminder.
# Still no bootstrap - the file exists, so this is not a first run.
state, dropped = _salvage_state(data)
logger.warning(
f"proactive state file invalid, salvaged entry by entry: {path} "
f"(dropped {dropped} unreadable entr(ies); kept open_topics={len(state.open_topics)} "
f"schedules={len(state.schedules)}; {e})",
)
return state, False
return state, needs_bootstrap
# Truth-source sections whose entries are validated one by one when the whole
# file fails to parse. ``resolved`` is a plain dict list, so it only needs the
# non-mapping items filtered out.
_STATE_SECTIONS = (("open_topics", ProactiveTopic), ("schedules", ProactiveSchedule))
def _salvage_state(data: dict) -> tuple[ProactiveStateFile, int]:
"""Rebuild a partly-unreadable truth source, dropping only the bad entries.
Returns the salvaged model and the number of entries that had to be
dropped. Never raises: a section that is not even a list is treated as
absent, exactly like a file written before that section existed.
"""
sections: dict[str, list] = {}
dropped = 0
for key, model in _STATE_SECTIONS:
raw = data.get(key)
raw = raw if isinstance(raw, list) else []
kept = []
for item in raw:
try:
kept.append(model.model_validate(item))
except Exception: # noqa: BLE001
dropped += 1
sections[key] = kept
raw_resolved = data.get("resolved")
resolved = [item for item in (raw_resolved if isinstance(raw_resolved, list) else []) if isinstance(item, dict)]
version = data.get("version")
state = ProactiveStateFile(
version=version if isinstance(version, int) else 1,
resolved=resolved,
**sections,
)
return state, dropped
async def save_state(ws: Path, state_file: ProactiveStateFile, daily: str = "daily") -> None:
"""Atomically persist the truth source (path lock + tmp file + os.replace)."""
path = state_file_path(ws, daily)
lock = await get_path_lock(path)
async with lock:
path.parent.mkdir(parents=True, exist_ok=True)
rendered = yaml.safe_dump(state_file.model_dump(), allow_unicode=True, sort_keys=False)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(rendered if rendered.endswith("\n") else f"{rendered}\n")
os.replace(tmp, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
def safe_date(text: str) -> dt.date | None:
"""Parse an ISO ``YYYY-MM-DD`` string, returning None when it is not one."""
try:
return dt.date.fromisoformat(str(text or "").strip())
except ValueError:
return None
async def load_carry_forward(
ws: Path,
state_file: ProactiveStateFile,
day: str,
days: int,
top_k: int,
daily: str = "daily",
needs_bootstrap: bool = False,
) -> tuple[list[ProactiveTopic], list[ProactiveTopic]]:
"""Return ``(carry_forward_all, carry_forward_prompt)`` sorted per A4 rule 1.
Bootstraps the truth source from interests.yaml history exactly once on
first run (missing state file) or when an existing file lacks the
``open_topics`` key (F1.4). Over-age topics are dropped here with a log;
resolved ids are suppressed.
"""
if needs_bootstrap:
state_file.open_topics = _bootstrap_from_history(ws, day, days, daily)
await save_state(ws, state_file, daily)
resolved_ids = {str(r.get("id") or "") for r in state_file.resolved if isinstance(r, dict)}
base = safe_date(day)
open_topics: list[ProactiveTopic] = []
expired = 0
for topic in state_file.open_topics:
if topic.id and topic.id in resolved_ids:
continue
first_seen = safe_date(topic.first_seen)
# Boundary aligned with trim_state_file/_expiry_cutoff: age >= days is
# over-age everywhere, so a topic never enters the prompt in the same
# round it gets pruned from the truth source (audit item 9).
if base is not None and first_seen is not None and (base - first_seen).days >= int(days):
expired += 1
continue
open_topics.append(topic)
if expired:
logger.info(f"proactive carry-forward dropped {expired} over-age topic(s) (window={days}d)")
ordered = sort_topics(open_topics)
return ordered, ordered[: max(int(top_k), 0)]
def _bootstrap_from_history(ws: Path, day: str, days: int, daily: str) -> list[ProactiveTopic]:
"""One-time bootstrap: newest record per id wins, first_seen takes the min (F1.4)."""
if safe_date(day) is None:
return []
records: dict[str, ProactiveTopic] = {}
first_seen: dict[str, str] = {}
for file_date in reversed(recent_dates(day, days)): # newest -> oldest
data = read_interests_data(interests_path_for(ws, daily, file_date))
if not data:
continue
topics, _is_v1, _push = parse_interests_topics(data, file_date)
for topic in topics:
anchor = topic.first_seen or file_date
if topic.id not in first_seen or anchor < first_seen[topic.id]:
first_seen[topic.id] = anchor
records.setdefault(topic.id, topic)
out: list[ProactiveTopic] = []
for tid, topic in records.items():
topic.first_seen = first_seen.get(tid) or topic.first_seen or day
out.append(topic)
logger.info(f"proactive bootstrap built {len(out)} open topic(s) from interests.yaml history")
return out
def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None:
"""Prune budget/exposure/resolved windows and over-age/resolved open topics."""
base = safe_date(day)
if base is None:
return
cutoff = (base - dt.timedelta(days=max(int(days), 0))).isoformat()
state_file.resolved = [
r for r in state_file.resolved if isinstance(r, dict) and str(r.get("resolved_at") or "") > cutoff
]
resolved_ids = {str(r.get("id") or "") for r in state_file.resolved}
kept: list[ProactiveTopic] = []
for topic in state_file.open_topics:
if topic.id and topic.id in resolved_ids:
continue
first_seen = safe_date(topic.first_seen)
if first_seen is not None and first_seen.isoformat() <= cutoff:
continue
kept.append(topic)
state_file.open_topics = kept
# ---------------------------------------------------------------------------
# interests.yaml read/render (F1.2 / A2 / A4)
# ---------------------------------------------------------------------------
def quarantine_interests(path: Path, error: Exception) -> None:
"""Rename a corrupt interests.yaml aside (A2): ``interests.corrupt-<ts>.yaml``."""
stamp = int(time.time())
corrupt = path.with_name(f"interests.corrupt-{stamp}.yaml")
try:
path.rename(corrupt)
logger.warning(f"quarantined corrupt interests file {path} -> {corrupt.name}: {error}")
except OSError:
logger.warning(f"corrupt interests file {path}: {error}")
def read_interests_file(path: Path) -> tuple[str, dict] | None:
"""Read interests.yaml returning ``(raw_text, data)``; quarantine corrupt files (A2)."""
if not path.is_file():
return None
try:
raw_text = path.read_text(encoding="utf-8")
data = yaml.safe_load(raw_text)
if not isinstance(data, dict):
raise ValueError("interests.yaml is not a mapping")
return raw_text, data
except Exception as e: # noqa: BLE001
quarantine_interests(path, e)
return None
def read_interests_data(path: Path) -> dict | None:
"""Parse interests.yaml; quarantine corrupt files (A2) and return None."""
loaded = read_interests_file(path)
return loaded[1] if loaded else None
def parse_interests_topics(data: dict, file_date: str) -> tuple[list[ProactiveTopic], bool, bool]:
"""Return ``(topics, is_v1, push)`` with A2 fallbacks applied.
Missing ``first_seen``/``last_evidence_at`` fall back to the file date
(not today); missing ids are derived from the frozen title hash.
"""
is_v1 = data.get("version") is None
push = data.get("push", True)
if not isinstance(push, bool):
push = True
raw_topics = data.get("topics") or []
topics: list[ProactiveTopic] = []
for raw in raw_topics if isinstance(raw_topics, list) else []:
if not isinstance(raw, dict):
continue
title = str(raw.get("title") or "").strip()
reason = str(raw.get("reason") or "").strip()
if not title or not reason:
continue
topics.append(
ProactiveTopic(
id=str(raw.get("id") or "").strip() or topic_id(title),
title=title,
reason=reason,
kind=raw.get("kind", DEFAULT_KIND),
confidence=raw.get("confidence", 0.5),
first_seen=str(raw.get("first_seen") or "").strip() or file_date,
last_evidence_at=str(raw.get("last_evidence_at") or "").strip() or file_date,
evidence=str(raw.get("evidence") or "").strip()[:120],
paths=raw.get("paths") or [],
schedule_id=str(raw.get("schedule_id") or "").strip(),
due_at=str(raw.get("due_at") or "").strip(),
),
)
return topics, is_v1, push
def sort_topics(topics: list) -> list:
"""Order: last_evidence_at desc -> kind priority -> confidence desc -> id asc.
Freshness is the primary key (v5 aging fix): stale topics sink below newly
evidenced ones of any kind, so long-lived follow_ups cannot permanently
crowd out new discoveries.
Kind priority is ``KIND_PRIORITY`` (reminder > follow_up > new_idea >
interest_extend), so a reminder always outranks discovery kinds inside the
same freshness band and survives the ``max_topics`` render cap.
"""
def get(topic, key):
return getattr(topic, key) if not isinstance(topic, dict) else topic.get(key)
out = sorted(topics, key=lambda t: str(get(t, "id") or ""))
out.sort(key=lambda t: clamp_confidence(get(t, "confidence")), reverse=True)
out.sort(key=lambda t: kind_priority(get(t, "kind")))
out.sort(key=lambda t: str(get(t, "last_evidence_at") or ""), reverse=True)
return out
def dump_topic(topic) -> dict:
"""Render one topic as an A2-ordered dict for interests.yaml v2."""
get = (lambda k: getattr(topic, k)) if not isinstance(topic, dict) else topic.get
dumped = {
"id": str(get("id") or ""),
"title": str(get("title") or ""),
"reason": str(get("reason") or ""),
"kind": str(get("kind") or DEFAULT_KIND),
"confidence": clamp_confidence(get("confidence")),
"first_seen": str(get("first_seen") or ""),
"last_evidence_at": str(get("last_evidence_at") or ""),
"evidence": str(get("evidence") or "")[:120],
"paths": [str(p) for p in (get("paths") or [])],
}
# Reminder links are emitted only when present so the v2 topic shape of
# every other kind stays byte-identical.
if schedule_id := str(get("schedule_id") or ""):
dumped["schedule_id"] = schedule_id
if due_at := str(get("due_at") or ""):
dumped["due_at"] = due_at
return dumped
def render_interests(
day: str,
topics: list,
push: bool,
now_dt: dt.datetime,
agenda: list | None = None,
suppressed: list | None = None,
) -> dict:
"""Render the full v2 file content from the truth source (INV-6).
v5: ``skip_reason`` is no longer persisted (no consumer); it survives as
structured log/metadata on ``ProactiveState.file_skip_reason`` (R7).
The plan/agenda enrichment keys (``agenda``/``suppressed``) are only
present when the agenda step rendered the file; the topics-only renderer
passes ``None`` so its file shape stays unchanged.
"""
rendered = {
"version": 2,
"date": day,
"generated_at": now_dt.isoformat(timespec="seconds"),
"push": bool(push),
"topics": [dump_topic(t) for t in topics],
}
if agenda is not None:
rendered["agenda"] = list(agenda)
if suppressed is not None:
rendered["suppressed"] = list(suppressed)
return rendered
def write_interests_if_changed(ws: Path, path: Path, rendered: dict) -> bool: # pylint: disable=unused-argument
"""Apply A4 render-write rules (idempotent skip + atomic replace).
v5 (R1): the "push=false never overwrites nightly v1" special case is
gone; ``push`` is derived from the cumulative truth source, so re-renders
are monotonic and need no guard.
"""
existing: dict | None = None
if path.is_file():
existing = read_interests_data(path)
if existing is not None:
existing_push = existing.get("push", True)
if not isinstance(existing_push, bool):
existing_push = True
if (
existing_push == bool(rendered.get("push"))
and existing.get("topics") == rendered.get("topics")
and (existing.get("agenda") or []) == (rendered.get("agenda") or [])
and (existing.get("suppressed") or []) == (rendered.get("suppressed") or [])
):
return False
path.parent.mkdir(parents=True, exist_ok=True)
payload = yaml.safe_dump(rendered, allow_unicode=True, sort_keys=False)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(payload if payload.endswith("\n") else f"{payload}\n")
os.replace(tmp, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
return True
# ---------------------------------------------------------------------------
# Candidate cleaning (extract side, A3)
# ---------------------------------------------------------------------------
def clean_candidate(raw, allowed_paths: set[str], kind: str, day: str) -> dict:
"""Clean one LLM candidate; drop entries whose paths escape M (INV-8)."""
if not isinstance(raw, dict):
return {}
title = str(raw.get("title") or "").strip()
reason = str(raw.get("reason") or "").strip()
paths = clean_paths(raw.get("paths"), allowed_paths)
if not title or not reason or not paths:
return {}
return {
"id": topic_id(title),
"title": title,
"reason": reason,
"kind": kind if kind in TOPIC_KINDS else DEFAULT_KIND,
"confidence": clamp_confidence(raw.get("confidence")),
"first_seen": day,
"last_evidence_at": day,
# Derived, not LLM-emitted: the first whitelisted path anchors the
# candidate, so the output contract stays one field smaller.
"evidence": paths[0][:120],
"paths": paths,
}
def current_now(step) -> dt.datetime:
"""Business-time access per INV-3 (timezone-aware; never datetime.now())."""
tz = step.app_context.app_config.timezone if step.app_context is not None else None
return now(tz)
def parse_extract_reply(text: str) -> dict:
"""Parse the A3 fenced YAML/JSON output; fenced blocks take priority.
A reply only counts as parsed when at least one contract section
(``EXTRACT_SECTIONS``) is present as a list; a non-empty mapping with
misspelled or missing section names is a parse failure, so
``_extract_with_retry`` retries instead of checkpointing changed files
on output that can never yield topics.
Unlike the dream parser there is no scalar-mapping fallback: proactive
output is sectioned lists, and a partial fallback would corrupt updates.
"""
candidates = [m.group(1).strip() for m in re.finditer(r"```(?:json|ya?ml)?\s*(.*?)```", text, re.S | re.I)]
candidates.append((text or "").strip())
for raw in candidates:
if not raw:
continue
try:
data = yaml.safe_load(raw)
except yaml.YAMLError:
continue
if isinstance(data, dict) and data and any(isinstance(data.get(key), list) for key in EXTRACT_SECTIONS):
return data
return {}
def strip_frontmatter(text: str) -> tuple[dict, str]:
"""Split leading ``---`` YAML frontmatter blocks from the body.
Handles files with several consecutive frontmatter blocks; returns
``(merged_meta, body)``. Parse failures degrade to empty meta.
"""
meta: dict = {}
body = text or ""
while True:
stripped = body.lstrip()
if not stripped.startswith("---"):
break
rest = stripped[3:]
end = rest.find("\n---")
if end < 0:
break
block = rest[:end]
body = rest[end + 4 :]
try:
data = yaml.safe_load(block)
except yaml.YAMLError:
data = None
if isinstance(data, dict):
for key, value in data.items():
meta.setdefault(str(key), value)
return meta, body.strip()
def _contained_workspace_path(ws: Path, rel_path: str) -> Path | None:
"""Resolve ``rel_path`` strictly inside the workspace (audit item 7).
Rejects absolute paths, home-relative paths and ``..`` traversal so the
profile fallback can never read (and then feed into prompts) files that
live outside the workspace.
"""
candidate = Path(rel_path)
if candidate.is_absolute() or candidate.anchor or rel_path.startswith("~") or ".." in candidate.parts:
return None
workspace = ws.resolve()
resolved = (workspace / candidate).resolve()
try:
resolved.relative_to(workspace)
except ValueError:
return None
return resolved
def load_personal_profile_block(
ws: Path,
digest_dir: str,
max_chars: int,
fallback_rel_path: str = "",
) -> str:
"""Build the user profile/preference block used to personalize proactive inference.
Primary source is ``<digest_dir>/personal/*.md`` (one file per profile
facet: identity, preferences, constraints). Each file contributes its
frontmatter ``description`` plus a body excerpt under an equal per-file
budget. Falls back to a single profile file (legacy ``profile.md``) when
the personal directory is absent, and to a sentinel when nothing exists.
"""
max_chars = int(max_chars)
if max_chars <= 0:
return "(no user profile)"
personal_dir = ws / digest_dir / "personal"
if personal_dir.is_dir():
files = sorted(path for path in personal_dir.glob("*.md") if path.is_file())
if files:
per_file = max(max_chars // len(files), 200)
sections: list[str] = []
for path in files:
try:
raw = path.read_text(encoding="utf-8")
except OSError:
continue
meta, body = strip_frontmatter(raw)
title = str(meta.get("name") or path.stem)
description = str(meta.get("description") or "").strip()
excerpt = body[:per_file].strip()
section = f"### {title}\n"
if description:
section += f"{description}\n"
if excerpt:
section += excerpt
sections.append(section.rstrip())
if sections:
return "\n\n".join(sections)[:max_chars]
if fallback_rel_path:
fallback = _contained_workspace_path(ws, fallback_rel_path)
if fallback is not None and fallback.is_file():
try:
text = fallback.read_text(encoding="utf-8")[:max_chars]
except OSError:
return "(no user profile)"
return text.strip() or "(no user profile)"
return "(no user profile)"
CAPABILITY_SECTIONS = (
("procedure", "procedure (reusable experience, skills, tool/API know-how the agent already has)"),
("wiki", "wiki (existing knowledge nodes of the personal knowledge base)"),
)
def load_capability_block(ws: Path, digest_dir: str, max_chars: int) -> str:
"""Inventory of what the agent can already do, used by the ``new_ideas`` branch.
Reads only rebuildable digest nodes: ``<digest_dir>/procedure/*.md``
(accumulated experience, skills, tool/API know-how) and
``<digest_dir>/wiki/*.md`` (knowledge nodes an interest can extend).
Each node contributes its frontmatter ``description`` so proposals stay
grounded in resources that actually exist instead of invented capabilities.
"""
max_chars = int(max_chars)
if max_chars <= 0:
return "(no capability inventory)"
per_section = max(max_chars // max(len(CAPABILITY_SECTIONS), 1), 200)
sections: list[str] = []
for subdirectory, heading in CAPABILITY_SECTIONS:
lines = _digest_inventory_lines(ws / digest_dir / subdirectory, per_section)
if lines:
sections.append(f"### {heading}\n" + "\n".join(lines))
if not sections:
return "(no capability inventory)"
return "\n\n".join(sections)[:max_chars]
def _digest_inventory_lines(directory: Path, budget: int) -> list[str]:
"""``- <title>: <description>`` lines for one digest subdirectory, budget-bounded."""
if not directory.is_dir():
return []
lines: list[str] = []
used = 0
for path in sorted(p for p in directory.glob("*.md") if p.is_file()):
try:
raw = path.read_text(encoding="utf-8")
except OSError:
continue
meta, _body = strip_frontmatter(raw)
title = str(meta.get("name") or path.stem)
description = " ".join(str(meta.get("description") or "").split())
line = f"- {title}" + (f": {description[:160]}" if description else "")
if used + len(line) > budget:
break
lines.append(line)
used += len(line) + 1
return lines
def parse_fenced_yaml(text: str) -> dict:
"""Parse the first fenced YAML/JSON block (or the bare text) into a dict.
Shared by the plan/agenda reply parsers; returns ``{}`` when nothing
parses to a mapping.
"""
candidates = [m.group(1).strip() for m in re.finditer(r"```(?:json|ya?ml)?\s*(.*?)```", text, re.S | re.I)]
candidates.append((text or "").strip())
for raw in candidates:
if not raw:
continue
try:
data = yaml.safe_load(raw)
except yaml.YAMLError:
continue
if isinstance(data, dict):
return data
return {}
def parse_plan_reply(text: str) -> list[dict]:
"""Parse the plan step reply; requires a ``cards`` list of mappings."""
data = parse_fenced_yaml(text)
cards = data.get("cards")
if not isinstance(cards, list):
return []
return [card for card in cards if isinstance(card, dict)]
def parse_agenda_reply(text: str) -> tuple[list[dict], list[dict]]:
"""Parse the agenda step reply into ``(agenda, suppressed)`` lists.
``agenda`` must be present as a list; ``suppressed`` defaults to empty.
"""
data = parse_fenced_yaml(text)
agenda = data.get("agenda")
if not isinstance(agenda, list):
return [], []
suppressed = data.get("suppressed")
if not isinstance(suppressed, list):
suppressed = []
return [item for item in agenda if isinstance(item, dict)], [item for item in suppressed if isinstance(item, dict)]
def resolve_agent_wrapper(step):
"""Return the step's agent_wrapper, falling back to the app default (F4.4)."""
wrapper = step.agent_wrapper
if wrapper is not None:
return wrapper
if step.app_context is not None:
fallback = step.app_context.components.get(ComponentEnum.AGENT_WRAPPER, {}).get("default")
if fallback is not None:
configured = step.kwargs.get("agent_wrapper", "default")
step.logger.warning(f"[{step.name}] agent_wrapper '{configured}' missing; using default")
return fallback
return None