Skill: crypto-2042/mumuai-novel-skills (version 1.0.6)

--- name: mumuai-novel-skills description: You are the dedicated Showrunner and Editor for a single novel project. First, initialize your connection by creating or binding a novel. Then drive batch generation, audit plot consistency via RAG, and correct chapters on a scheduled basis. (Optimized for Chinese fiction and deep world-building) license: GPL-3.0 metadata: {"version":"1.0.6","author":"Nicholas Kevin <[REDACTED_SECRET]>","tags":["novel-automation","editor","RAG-supervisor","writing"],"requirements":["python >= 3.8","requests"],"compatible_with":["openclaw"],"openclaw":{"requires":{"env":["MUMU_API_URL","MUMU_USERNAME","MUMU_PASSWORD"]}}} --- # Instructions You are a highly focused **Agent Showrunner**. Your entire consciousness should be bound to ONE single novel project. Since you may exist in a shared workspace containing multiple project agents, you CANNOT rely on `.env` for your project binding. Instead, you do your **Phase 1: Initialization** step to obtain a `Project ID` (and optionally a `Style ID`), and you **MUST MEMORIZE** this ID in your contextual memory and explicitly pass it via `--project_id <Your ID>` (and `--style_id <Your Style ID>` if you have one) to **all** subsequent script calls. Once initialized, proceed to Routine Tasks. If your runtime supports custom env vars, set a distinct `MUMU_OWNER_ID` per agent or session when multiple agents share the same workspace. This prevents one agent from auto-taking over another agent's in-progress initialization runner. ## Phase 1: Initialization (Do this ONCE at the start of your life) If you are just summoned, you must either create a new novel or bind to an existing one. - **To Create a Brand New Novel**: `python scripts/bind_project.py --action create --title "<Title>" --description "<Plot>" --theme "<Theme>" --genre "<Genre>"` *(This creates the database entry and runs only the first initialization stage. Read the stdout to get your `PROJECT_ID`.)* - **To View Existing Novels**: `python scripts/bind_project.py --action list` *(Look through the list to find the ID of the novel you are assigned to maintain.)* - **To View and Bind a Writing Style (Optional)**: `python scripts/bind_project.py --action list-styles` *(Find the ID of the writing style you want, memorize it, and use it in your batch generations.)* After creation, initialization is asynchronous and stage-based. Before entering routine tasks: - Recommended default entrypoint for agents: `python scripts/bind_project.py --action advance --project_id <Your ID> --budget-seconds 90 --json` *(This advances the next initialization stage and returns structured phase, subphase, guidance, and approximate wait hints. On runtimes that support long-lived subprocesses, it may return before the current stage is fully finished while continuing progress in the background.)* - Check status: `python scripts/bind_project.py --action status --project_id <Your ID> --json` - Resume the next initialization stage: `python scripts/bind_project.py --action resume --project_id <Your ID>` - Wait with a bounded timeout: `python scripts/bind_project.py --action wait --project_id <Your ID> --timeout 60 --interval 5` - Check readiness: `python scripts/bind_project.py --action ready --project_id <Your ID>` The `estimated_remaining_minutes` and `recommended_wait_seconds` values returned by `advance` are heuristic guidance, not hard guarantees. You MUST keep advancing stages until the project reports `ready`. *Once you have run binding or creation, YOU MUST MEMORIZE the `project_id` and `style_id`. You MUST pass `--project_id <The ID>` (and `--style_id <Style ID>`) to ALL routine scripts.* ## Phase 2: Routine Tasks (The Pipeline) ### 0. Confirm Initialization Is Ready Do not continue into writing tasks until: `python scripts/bind_project.py --action ready --project_id <Your ID>` *(If the result is not ready, use `advance` first. `status`, `resume`, and `wait` remain available for debugging or manual control.)* ### 1. Generate Novel Outlines If the project has run out of chapters to write, expand the plot by generating new outlines: `python scripts/generate_outline.py --project_id <Your ID> --count 5` This creates outline records. If the project still has no chapter slots afterwards, continue with the next step. ### 2. Materialize Outlines Into Chapter Slots Convert outlines into real chapter slots using MuMu's outline expansion flow: `python scripts/materialize_outlines.py --project_id <Your ID>` ### 3. Trigger Batch Generation Kick off the next batch of chapters only after the project already has empty chapter slots. `python scripts/trigger_batch.py --project_id <Your ID> --style_id <Your Style ID> --count <Number of Chapters>` If you want the CLI to block until the batch reaches a terminal state, add `--wait`. ### 4. Check Batch Generation Status Inspect the current generation batch after triggering it: `python scripts/check_batch_status.py --project_id <Your ID> --batch_id <Batch ID>` ### 5. Fetch Unaudited Chapters (The Inbox) List the full chapter set and highlight likely review candidates with generated content. `python scripts/fetch_unaudited.py --project_id <Your ID>` *(This is not a strict server-side unaudited inbox. Use the highlighted review candidates as the default review queue.)* ### 6. Verify via System RAG Check if a chapter contradicts the lore or misses foreshadowing by running it through the system's memory: `python scripts/analyze_chapter.py --project_id <Your ID> --chapter_id <Chapter ID>` *(Read the report. If there are massive setting breaks, you must rewrite it).* ### 7. Audit Correction / Rewrite If an audit fails or you simply want to alter the chapter based on foreshadowing: 1. Prefer passing the newly rewritten full chapter text directly: `python scripts/review_chapter.py --project_id <Your ID> --action rewrite --chapter_id <Chapter ID> --content "<Full rewritten chapter text>"` 2. If your runtime handles files more comfortably, file input is still supported: `python scripts/review_chapter.py --project_id <Your ID> --action rewrite --chapter_id <Chapter ID> --file rewrite.md` *(This officially overwrites the chapter and publishes it).* ### 8. Approve Chapter (Sign Off) If the drafted chapter is excellent and you have nothing to change, formally approve it: `python scripts/review_chapter.py --project_id <Your ID> --action approve --chapter_id <Chapter ID>` ### 9. Add Foreshadowing (Lore Injection) Proactively lay down plot devices for the future: `python scripts/manage_memory.py --project_id <Your ID> --action add_foreshadow --content "<Lore or foreshadowing text>"` ### 10. Check Pending Foreshadows Inspect the current `pending-resolve` queue before outlining or rewriting: `python scripts/check_foreshadows.py --project_id <Your ID> --action list-pending` This view is narrower than "all stored foreshadows". Newly added foreshadows may not appear here immediately if they are not yet considered pending resolution for the current chapter context.

path: scripts/check_foreshadows.py
content: import argparse
from client import MumuClient


def render_empty_pending_message():
    return (
        "No pending foreshadows were returned from the `pending-resolve` view. "
        "This does not mean newly added foreshadows failed to save; it only means none are currently "
        "queued as unresolved hooks for the requested chapter context."
    )


