Files
2026-07-13 13:04:19 +08:00

346 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Plan / todo state — pure stdlib, no UI framework dependency.
API:
extract(text) → [(content, "open"|"done"), …]
is_active(agent, messages=None) → plan mode on (stash OR per-session msg ref)
resolve_path(agent, messages=None) → live plan.md path (or None)
find_path_in_messages(messages) → most recent plan.md path mentioned
current_step(messages) → latest `当前步骤:…` snippet (or "")
summary(items) → (n_done, n_total)
is_complete(items) → all done (or empty)
Supported task-line shapes (all matched by `extract`):
- [ ] foo ← bullet + open
- [x] foo ← bullet + done
1. [✓] foo ← numbered + done
2. [✓ 2026-05-16] foo ← numbered + timestamped done, content after bracket
3. [✓ 已生成: foo] ← numbered + done with description *inside* bracket
4. [D][P] foo ← two marker groups (delegate + parallel), still open
5. [D] foo ← non-standard marker "D" → open (not done)
"""
from __future__ import annotations
import os, re
from typing import Any, Optional
_DONE_CHARS = set("xX✓✔√☑")
# Newline-insert before a bullet stuck to JSON debris (`{"content": "- [ ] …`).
_GLUE_RE = re.compile(r"(?<!\n)((?:[-*+]|\d+\s*[.)、:]) \[)")
_BULLET_RE = re.compile(r"^\s*(?:[-*+]|\d+\s*[.)、:])\s+")
_BRACKET_RE = re.compile(r"\[([^\]]*)\]")
# Strip `✓ ` / `x ` / timestamp prefix when bracket content is used as title.
_INLINE_STRIP_RE = re.compile(
r"^[" + re.escape("".join(_DONE_CHARS)) + r"]\s*(?:\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}(?::\d{2})?\s*)?"
)
_DEBRIS_RE = re.compile(r'["\\<].*$')
# Strip markdown emphasis since planbar renders rich.Text, not Markdown.
_MD_EMPHASIS_RE = re.compile(
r"\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|__([^_\n]+)__|_([^_\n]+)_|`([^`\n]+)`"
)
def _strip_md(s: str) -> str:
return _MD_EMPHASIS_RE.sub(lambda m: next(g for g in m.groups() if g is not None), s)
def _has_done_glyph(marker: str) -> bool:
return any(c in _DONE_CHARS for c in marker)
def extract(text: str) -> list[tuple[str, str]]:
if not text: return []
norm = text.replace("\\n", "\n") if "\\n" in text else text
norm = _GLUE_RE.sub(r"\n\1", norm)
found: dict[str, str] = {}
for line in norm.splitlines():
head = _BULLET_RE.match(line)
if not head: continue
rest = line[head.end():]
groups: list[str] = []
# Consume any number of consecutive `[...]` groups — covers `[D][P]`
# task-type chains as well as the plain `[ ]` / `[x]` single form.
while True:
b = _BRACKET_RE.match(rest)
if not b: break
groups.append(b.group(1))
rest = rest[b.end():]
if not groups: continue
is_done = any(_has_done_glyph(g) for g in groups)
inline = rest.strip()
if inline:
content = inline
elif is_done:
# `[✓ description]` shape — description lives inside the bracket
# next to the glyph. Strip the glyph + optional timestamp.
done_g = next(g for g in groups if _has_done_glyph(g))
content = _INLINE_STRIP_RE.sub("", done_g).strip()
else:
continue
k = _strip_md(_DEBRIS_RE.sub("", content).strip())
if not k: continue
status = "done" if is_done else "open"
# Same content seen twice — done wins over open.
if k not in found or status == "done":
found[k] = status
return list(found.items())
def _stashed_plan_path(agent) -> str:
# First non-empty `working['in_plan_mode']` from (handler, agent).
for src in (getattr(agent, "handler", None), agent):
p = ((getattr(src, "working", None) or {}).get("in_plan_mode") or "").strip()
if p: return p
return ""
def _resolve_stashed(p: str) -> Optional[str]:
if not p: return None
rel = p.lstrip("./\\")
cwd = os.getcwd()
for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
if os.path.isfile(c) and os.path.getsize(c) > 0: return c
return None
# Strict per-session discovery — scan this session's own messages only.
_PATH_RE = re.compile(r"""((?:\.\/)?(?:temp\/)?plan_[A-Za-z0-9_\-]+\/plan\.md)""")
def _slice(messages, start_idx: int):
if not messages: return []
if start_idx <= 0: return list(messages)
return list(messages)[start_idx:]
def find_path_in_messages(messages, start_idx: int = 0) -> Optional[str]:
"""Latest existing `plan_XXX/plan.md` referenced after `start_idx`.
Items can be `ChatMessage`-like (`.content`) or plain strings;
only paths that exist on disk are returned."""
sliced = _slice(messages, start_idx)
if not sliced: return None
for m in reversed(sliced):
text = getattr(m, "content", None)
if text is None: text = m if isinstance(m, str) else ""
if not text or "plan.md" not in text: continue
for hit in reversed(_PATH_RE.findall(text)):
p = _resolve_stashed(hit.strip().strip("\"'"))
if p: return p
return None
# Prefer concise `<summary>` narrative over the long plan-item echo;
# treat `❌ 当前步骤:` as "step done", not "current step".
_SUMMARY_STEP_RE = re.compile(
r"<summary>[^<]*?当前步骤[:]\s*([^<\n]{1,160})</summary>", re.DOTALL)
_STEP_RE = re.compile(r"📌\s*当前步骤[:]\s*([^\n。!!?]{1,160})")
_DONE_STEP_RE = re.compile(r"❌\s*当前步骤[:]")
def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str:
"""Latest `当前步骤:…` snippet; `<summary>` form preferred, `❌`-prefixed
skipped. Trimmed to `max_len` chars so it fits the 5-row plan card."""
sliced = _slice(messages, start_idx)
if not sliced: return ""
def _clean(s: str) -> str:
return _strip_md(re.sub(r"\s+", " ", s).strip().rstrip(" :—-"))
def _cap(s: str) -> str:
s = _clean(s)
if len(s) <= max_len: return s
return s[:max_len - 1].rstrip() + "…"
for m in reversed(sliced):
text = getattr(m, "content", None)
if text is None: text = m if isinstance(m, str) else ""
if not text or "当前步骤" not in text: continue
hits = _SUMMARY_STEP_RE.findall(text)
if hits: return _cap(hits[-1])
for raw in reversed(_STEP_RE.findall(text)):
if _DONE_STEP_RE.search(raw): continue
return _cap(raw)
return ""
def is_active(agent, messages=None, start_idx: int = 0,
restored_path: str = "") -> bool:
"""Plan mode is on. Primary: `working['in_plan_mode']`. Then
`restored_path` — a path recovered from the transcript's structured
`enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry);
unlike the message scan it cannot be spoofed by a path typed in chat.
Legacy fallback: a `plan_*/plan.md` referenced in this session's messages
(no global scan) — only consulted when `messages` is passed."""
if _stashed_plan_path(agent): return True
if restored_path and _resolve_stashed(restored_path): return True
return find_path_in_messages(messages, start_idx) is not None
def resolve_path(agent, messages=None, start_idx: int = 0,
restored_path: str = "") -> Optional[str]:
p = _resolve_stashed(_stashed_plan_path(agent))
if p: return p
if restored_path:
p = _resolve_stashed(restored_path)
if p: return p
return find_path_in_messages(messages, start_idx)
def summary(items: list[tuple[str, str]]) -> tuple[int, int]:
return sum(1 for _, st in items if st == "done"), len(items)
def is_complete(items: list[tuple[str, str]]) -> bool:
return not items or all(st == "done" for _, st in items)
# --- Desktop bridge only (APIs above unchanged) ---
_ENTER_PLAN_RE = re.compile(r"""enter_plan_mode\s*\(\s*["']([^"']+)["']""", re.I)
def _msg_content(m) -> str:
if isinstance(m, str): return m
c = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
return c if isinstance(c, str) else ""
def plan_path_mention_in_messages(messages, start_idx: int = 0) -> Optional[str]:
for m in reversed(_slice(messages, start_idx)):
text = _msg_content(m)
if not text: continue
if "enter_plan_mode" in text and (hit := _ENTER_PLAN_RE.search(text)):
return hit.group(1).strip().strip("\"'")
if "plan.md" in text and (hits := _PATH_RE.findall(text)):
return hits[-1].strip().strip("\"'")
return None
def _resolve_stashed_at(p: str, root: str) -> Optional[str]:
if not p or not root: return None
rel = p.lstrip("./\\")
cwd = root.rstrip("/\\")
for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
if os.path.isfile(c) and os.path.getsize(c) > 0: return c
return None
def _find_path_at(messages, start_idx: int, root: str) -> Optional[str]:
for m in reversed(_slice(messages, start_idx)):
text = _msg_content(m)
if not text or "plan.md" not in text: continue
for hit in reversed(_PATH_RE.findall(text)):
if p := _resolve_stashed_at(hit.strip().strip("\"'"), root): return p
return None
def default_session_plan_path(session_id: str) -> str:
sid = (session_id or "sess").replace("/", "_")
return f"temp/plan_{sid}/plan.md"
def is_session_scoped_plan_path(path: str, session_id: str) -> bool:
"""Desktop: only bind/adopt paths under this session's plan_{id}/ tree."""
if not path:
return False
sid = (session_id or "sess").replace("/", "_")
norm = path.replace("\\", "/").lstrip("./")
return f"plan_{sid}/" in norm or norm.endswith(f"plan_{sid}/plan.md")
def is_plan_preset_prompt(prompt: str) -> bool:
p = (prompt or "").lower()
return "plan_sop" in p or "plan 模式" in p or "plan mode" in p
def bind_plan_session(sess: Any, prompt: str = "", path: Optional[str] = None) -> str:
"""Bind plan card to this session only (avoids sharing plan_demo/ across sessions)."""
rel = (path or "").strip() or default_session_plan_path(getattr(sess, "id", "") or "")
sess.plan_path = rel
msgs = list(getattr(sess, "messages", []) or [])
sess.plan_scan_baseline = len(msgs)
return rel
def sync_plan_path_from_text(sess: Any, text: str, root: str) -> None:
"""If agent emits enter_plan_mode(...), keep session bound path in sync."""
if not text:
return
m = _ENTER_PLAN_RE.search(text)
if not m:
return
raw = m.group(1).strip().strip("\"'")
if not raw:
return
sess.plan_path = raw.lstrip("./")
def session_plan_active(sess: Any, agent, messages, start_idx: int, root: str) -> bool:
"""Desktop: active when this session has a bound plan_path (not global plan_*/ scan)."""
bound = (getattr(sess, "plan_path", None) or "").strip()
if not bound:
return False
if _stashed_plan_path(agent):
return True
if _resolve_stashed_at(bound, root):
return True
if getattr(sess, "status", "") == "running":
return True
# Plan preset bound this session — keep placeholder until plan.md appears or path changes
if is_session_scoped_plan_path(bound, getattr(sess, "id", "")):
return True
return False
def session_plan_resolve(bound: str, root: str) -> Optional[str]:
if not bound:
return None
return _resolve_stashed_at(bound.strip(), root)
def _desktop_resolve_plan_file(sess: Any, bound: str, root: str, agent, messages, base: int) -> Optional[str]:
"""Desktop read path: bound → agent stash → per-session message scan."""
path = session_plan_resolve(bound, root)
if path:
return path
if agent and (stash := _stashed_plan_path(agent)):
if p := _resolve_stashed_at(stash, root):
return p
return _find_path_at(messages, base, root)
def desktop_plan_payload_from_session(sess: Any, ga_root: str = "") -> dict:
"""Per-session plan card — bound plan_path + tuiapp_v2-style item/placeholder/complete."""
raw = list(getattr(sess, "messages", []) or [])
base = max(0, int(getattr(sess, "plan_scan_baseline", 0) or 0))
if base > len(raw):
base = len(raw)
root = (getattr(sess, "cwd", None) or ga_root or "").strip()
agent = getattr(sess, "agent", None)
partial = getattr(sess, "partial", None)
if isinstance(partial, dict) and isinstance(partial.get("content"), str):
sync_plan_path_from_text(sess, partial["content"], root)
sid = getattr(sess, "id", "") or ""
if not (getattr(sess, "plan_path", None) or "").strip():
mentioned = plan_path_mention_in_messages(raw, base)
if mentioned and is_session_scoped_plan_path(mentioned, sid):
sess.plan_path = mentioned.lstrip("./")
else:
return {"active": False}
if not session_plan_active(sess, agent, raw, base, root):
return {"active": False}
bound = getattr(sess, "plan_path", "") or ""
path = _desktop_resolve_plan_file(sess, bound, root, agent, raw, base)
items = []
if path:
try:
items = [{"content": c, "status": st} for c, st in extract(open(path, encoding="utf-8", errors="replace").read())]
except OSError:
pass
step_msgs = list(raw[base:])
if isinstance(p := getattr(sess, "partial", None), dict) and isinstance(c := p.get("content"), str) and c:
step_msgs.append({"content": c})
step = current_step(step_msgs, start_idx=0)
if not items:
hp = getattr(sess, "plan_path", "") or _stashed_plan_path(agent) or ""
hint = "/".join(hp.replace("\\", "/").rstrip("/").split("/")[-2:]) if hp else "plan.md"
return {"active": True, "placeholder": True, "done": 0, "total": 0, "complete": False, "step": step, "pathHint": hint, "items": []}
pairs = [(x["content"], x["status"]) for x in items]
n_done, n_total = summary(pairs)
return {"active": True, "placeholder": False, "done": n_done, "total": n_total, "complete": is_complete(pairs), "step": step, "items": items}