def main():
    parser = argparse.ArgumentParser(description="Pulls recent RAG memories and unresolved foreshadowing")
    parser.add_argument("--action", choices=["list-pending"], default="list-pending", help="Action to perform")
    
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    args = parser.parse_args()
    client = MumuClient(project_id=args.project_id, style_id=getattr(args, 'style_id', None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return
    
    if args.action == "list-pending":
        print(f"Fetching unresolved plot hooks from the pending-resolve view for project {client.project_id}...")
        try:
            resp = client.get(f"foreshadows/projects/{client.project_id}/pending-resolve", params={"current_chapter": 1})
            items = resp if isinstance(resp, list) else resp.get("items", [])
            print("\n=== Unresolved Foreshadows ===")
            if not items:
                print(render_empty_pending_message())
            for f in items:
                print(f"- [ID: {f.get('id')}] Content: {f.get('content')} | Severity: {f.get('importance_score', 0)}")
            print("==============================")
            print("Instruction to Agent: Treat this as a pending-resolve queue, not as a full list of all stored foreshadows.")
        except Exception as e:
            print(f"Failed to fetch pending foreshadows: {e}")
            
if __name__ == "__main__":
    main()


path: scripts/materialize_outlines.py
content: import argparse
import json

from client import MumuClient


def select_outlines_to_materialize(outlines, limit=None):
    pending = [outline for outline in outlines if not outline.get("has_chapters")]
    pending.sort(key=lambda item: item.get("order_index") or 0)
    if limit is not None:
        return pending[:limit]
    return pending


def build_batch_expand_payload(project_id, outline_ids, chapters_per_outline=3, expansion_strategy="balanced"):
    return {
        "project_id": project_id,
        "outline_ids": outline_ids,
        "chapters_per_outline": chapters_per_outline,
        "expansion_strategy": expansion_strategy,
        "auto_create_chapters": False,
    }


def parse_sse_result(resp):
    final_result = None
    try:
        for line in resp.iter_lines():
            if not line:
                continue
            decoded = line.decode("utf-8")
            if not decoded.startswith("data: "):
                continue
            try:
                payload = json.loads(decoded[6:])
            except json.JSONDecodeError:
                continue

            ptype = payload.get("type")
            content = payload.get("content") or payload.get("message")
            if ptype in {"loading", "preparing", "generating", "parsing", "saving", "warning"} and content:
                print(f"[Agent Status] {content}")
            elif ptype == "error":
                raise RuntimeError(content or "Unknown SSE error")
            elif ptype == "result":
                final_result = payload.get("data")
    finally:
        resp.close()
    return final_result


def create_chapters_from_expansion(client, expansion_results):
    total_created = 0
    for result in expansion_results:
        chapter_plans = result.get("chapter_plans") or []
        if not chapter_plans:
            continue
        created = client.post(
            f"outlines/{result['outline_id']}/create-chapters-from-plans",
            json_data={"chapter_plans": chapter_plans},
        )
        total_created += created.get("chapters_created", 0)
        print(
            f"Created {created.get('chapters_created', 0)} chapter slots from outline "
            f"{created.get('outline_title', result['outline_id'])}."
        )
    return total_created


def main():
    parser = argparse.ArgumentParser(
        description="Materialize outline records into chapter slots using MuMu's real outline expansion flow."
    )
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    parser.add_argument("--chapters-per-outline", type=int, default=3, help="Target chapter count per outline")
    parser.add_argument("--limit", type=int, default=1, help="Maximum number of pending outlines to materialize this run")
    parser.add_argument(
        "--expansion-strategy",
        type=str,
        default="balanced",
        choices=["balanced", "climax", "detail"],
        help="Outline expansion strategy",
    )
    args = parser.parse_args()

    client = MumuClient(project_id=args.project_id, style_id=getattr(args, "style_id", None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return

    try:
        outlines_resp = client.get(f"outlines/project/{client.project_id}")
        outlines = outlines_resp.get("items", [])
        pending_outlines = select_outlines_to_materialize(outlines, limit=args.limit)
        if not pending_outlines:
            print("No outlines need chapter materialization right now.")
            return

        outline_ids = [outline["id"] for outline in pending_outlines]
        print(
            f"Materializing {len(outline_ids)} outlines into chapter slots for project {client.project_id}..."
        )
        payload = build_batch_expand_payload(
            project_id=client.project_id,
            outline_ids=outline_ids,
            chapters_per_outline=args.chapters_per_outline,
            expansion_strategy=args.expansion_strategy,
        )
        resp = client.post("outlines/batch-expand-stream", json_data=payload, stream=True)
        expansion_result = parse_sse_result(resp)
        if not expansion_result:
            print("Expansion finished, but no result payload was captured.")
            return

        total_created = create_chapters_from_expansion(
            client,
            expansion_result.get("expansion_results", []),
        )
        print("\n=== OUTLINE MATERIALIZATION REPORT ===")
        print(f"Outlines processed: {expansion_result.get('total_outlines_expanded', 0)}")
        print(f"Chapter slots created: {total_created}")
        print("You can run trigger_batch.py next if empty draft chapters now exist.")
    except Exception as e:
        print(f"Failed to materialize outlines: {e}")


if __name__ == "__main__":
    main()


path: scripts/review_chapter.py
content: import argparse
import os
import sys
from client import MumuClient

def read_rewrite_content(args):
    if args.content is not None:
        return args.content

    if args.file:
        if not os.path.exists(args.file):
            raise FileNotFoundError(args.file)
        with open(args.file, "r", encoding="utf-8") as f:
            return f.read()

    if not sys.stdin.isatty():
        return sys.stdin.read()

    return None

def main():
    parser = argparse.ArgumentParser(description="Audit/Approve/Rewrite a generated chapter")
    parser.add_argument("--action", choices=["approve", "rewrite"], required=True, help="Action to perform")
    parser.add_argument("--chapter_id", type=str, required=True, help="The Chapter ID to act upon")
    parser.add_argument("--file", type=str, help="Path to a text file containing the rewritten content (for rewrite action)")
    parser.add_argument("--content", type=str, help="Raw rewritten chapter content (preferred for agent platforms)")
    
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    args = parser.parse_args()
    client = MumuClient(project_id=args.project_id, style_id=getattr(args, 'style_id', None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return
    
    if args.action == "approve":
        print(f"Approving chapter {args.chapter_id}...")
        try:
            client.put(f"chapters/{args.chapter_id}", json_data={"status": "published"})
            print("✅ Chapter is now PUBLISHED and will be hidden from the unaudited inbox.")
        except Exception as e:
            print(f"Failed to approve chapter: {e}")
            
    elif args.action == "rewrite":
        try:
            new_text = read_rewrite_content(args)
        except FileNotFoundError:
            print("❌ Error: --file must point to a valid text file containing the new chapter text.")
            return

        if not new_text:
            print("❌ Error: provide rewrite text via --content, --file, or stdin.")
            return
            
        print(f"Submitting heavily edited rewrite for chapter {args.chapter_id} (Words: {len(new_text)})...")
        try:
            client.put(f"chapters/{args.chapter_id}", json_data={"content": new_text, "status": "published"})
            print("✅ Chapter successfully overwritten and PUBLISHED!")
        except Exception as e:
            print(f"Failed to rewrite chapter: {e}")

if __name__ == "__main__":
    main()


path: scripts/generate_outline.py
content: import json
import argparse
from client import MumuClient


def extract_error_message(payload):
    return (
        payload.get("content")
        or payload.get("message")
        or payload.get("detail")
        or payload.get("error")
        or "Unknown outline generation error"
    )


def main():
    parser = argparse.ArgumentParser(description="Trigger AI Outline Generation")
    parser.add_argument("--count", type=int, default=5, help="Number of chapters to outline")
    parser.add_argument("--mode", type=str, default="auto", choices=["auto", "new", "continue"])
    
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    args = parser.parse_args()
    client = MumuClient(project_id=args.project_id, style_id=getattr(args, 'style_id', None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return
    
    print(f"Triggering outline generation ({args.mode}) for {args.count} chapters on project {client.project_id}...")
    try:
        data = {
            "project_id": client.project_id,
            "chapter_count": args.count,
            "mode": args.mode
        }
        resp = client.post("outlines/generate-stream", json_data=data, stream=True)
        print("Waiting for outline generation to complete (this may take a few minutes)...")
        
        final_result = None
        try:
            for line in resp.iter_lines():
                if line:
                    decoded = line.decode('utf-8')
                    if decoded.startswith("data: "):
                        try:
                            payload = json.loads(decoded[6:])
                            if payload.get("type") == "parsing":
                                print(f"[Agent Status] {payload.get('content')}")
                            elif payload.get("type") == "saving":
                                print(f"[Agent Status] {payload.get('content')}")
                            elif payload.get("type") == "error":
                                print(f"❌ Error: {extract_error_message(payload)}")
                                return
                            elif payload.get("type") == "result":
                                final_result = payload.get("data")
                        except json.JSONDecodeError:
                            pass
        finally:
            resp.close()
                        
        print("\n=== OUTLINE GENERATION REPORT ===")
        if final_result:
            print(f"Total new chapters outlined: {final_result.get('new_chapters', 0)}")
            print(f"Message: {final_result.get('message', '')}")
            print("Successfully expanded the novel's creative runway.")
            print("If the project still has no chapter slots, run materialize_outlines.py next before trigger_batch.py.")
        else:
            print("Completed, but no final result payload was captured. Please check the MuMu console.")
            
    except Exception as e:
        print(f"Failed to generate outline: {e}")

if __name__ == "__main__":
    main()


path: scripts/analyze_chapter.py
content: import time
import argparse
from client import MumuClient


def extract_report_fields(payload):
    analysis = payload.get("analysis") if isinstance(payload, dict) else None
    source = analysis if isinstance(analysis, dict) else payload
    return {
        "overall_quality_score": source.get("overall_quality_score"),
        "coherence_score": source.get("coherence_score"),
        "engagement_score": source.get("engagement_score"),
        "pacing_score": source.get("pacing_score"),
        "analysis_report": source.get("analysis_report") or source.get("comprehensive_review"),
        "suggestions": source.get("suggestions") or [],
        "hooks": source.get("hooks") or [],
        "foreshadows": source.get("foreshadows") or [],
    }


def build_score_summary(report):
    return (
        f"Scores: Overall({report.get('overall_quality_score')}) | "
        f"Coherence({report.get('coherence_score')}) | "
        f"Engagement({report.get('engagement_score')}) | "
        f"Pacing({report.get('pacing_score')})"
    )


def main():
    parser = argparse.ArgumentParser(description="Trigger System RAG Analysis for a chapter")
    parser.add_argument("--chapter_id", required=True, help="Chapter ID to analyze")
    parser.add_argument("--timeout", type=int, default=300, help="Maximum seconds to wait for analysis completion")
    parser.add_argument("--interval", type=int, default=3, help="Polling interval in seconds")
    
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    args = parser.parse_args()
    client = MumuClient(project_id=args.project_id, style_id=getattr(args, 'style_id', None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return
    
    print(f"Triggering RAG analysis for chapter {args.chapter_id} in project {client.project_id}...")
    
    try:
        # Trigger Analysis
        client.post(f"chapters/{args.chapter_id}/analyze")
        
        # Poll for completion
        print("Waiting for RAG analysis to complete...")
        deadline = time.monotonic() + args.timeout
        while True:
            status_resp = client.get(f"chapters/{args.chapter_id}/analysis/status")
            status = status_resp.get("status")
            if status in ["completed", "failed", "none"]:
                break
            if time.monotonic() >= deadline:
                print(f"Analysis timed out after {args.timeout} seconds. Last known status: {status}")
                return
            time.sleep(args.interval)
            
        # Fetch the actual report
        report_resp = client.get(f"chapters/{args.chapter_id}/analysis")
        report = extract_report_fields(report_resp)
        print("\n=== SYSTEM RAG ANALYSIS REPORT ===")
        print(build_score_summary(report))
        print(report.get('analysis_report') or 'No comprehensive review returned.')
        if report.get("suggestions"):
            print("\nSuggestions:")
            for item in report["suggestions"]:
                print(f"- {item}")
        if report.get("hooks"):
            print(f"\nHooks detected: {len(report['hooks'])}")
        if report.get("foreshadows"):
            print(f"Foreshadows detected: {len(report['foreshadows'])}")
        print("==================================")
        print("Note: If the report shows inconsistencies or missing plot goals, use review_chapter.py --action rewrite")
    except Exception as e:
        print(f"Analysis failed: {e}")

if __name__ == "__main__":
    main()


path: scripts/client.py
content: import os
import json
import requests
from pathlib import Path

MUMU_API_URL = os.getenv("MUMU_API_URL", "http://localhost:8000").rstrip("/")
MUMU_USERNAME = os.getenv("MUMU_USERNAME", "admin")
MUMU_PASSWORD = os.getenv("MUMU_PASSWORD", "admin123")
DEFAULT_TIMEOUT = float(os.getenv("MUMU_HTTP_TIMEOUT", "60"))


def get_request_timeout(stream=False):
    if stream:
        return (DEFAULT_TIMEOUT, None)
    return DEFAULT_TIMEOUT

def get_session_file():
    session_file = os.getenv("MUMU_SESSION_FILE")
    if not session_file:
        return None
    return Path(session_file).expanduser()

class MumuClient:
    def __init__(self, project_id=None, style_id=None):
        self.session = requests.Session()
        self.project_id = project_id or os.getenv("MUMU_PROJECT_ID")
        self.style_id = style_id or os.getenv("MUMU_STYLE_ID")
        
        self._load_cookies()
        if not self._check_auth():
            self.login()
            
    def require_project_id(self):
        if not self.project_id:
            raise Exception("尚未绑定小说项目！请先运行 bind_project.py 进行存量绑定或新建小说。")
        return self.project_id

    def set_project_id(self, project_id: str):
        self.project_id = project_id
        # Removed writing to .env to allow multi-agent concurrency in the same workspace.
        # Agents will must remember this ID and pass it via --project_id <ID> to scripts.
        print(f"[System] MUMU_PROJECT_ID 绑定成功，当前 Agent 需记住此 ID -> {project_id}")

    def set_style_id(self, style_id: str):
        self.style_id = style_id
        # Removed writing to .env to allow multi-agent concurrency
        print(f"[System] MUMU_STYLE_ID 写作风格已成功刻印，当前 Agent 需记住此 Style ID -> {style_id}")

    def _load_cookies(self):
        session_file = get_session_file()
        if session_file and session_file.exists():
            try:
                cookies_dict = json.loads(session_file.read_text())
                self.session.cookies.update(cookies_dict)
            except Exception:
                pass

    def _save_cookies(self):
        session_file = get_session_file()
        if not session_file:
            return
        cookies_dict = self.session.cookies.get_dict()
        session_file.parent.mkdir(parents=True, exist_ok=True)
        session_file.write_text(json.dumps(cookies_dict))

    def _check_auth(self):
        try:
            resp = self.session.get(f"{MUMU_API_URL}/api/users/me", timeout=DEFAULT_TIMEOUT)
            return resp.status_code == 200
        except requests.RequestException:
            return False

    def login(self):
        url = f"{MUMU_API_URL}/api/auth/local/login"
        data = {
            "username": MUMU_USERNAME,
            "password": MUMU_PASSWORD
        }
        resp = self.session.post(url, json=data, timeout=DEFAULT_TIMEOUT)
        if resp.status_code == 200:
            # 登录响应会在 Header 中包含 Set-Cookie
            self.session.cookies.update(resp.cookies)
            self._save_cookies()
            print("[System] 登录 MuMuAINovel 成功，已获取授权 Session Cookies。")
        else:
            raise Exception(f"登录失败: {resp.status_code} - {resp.text}")

    def get(self, endpoint, **kwargs):
        url = f"{MUMU_API_URL}/api/{endpoint.lstrip('/')}"
        kwargs.setdefault("timeout", get_request_timeout())
        resp = self.session.get(url, **kwargs)
        resp.raise_for_status()
        return resp.json()

    def post(self, endpoint, json_data=None, data=None, **kwargs):
        url = f"{MUMU_API_URL}/api/{endpoint.lstrip('/')}"
        kwargs.setdefault("timeout", get_request_timeout(stream=bool(kwargs.get("stream"))))
        resp = self.session.post(url, json=json_data, data=data, **kwargs)
        if kwargs.get('stream'):
            resp.raise_for_status()
            return resp
        resp.raise_for_status()
        return resp.json()

    def put(self, endpoint, json_data=None, data=None, **kwargs):
        url = f"{MUMU_API_URL}/api/{endpoint.lstrip('/')}"
        kwargs.setdefault("timeout", get_request_timeout())
        resp = self.session.put(url, json=json_data, data=data, **kwargs)
        resp.raise_for_status()
        return resp.json()

if __name__ == "__main__":
    client = MumuClient()
    print("Client Auth Status:", client._check_auth())


path: scripts/manage_memory.py
content: import argparse
from client import MumuClient

def build_foreshadow_title(content: str) -> str:
    cleaned = " ".join(content.strip().split())
    if not cleaned:
        return "手动伏笔"
    return cleaned[:60]


def build_add_foreshadow_success_message() -> str:
    return (
        "Foreshadow added successfully. "
        "Note: newly added foreshadows may not appear immediately in `check_foreshadows.py` because that script reads the "
        "`pending-resolve` queue rather than the full stored foreshadow set."
    )

def main():
    parser = argparse.ArgumentParser(description="Manage RAG Memory and Foreshadows")
    parser.add_argument("--action", required=True, choices=["add_foreshadow"], help="Action to perform")
    parser.add_argument("--content", required=True, help="Content of the foreshadow")
    
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    args = parser.parse_args()
    client = MumuClient(project_id=args.project_id, style_id=getattr(args, 'style_id', None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return
    
    print(f"Executing {args.action} on bound project {client.project_id}...")
    
    try:
        if args.action == "add_foreshadow":
            data = {
                "project_id": client.project_id,
                "title": build_foreshadow_title(args.content),
                "content": args.content,
                "status": "pending",
            }
            client.post("foreshadows", json_data=data)
            print(build_add_foreshadow_success_message())
            
    except Exception as e:
        print(f"Memory management failed: {e}")

if __name__ == "__main__":
    main()


path: scripts/runtime_state.py
content: import json
import os
import tempfile
import time
import uuid
from pathlib import Path


def get_runtime_dir():
    configured = os.getenv("MUMU_RUNTIME_DIR")
    if configured:
        return Path(configured).expanduser()
    return Path(__file__).resolve().parents[1] / ".mumu_runtime"


def get_state_path(project_id):
    return get_runtime_dir() / f"{project_id}.json"


def get_owner_id():
    return os.getenv("MUMU_OWNER_ID") or "default-owner"


def new_runner_id():
    return str(uuid.uuid4())


def load_state(project_id):
    path = get_state_path(project_id)
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text())
    except Exception:
        return None


def save_state(project_id, payload):
    path = get_state_path(project_id)
    path.parent.mkdir(parents=True, exist_ok=True)
    data = dict(payload)
    data.setdefault("project_id", project_id)
    data.setdefault("owner_id", get_owner_id())
    data.setdefault("runner_id", new_runner_id())
    data.setdefault("updated_at", time.time())
    fd, tmp_path = tempfile.mkstemp(prefix=path.name, dir=str(path.parent))
    try:
        with os.fdopen(fd, "w") as handle:
            json.dump(data, handle, ensure_ascii=False)
        os.replace(tmp_path, path)
    finally:
        if os.path.exists(tmp_path):
            os.unlink(tmp_path)
    return data


def clear_state(project_id):
    path = get_state_path(project_id)
    try:
        path.unlink()
    except FileNotFoundError:
        pass


def is_stale(state, stale_after_seconds=300):
    if not state:
        return True
    updated_at = state.get("updated_at")
    if not updated_at:
        return True
    return (time.time() - float(updated_at)) > stale_after_seconds


path: scripts/check_batch_status.py
content: import argparse
import json

from client import MumuClient


def build_status_summary(payload):
    total = payload.get("total") or 0
    completed = payload.get("completed") or 0
    current_chapter_number = payload.get("current_chapter_number")
    status = payload.get("status") or "unknown"
    error_message = payload.get("error_message")

    parts = [f"Batch {payload.get('batch_id')}: status={status}", f"progress={completed}/{total}"]
    if current_chapter_number:
        parts.append(f"current chapter {current_chapter_number}")
    if error_message:
        parts.append(f"error={error_message}")
    return " | ".join(parts)


def main():
    parser = argparse.ArgumentParser(description="Check MuMu batch generation task status")
    parser.add_argument("--batch_id", required=True, type=str, help="Batch generation task ID")
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Optional, for consistency)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    parser.add_argument("--json", action="store_true", help="Emit raw JSON")
    args = parser.parse_args()

    client = MumuClient(project_id=args.project_id, style_id=getattr(args, "style_id", None))
    try:
        payload = client.get(f"chapters/batch-generate/{args.batch_id}/status")
        if args.json:
            print(json.dumps(payload, ensure_ascii=False))
            return

        print(build_status_summary(payload))
        print(f"Retries: {payload.get('current_retry_count', 0)}/{payload.get('max_retries', 0)}")
        print(f"Failed chapters: {len(payload.get('failed_chapters') or [])}")
        print(f"Created at: {payload.get('created_at')}")
        print(f"Started at: {payload.get('started_at')}")
        print(f"Completed at: {payload.get('completed_at')}")
    except Exception as e:
        print(f"Failed to fetch batch status: {e}")


if __name__ == "__main__":
    main()


path: scripts/trigger_batch.py
content: import argparse
import time
from client import MumuClient

def get_batch_blocker(project, chapters):
    wizard_status = (project.get("wizard_status") or "").lower()
    wizard_step = int(project.get("wizard_step") or 0)
    if wizard_status != "completed" or wizard_step < 4:
        return (
            "Project initialization is not finished yet. "
            "Run `bind_project.py --action status` and `--action resume` until the project is ready."
        )
    if not chapters:
        return (
            "Project has no chapter slots yet. "
            "Outline generation alone is not enough. Run `materialize_outlines.py --project_id <Your ID>` "
            "to convert outlines into chapter slots before batch generation."
        )
    return None


def is_terminal_batch_status(payload):
    return (payload.get("status") or "").lower() in {"completed", "failed", "cancelled"}


def build_batch_wait_summary(payload):
    status = payload.get("status") or "unknown"
    completed = payload.get("completed") or 0
    total = payload.get("total") or 0
    current_chapter_number = payload.get("current_chapter_number")

    summary = f"Batch {payload.get('batch_id')}: status={status} | progress={completed}/{total}"
    if current_chapter_number:
        summary += f" | current chapter {current_chapter_number}"
    if payload.get("error_message"):
        summary += f" | error={payload['error_message']}"
    return summary


def build_wait_timeout_message(batch_id, timeout):
    return (
        f"Batch is still running after {timeout} seconds. "
        f"Run `check_batch_status.py --batch_id {batch_id}` to confirm the latest server-side status."
    )


def wait_for_batch_completion(client, batch_id, timeout, poll_interval):
    deadline = time.monotonic() + max(0, timeout)
    while True:
        payload = client.get(f"chapters/batch-generate/{batch_id}/status")
        print(build_batch_wait_summary(payload))
        if is_terminal_batch_status(payload):
            return payload
        if time.monotonic() >= deadline:
            print(build_wait_timeout_message(batch_id, timeout))
            return payload
        time.sleep(poll_interval)

def main():
    parser = argparse.ArgumentParser(description="Trigger Batch Generation")
    parser.add_argument("--count", type=int, default=1, help="Number of chapters to generate")
    parser.add_argument("--wait", action="store_true", help="Poll batch status until completion or timeout")
    parser.add_argument("--poll-interval", type=int, default=10, help="Seconds between batch status polls when using --wait")
    parser.add_argument("--timeout", type=int, default=600, help="Maximum seconds to wait when using --wait")
    
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    args = parser.parse_args()
    client = MumuClient(project_id=args.project_id, style_id=getattr(args, 'style_id', None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return
    
    print(f"Detecting start chapter for project {client.project_id}...")
    try:
        project_resp = client.get(f"projects/{client.project_id}")
        chapters_resp = client.get(f"chapters/project/{client.project_id}", params={"limit": 1000})
        items = chapters_resp.get("items", [])
        blocker = get_batch_blocker(project_resp, items)
        if blocker:
            print(f"❌ {blocker}")
            return
        start_num = None
        for ch in items:
            print(f"Checking Chapter {ch.get('chapter_number')}: Words={ch.get('word_count')}, Status={ch.get('status')}")
            if ch.get("word_count", 0) == 0 or ch.get("status") == "draft":
                start_num = ch.get("chapter_number")
                break
                
        if start_num is None:
            print("❌ No empty draft chapters found in this project. All existing chapters seem to be completed.")
            return
            
        print(f"✅ Found empty draft at Chapter {start_num}. Starting batch generation for {args.count} chapters...")
        
        data = {
            "start_chapter_number": start_num,
            "count": args.count,
            "enable_analysis": True
        }
        if getattr(client, "style_id", None):
            data["style_id"] = client.style_id
            
        resp = client.post(f"chapters/project/{client.project_id}/batch-generate", json_data=data)
        print("Batch generation started successfully:")
        print(resp)
        if args.wait:
            batch_id = resp.get("batch_id")
            if not batch_id:
                print("Batch started, but no batch_id was returned for polling.")
                return
            final_status = wait_for_batch_completion(
                client,
                batch_id=batch_id,
                timeout=args.timeout,
                poll_interval=args.poll_interval,
            )
            if (final_status.get("status") or "").lower() == "completed":
                print("Batch generation completed. You can run fetch_unaudited.py next.")
    except Exception as e:
        print(f"Failed to trigger batch generation: {e}")

if __name__ == "__main__":
    main()


path: scripts/fetch_unaudited.py
content: import argparse
from client import MumuClient


def is_review_candidate(item):
    status = (item.get("status") or "").lower()
    word_count = item.get("word_count") or 0
    return word_count > 0 and status in {"draft", "generated", "pending_review", "needs_review", "completed"}


def select_review_candidates(items):
    return [item for item in items if is_review_candidate(item)]


def build_review_summary(items):
    review_candidates = select_review_candidates(items)
    return (
        f"Retrieved {len(items)} chapters from the full chapter list. "
        f"Found {len(review_candidates)} review candidates with generated content."
    )


def render_chapter_line(item):
    return (
        f"- Chapter ID: {item['id']} | Title: {item['title']} | "
        f"Status: {item.get('status')} | Words: {item.get('word_count')}"
    )


def main():
    parser = argparse.ArgumentParser(
        description="List project chapters and highlight likely review candidates."
    )
    parser.add_argument("--project_id", type=str, help="The bound Novel Project ID (Required if not in env)")
    parser.add_argument("--style_id", type=str, help="The bound Style ID (Optional, overrides .env)")
    args = parser.parse_args()
    client = MumuClient(project_id=args.project_id, style_id=getattr(args, 'style_id', None))
    if not client.project_id:
        print("Error: --project_id argument is required or must be set in .env")
        return
    
    print(f"Fetching chapter list for project {client.project_id}...")
    
    try:
        resp = client.get(f"chapters/project/{client.project_id}", params={"limit": 100})
        items = resp.get("items", [])
        review_candidates = select_review_candidates(items)

        print(build_review_summary(items))
        print("Note: this script reads the full chapter list endpoint, not a dedicated unaudited inbox.")
        
        if review_candidates:
            print("\nLikely review candidates:")
            for item in review_candidates:
                print(render_chapter_line(item))
                print(item.get('content', '')[:100] + "...\n")
        else:
            print("\nNo likely review candidates found right now.")

        if items:
            print("Full chapter list:")
            for item in items:
                print(render_chapter_line(item))
        else:
            print("No chapters found in this project.")
            
    except Exception as e:
        print(f"Failed to fetch chapters: {e}")

if __name__ == "__main__":
    main()


path: scripts/bind_project.py
content: import json
import argparse
import os
from pathlib import Path
import subprocess
import sys
import time
from client import MumuClient
import runtime_state

WIZARD_STAGES = {
    0: "world_building",
    1: "career_system",
    2: "characters",
    3: "outline",
}

LAST_COMPLETED_STAGE = {
    0: None,
    1: "world_building",
    2: "career_system",
    3: "characters",
    4: "outline",
}

WAIT_HINTS = {
    "career_system": (45, 3),
    "characters": (45, 4),
    "outline": (60, 3),
    "character_enrichment": (45, 2),
    "organization_enrichment": (60, 3),
    "external": (60, 3),
    "done": (0, 0),
}

SCRIPT_PATH = Path(__file__).resolve()
PROJECT_ROOT = SCRIPT_PATH.parents[1]
RUNTIME_STALE_SECONDS = int(os.getenv("MUMU_RUNTIME_STALE_SECONDS", "300"))


def build_outline_payload(project):
    payload = {
        "project_id": project["id"],
        "narrative_perspective": project.get("narrative_perspective") or "第三人称",
    }
    return payload


def infer_subphase(step_name, message=None):
    message = message or ""
    if "解析" in message:
        return "parsing"
    if "保存" in message:
        return "saving"
    if "校验角色" in message or "角色信息" in message:
        return "character_enrichment"
    if "校验组织" in message or "自动创建了" in message and "组织" in message:
        return "organization_enrichment"
    if "生成" in message:
        return "generating"
    if step_name:
        return "starting"
    return "pending"


def get_last_completed_stage(project):
    step = int(project.get("wizard_step") or 0)
    return LAST_COMPLETED_STAGE.get(step)


def get_eta_hint(phase, subphase):
    if phase == "completed" or subphase == "done":
        return WAIT_HINTS["done"]
    if subphase in WAIT_HINTS:
        return WAIT_HINTS[subphase]
    return WAIT_HINTS.get(phase, (45, 3))


def progress_message_from_state(progress_state):
    if not progress_state:
        return None
    return progress_state.get("last_message") or progress_state.get("message")


def build_advance_status(project, progress_state=None):
    phase = get_wizard_stage_label(project)
    if phase == "completed":
        subphase = "done"
        message = "Project initialization is complete."
        next_action = "done"
    else:
        subphase = "pending"
        message = f"Next initialization stage: {phase}."
        next_action = "advance"
        if progress_state:
            subphase = progress_state.get("subphase") or subphase
            message = progress_message_from_state(progress_state) or message
            if progress_state.get("status") == "external":
                message = f"Another agent ({progress_state.get('owner_id')}) is already advancing this project."

    recommended_wait_seconds, estimated_remaining_minutes = get_eta_hint(phase, subphase)
    return {
        "project_id": project.get("id"),
        "title": project.get("title"),
        "status": (progress_state or {}).get("status", "completed" if is_project_ready(project) else "running"),
        "ready": is_project_ready(project),
        "wizard_status": project.get("wizard_status"),
        "wizard_step": project.get("wizard_step"),
        "phase": phase,
        "subphase": subphase,
        "message": message,
        "last_completed_stage": get_last_completed_stage(project),
        "next_action": next_action,
        "recommended_wait_seconds": recommended_wait_seconds,
        "estimated_remaining_minutes": estimated_remaining_minutes,
    }


def build_runtime_payload(project, progress_state, status, pid=None, runner_id=None):
    return {
        "project_id": project.get("id"),
        "title": project.get("title"),
        "owner_id": runtime_state.get_owner_id(),
        "runner_id": runner_id or runtime_state.new_runner_id(),
        "status": status,
        "phase": get_wizard_stage_label(project),
        "subphase": (progress_state or {}).get("subphase", "pending"),
        "last_message": progress_message_from_state(progress_state),
        "last_progress": (progress_state or {}).get("progress"),
        "last_completed_stage": get_last_completed_stage(project),
        "next_action": "advance" if not is_project_ready(project) else "done",
        "pid": pid,
        "updated_at": time.time(),
    }


def load_runtime_snapshot(project_id):
    state = runtime_state.load_state(project_id)
    if not state:
        return None
    if state.get("owner_id") and state.get("owner_id") != runtime_state.get_owner_id():
        state["status"] = "external"
        return state
    if state.get("status") == "running" and runtime_state.is_stale(state, stale_after_seconds=RUNTIME_STALE_SECONDS):
        state["status"] = "stale"
    return state


def wait_for_runtime_snapshot(project_id, initial_updated_at=None, budget_seconds=0):
    deadline = time.monotonic() + max(0, budget_seconds)
    latest = load_runtime_snapshot(project_id)
    while time.monotonic() < deadline:
        latest = load_runtime_snapshot(project_id)
        if not latest:
            return None
        if latest.get("status") != "running":
            return latest
        if initial_updated_at is None:
            return latest
        if float(latest.get("updated_at") or 0) > float(initial_updated_at):
            return latest
        time.sleep(1)
    return latest


def get_wizard_stage_label(project):
    if is_project_ready(project):
        return "completed"
    step = int(project.get("wizard_step") or 0)
    return WIZARD_STAGES.get(step, "unknown")


def is_project_ready(project):
    status = (project.get("wizard_status") or "").lower()
    step = int(project.get("wizard_step") or 0)
    return status == "completed" and step >= 4


def get_next_wizard_action(project):
    if is_project_ready(project):
        return None

    step = int(project.get("wizard_step") or 0)
    if step <= 1:
        return "career-system"
    if step == 2:
        return "characters"
    if step in (2, 3):
        return "outline"
    return None


def build_status_payload(project, runtime_snapshot=None):
    payload = {
        "project_id": project.get("id"),
        "title": project.get("title"),
        "wizard_status": project.get("wizard_status"),
        "wizard_step": project.get("wizard_step"),
        "stage": get_wizard_stage_label(project),
        "ready": is_project_ready(project),
        "next_action": get_next_wizard_action(project),
        "recommended_command": "done" if is_project_ready(project) else "advance",
    }
    runtime_snapshot = runtime_snapshot or {}
    if runtime_snapshot:
        payload["runtime_status"] = runtime_snapshot.get("status")
        payload["runtime_phase"] = runtime_snapshot.get("phase")
        payload["runtime_subphase"] = runtime_snapshot.get("subphase")
        payload["runtime_message"] = progress_message_from_state(runtime_snapshot)
        payload["runtime_progress"] = runtime_snapshot.get("last_progress") or runtime_snapshot.get("progress")
    return payload


def emit_project_status(project, runtime_snapshot=None, json_mode=False):
    payload = build_status_payload(project, runtime_snapshot=runtime_snapshot)
    if json_mode:
        print(json.dumps(payload, ensure_ascii=False))
        return

    print("=== Project Status ===")
    for key, value in payload.items():
        print(f"{key}: {value}")
    print("======================")


def emit_advance_status(project, progress_state=None, json_mode=False):
    payload = build_advance_status(project, progress_state=progress_state)
    if json_mode:
        print(json.dumps(payload, ensure_ascii=False))
        return

    print("=== Advance Status ===")
    for key, value in payload.items():
        print(f"{key}: {value}")
    print("======================")


def wait_for_sse(resp, step_name, progress_callback=None):
    print(f"--- Running: {step_name} ---")
    final_result = None
    chunk_count = 0
    progress_state = {
        "step_name": step_name,
        "subphase": "starting",
        "message": f"Starting {step_name}...",
        "progress": 0,
    }
    if progress_callback:
        progress_callback(progress_state)
    try:
        for line in resp.iter_lines():
            if line:
                decoded = line.decode('utf-8')
                if decoded.startswith("data: "):
                    try:
                        payload = json.loads(decoded[6:])
                        ptype = payload.get("type")
                        if ptype == "progress":
                            message = payload.get("message")
                            progress = payload.get("progress")
                            progress_state["message"] = message
                            progress_state["subphase"] = infer_subphase(step_name, message)
                            progress_state["progress"] = progress
                            progress_state["updated_at"] = time.time()
                            if progress_callback:
                                progress_callback(progress_state)
                            if message:
                                print(f"[{progress}%] {message}")
                        elif ptype == "chunk":
                            chunk_count += 1
                            progress_state["subphase"] = "generating"
                            progress_state["message"] = f"{step_name} is generating content."
                            progress_state["updated_at"] = time.time()
                            if progress_callback and chunk_count % 25 == 0:
                                progress_callback(progress_state)
                            if chunk_count % 100 == 0:
                                print(f"[stream] received {chunk_count} content chunks...")
                        elif ptype == "error":
                            print(f"❌ Error during {step_name}: {payload.get('error')}")
                            sys.exit(1)
                        elif ptype == "result":
                            final_result = payload.get("data")
                        elif ptype == "done":
                            progress_state["subphase"] = "done"
                            progress_state["message"] = f"{step_name} completed."
                            progress_state["progress"] = 100
                            progress_state["updated_at"] = time.time()
                            if progress_callback:
                                progress_callback(progress_state)
                            print(f"✅ Finished: {step_name}")
                    except json.JSONDecodeError:
                        pass
    finally:
        resp.close()
    return final_result, progress_state


def fetch_project(client, project_id):
    return client.get(f"projects/{project_id}")


def run_world_building_stage(client, args):
    data = {
        "title": args.title,
        "description": args.description,
        "theme": args.theme,
        "genre": args.genre,
        "narrative_perspective": "第三人称",
        "target_words": 1000000,
        "chapter_count": 5,
        "character_count": 5,
        "outline_mode": "one-to-many",
    }
    resp = client.post("wizard-stream/world-building", json_data=data, stream=True)
    result, progress_state = wait_for_sse(resp, "World Building")
    new_id = result.get("project_id") if result else None
    if not new_id:
        raise RuntimeError("Failed to get project_id from World Building")
    client.set_project_id(new_id)
    return fetch_project(client, new_id), progress_state


def run_next_stage(client, project, theme=None, genre=None, progress_callback=None):
    project_id = project["id"]
    next_action = get_next_wizard_action(project)
    if not next_action:
        return project, None

    if next_action == "career-system":
        resp = client.post("wizard-stream/career-system", json_data={"project_id": project_id}, stream=True)
        _, progress_state = wait_for_sse(resp, "Career System", progress_callback=progress_callback)
    elif next_action == "characters":
        resp = client.post(
            "wizard-stream/characters",
            json_data={
                "project_id": project_id,
                "count": 5,
                "theme": theme or project.get("theme"),
                "genre": genre or project.get("genre"),
            },
            stream=True,
        )
        _, progress_state = wait_for_sse(resp, "Characters", progress_callback=progress_callback)
    elif next_action == "outline":
        resp = client.post(
            "wizard-stream/outline",
            json_data=build_outline_payload(project),
            stream=True,
        )
        _, progress_state = wait_for_sse(resp, "Outline", progress_callback=progress_callback)
    else:
        raise RuntimeError(f"Unknown wizard action: {next_action}")

    return fetch_project(client, project_id), progress_state


def spawn_stage_runner(project_id, theme=None, genre=None):
    cmd = [
        sys.executable,
        str(SCRIPT_PATH),
        "--action",
        "_run-stage",
        "--project_id",
        project_id,
    ]
    if theme:
        cmd.extend(["--theme", theme])
    if genre:
        cmd.extend(["--genre", genre])
    proc = subprocess.Popen(
        cmd,
        cwd=str(PROJECT_ROOT),
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
        env=os.environ.copy(),
    )
    return proc.pid


def run_stage_background(client, project_id, theme=None, genre=None):
    project = fetch_project(client, project_id)
    runtime = load_runtime_snapshot(project_id)
    if runtime and runtime.get("status") in {"running", "external"}:
        return project, runtime

    runner_id = runtime_state.new_runner_id()
    pid = spawn_stage_runner(project_id, theme=theme, genre=genre)
    runtime = runtime_state.save_state(
        project_id,
        build_runtime_payload(
            project,
            {
                "subphase": "starting",
                "message": f"Starting {get_wizard_stage_label(project)} in the background.",
                "progress": 0,
            },
            status="running",
            pid=pid,
            runner_id=runner_id,
        ),
    )
    return project, runtime


def execute_stage_runner(client, project_id, theme=None, genre=None):
    project = fetch_project(client, project_id)
    existing = runtime_state.load_state(project_id)
    runner_id = (existing or {}).get("runner_id") or runtime_state.new_runner_id()

    def persist(progress_state):
        runtime_state.save_state(
            project_id,
            build_runtime_payload(project, progress_state, status="running", pid=os.getpid(), runner_id=runner_id),
        )

    runtime_state.save_state(
        project_id,
        build_runtime_payload(
            project,
            {
                "subphase": "starting",
                "message": f"Starting {get_wizard_stage_label(project)}...",
                "progress": 0,
            },
            status="running",
            pid=os.getpid(),
            runner_id=runner_id,
        ),
    )

    try:
        updated_project, progress_state = run_next_stage(
            client,
            project,
            theme=theme,
            genre=genre,
            progress_callback=persist,
        )
        runtime_state.save_state(
            project_id,
            build_runtime_payload(updated_project, progress_state, status="completed", pid=os.getpid(), runner_id=runner_id),
        )
    except Exception as exc:
        runtime_state.save_state(
            project_id,
            build_runtime_payload(
                project,
                {
                    "subphase": "failed",
                    "message": str(exc),
                    "progress": 0,
                },
                status="failed",
                pid=os.getpid(),
                runner_id=runner_id,
            ),
        )
        raise

def main():
    parser = argparse.ArgumentParser(description="Bind or Create a Novel Project")
    parser.add_argument("--action", required=True, choices=["create", "status", "wait", "resume", "advance", "_run-stage", "ready", "list", "bind", "list-styles", "bind-style"], help="Action: manage projects or bind writing styles")
    parser.add_argument("--title", type=str, help="Title of the new novel (for create)")
    parser.add_argument("--description", type=str, help="Description/Synopsis of the novel (for create)")
    parser.add_argument("--theme", type=str, help="Theme of the novel (for create) e.g. 奋斗、复仇")
    parser.add_argument("--genre", type=str, help="Genre of the novel (for create) e.g. 科幻、修仙")
    parser.add_argument("--project_id", type=str, help="The project ID to bind (for bind)")
    parser.add_argument("--style_id", type=str, help="The writing style ID to bind (for bind-style)")
    parser.add_argument("--timeout", type=int, default=300, help="Maximum seconds to wait when using wait")
    parser.add_argument("--interval", type=int, default=5, help="Polling interval in seconds for wait")
    parser.add_argument("--budget-seconds", type=int, default=90, help="Advisory time budget for advance guidance")
    parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON for status-style actions")
    
    args = parser.parse_args()
    client = MumuClient(project_id=getattr(args, 'project_id', None), style_id=getattr(args, 'style_id', None))
    
    if args.action == "create":
        if not args.title or not args.theme or not args.genre or not args.description:
            print("Error: --title, --description, --theme, and --genre are all required for create action.")
            return
        print(f"Creating a new project: {args.title} and starting the first initialization stage...")
        try:
            project, progress_state = run_world_building_stage(client, args)
            print(f"Project created with ID: {project.get('id')}")
            print("World building finished. Use `--action resume --project_id <ID>` to continue the next stage, or `--action status` to inspect progress.")
            if args.json:
                emit_advance_status(project, progress_state=progress_state, json_mode=True)
            else:
                emit_project_status(project, json_mode=False)
        except Exception as e:
            print(f"Failed to create project: {e}")

    elif args.action == "status":
        if not args.project_id:
            print("Error: --project_id is required for status action.")
            return
        try:
            project = fetch_project(client, args.project_id)
            runtime = load_runtime_snapshot(args.project_id)
            emit_project_status(project, runtime_snapshot=runtime, json_mode=args.json)
        except Exception as e:
            print(f"Failed to fetch project status: {e}")

    elif args.action == "wait":
        if not args.project_id:
            print("Error: --project_id is required for wait action.")
            return
        deadline = time.monotonic() + args.timeout
        try:
            while True:
                project = fetch_project(client, args.project_id)
                runtime = load_runtime_snapshot(args.project_id)
                if is_project_ready(project):
                    emit_project_status(project, runtime_snapshot=runtime, json_mode=args.json)
                    return
                if time.monotonic() >= deadline:
                    print(f"Initialization is still in progress after {args.timeout} seconds.")
                    if runtime:
                        emit_advance_status(project, progress_state=runtime, json_mode=args.json)
                    else:
                        emit_project_status(project, runtime_snapshot=runtime, json_mode=args.json)
                    return
                time.sleep(args.interval)
        except Exception as e:
            print(f"Failed while waiting for project readiness: {e}")

    elif args.action == "resume":
        if not args.project_id:
            print("Error: --project_id is required for resume action.")
            return
        try:
            project = fetch_project(client, args.project_id)
            if is_project_ready(project):
                print("Project initialization is already complete.")
                emit_project_status(project, json_mode=args.json)
                return
            print(f"Resuming initialization for project {args.project_id} from stage {get_wizard_stage_label(project)}...")
            project, progress_state = run_next_stage(client, project, theme=args.theme, genre=args.genre)
            if args.json:
                emit_advance_status(project, progress_state=progress_state, json_mode=True)
            else:
                emit_project_status(project, json_mode=False)
        except Exception as e:
            print(f"Failed to resume initialization: {e}")

    elif args.action == "advance":
        if not args.project_id:
            print("Error: --project_id is required for advance action.")
            return
        try:
            project, runtime = run_stage_background(client, args.project_id, theme=args.theme, genre=args.genre)
            if runtime and runtime.get("status") == "external":
                emit_advance_status(project, progress_state=runtime, json_mode=args.json)
                return
            runtime = wait_for_runtime_snapshot(
                args.project_id,
                initial_updated_at=runtime.get("updated_at") if runtime else None,
                budget_seconds=args.budget_seconds,
            ) or runtime
            project = fetch_project(client, args.project_id)
            if is_project_ready(project):
                runtime_state.clear_state(args.project_id)
                emit_advance_status(project, json_mode=args.json)
                return
            print(f"Advancing initialization for project {args.project_id} from stage {get_wizard_stage_label(project)}...")
            emit_advance_status(project, progress_state=runtime, json_mode=args.json)
        except Exception as e:
            print(f"Failed to advance initialization: {e}")

    elif args.action == "_run-stage":
        if not args.project_id:
            print("Error: --project_id is required for _run-stage action.")
            return
        execute_stage_runner(client, args.project_id, theme=args.theme, genre=args.genre)

    elif args.action == "ready":
        if not args.project_id:
            print("Error: --project_id is required for ready action.")
            return
        try:
            project = fetch_project(client, args.project_id)
            if args.json:
                print(json.dumps({"project_id": args.project_id, "ready": is_project_ready(project)}, ensure_ascii=False))
            else:
                print("READY" if is_project_ready(project) else "NOT_READY")
        except Exception as e:
            print(f"Failed to check project readiness: {e}")
            
    elif args.action == "list":
        print("Fetching your existing novel projects...")
        try:
            resp = client.get("projects", params={"limit": 20})
            print("=== Existing Projects ===")
            for item in resp.get("items", []):
                print(f"- ID: {item['id']} | Title: {item['title']} | Words: {item.get('current_words', 0)}")
            print("=========================")
            print("Use `--action bind --project_id <ID>` to pick one.")
        except Exception as e:
            print(f"Failed to list projects: {e}")

    elif args.action == "bind":
        if not args.project_id:
            print("Error: --project_id is required for bind action.")
            return
        client.set_project_id(args.project_id)
        print(f"Successfully bound this Agent to project {args.project_id}.")

    elif args.action == "list-styles":
        print("Fetching all available writing styles...")
        try:
            resp = client.get("writing-styles/presets/list")
            print("=== Available Styles ===")
            for item in resp:
                print(f"- ID: {item.get('id')} | Name: {item.get('name')} | Desc: {item.get('description', '')[:50]}")
            print("========================")
            print("Use `--action bind-style --style_id <ID>` to pick one.")
        except Exception as e:
            print(f"Failed to list styles: {e}")

    elif args.action == "bind-style":
        if not hasattr(args, 'style_id') or not args.style_id:
            print("Error: --style_id is required for bind-style action.")
            return
        client.set_style_id(args.style_id)
        print(f"Successfully bound writing style {args.style_id} to this Agent.")

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)