| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- #!/usr/bin/env python3
- # /// script
- # requires-python = ">=3.9"
- # dependencies = ["tiktoken"]
- # ///
- """prepass — the Analyze pre-pass for the agent builder.
- Reads an agent skill directory and emits one compact JSON object that every
- lens and the analyze orchestrator consume. The pre-pass does the one thing the
- lenses should not each redo: it classifies the agent along the three-point
- gradient (stateless, memory, autonomous), counts tokens for SKILL.md and every
- in-tree file, and sets the gate that turns the conditional sanctum lens on.
- Detection rests on the sanctum, the built agent's runtime memory at
- `{project-root}/_bmad/memory/{skillName}/`. An agent that reloads a sanctum on
- waking is a memory agent; one that also carries live wake behavior (a PULSE
- file or a pulse/autonomous wake reference with named-task routing) is
- autonomous; one with no sanctum at all is stateless. This is the BUILT agent's
- memory, never the builder's process log (.memlog.md), and the two are kept
- apart here.
- Lengths come from tokens, never line counts. The count uses count_tokens.py
- (imported as a sibling, then shelled out, then a chars // 4 fallback) so the
- metric matches the rest of the builder and runs under a bare python3.
- Output contract (one line of JSON on stdout, the pinned prepass shape):
- {
- "agent_type": "stateless" | "memory" | "autonomous",
- "is_memory_agent": bool, # true for memory and autonomous
- "skill_md_tokens": int,
- "files": [{"path": str, "tokens": int}, ...]
- }
- Read-only over the target agent directory. It opens files to count and classify
- and writes nothing inside the agent tree.
- Usage:
- prepass.py <agent-dir> classify and count the agent at this directory
- """
- from __future__ import annotations
- import argparse
- import json
- import re
- import subprocess
- import sys
- from pathlib import Path
- SCRIPT_DIR = Path(__file__).resolve().parent
- # Directories we never descend into while counting agent files.
- SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
- # Extensions we treat as countable text. Binary or opaque assets are skipped.
- TEXT_SUFFIXES = {
- ".md", ".py", ".toml", ".yaml", ".yml", ".json", ".txt",
- ".csv", ".html", ".sh", ".cfg", ".ini",
- }
- # --- token counting ---------------------------------------------------------
- def _count_via_import(text: str):
- """Count tokens by importing the sibling count_tokens module."""
- if str(SCRIPT_DIR) not in sys.path:
- sys.path.insert(0, str(SCRIPT_DIR))
- try:
- import count_tokens # type: ignore
- except Exception:
- return None
- try:
- tokens, _method = count_tokens.count_tokens(text)
- return int(tokens)
- except Exception:
- return None
- def _count_via_shell(text: str):
- """Count tokens by shelling out to count_tokens.py with text on stdin."""
- script = SCRIPT_DIR / "count_tokens.py"
- if not script.exists():
- return None
- try:
- proc = subprocess.run(
- [sys.executable, str(script), "--stdin"],
- input=text,
- capture_output=True,
- text=True,
- timeout=60,
- )
- except Exception:
- return None
- if proc.returncode != 0:
- return None
- try:
- return int(json.loads(proc.stdout)["tokens"])
- except Exception:
- return None
- def count_tokens(text: str) -> int:
- """Token length of text via count_tokens.py, falling back to chars // 4.
- Prefers importing the vendored count_tokens module, then shelling out to it,
- then a bare character estimate so the pre-pass always produces a number.
- """
- for counter in (_count_via_import, _count_via_shell):
- result = counter(text)
- if result is not None:
- return result
- return len(text) // 4
- def read_text(path: Path) -> str:
- try:
- return path.read_text(encoding="utf-8")
- except (OSError, UnicodeDecodeError):
- return ""
- # --- agent classification ---------------------------------------------------
- def iter_files(root: Path):
- """Yield countable text files under root, skipping noise directories."""
- for path in sorted(root.rglob("*")):
- if not path.is_file():
- continue
- if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
- continue
- if path.suffix.lower() in TEXT_SUFFIXES:
- yield path
- def has_sanctum(root: Path, skill_text: str) -> bool:
- """True when the agent reloads a runtime sanctum on waking (a memory agent).
- The sanctum is the built agent's memory at `_bmad/memory/{skillName}/`. We
- treat any of these as a sanctum signal: the SKILL referencing that memory
- path, the Sacred-Truth / waking bootloader language, a wake or init-sanctum
- scaffolder, or the sanctum template assets (PERSONA / CREED / BOND / MEMORY
- / INDEX / CAPABILITIES). This is the built agent's memory, distinct from the
- builder's .memlog.md, which is never a sanctum signal.
- """
- if re.search(r"_bmad/memory/", skill_text):
- return True
- if re.search(r"\bsanctum\b", skill_text, re.IGNORECASE):
- return True
- if "Sacred Truth" in skill_text and re.search(r"\b(waking|wake)\b", skill_text, re.IGNORECASE):
- return True
- for pattern in ("scripts/wake*", "scripts/init-sanctum*"):
- for script in root.glob(pattern):
- if script.is_file():
- return True
- sanctum_seed = re.compile(
- r"^(PERSONA|CREED|BOND|MEMORY|INDEX|CAPABILITIES)-template\.md$"
- )
- assets = root / "assets"
- if assets.is_dir():
- for asset in assets.iterdir():
- if asset.is_file() and sanctum_seed.match(asset.name):
- return True
- return False
- def has_autonomous_wake(root: Path, skill_text: str) -> bool:
- """True when a memory agent also carries live autonomous wake behavior.
- Autonomous is memory plus a PULSE-driven wake: a deployed PULSE.md, a
- pulse/autonomous-wake reference, or SKILL wake routing (named-task pulse
- routing, a default wake behavior, quiet hours, or a wake frequency).
- The standard memory bootloader already names a Pulse Mode (`--pulse`) path
- that loads PULSE.md, and ships a PULSE template asset, in every memory
- agent. Those are seeds, not live wake behavior, so neither the bootloader's
- Pulse-Mode line nor a PULSE template asset counts here. The wake behavior
- must be deployed: a real PULSE.md, a wake reference file, or SKILL routing
- that names tasks or schedules a recurring wake.
- """
- if (root / "PULSE.md").is_file():
- return True
- refs = root / "references"
- if refs.is_dir():
- for ref in refs.iterdir():
- name = ref.name.lower()
- if ref.is_file() and ("pulse-wake" in name or "autonomous-wake" in name):
- return True
- wake_signals = [
- r"--pulse:\{", # named-task pulse routing
- r"-p:\{", # short-flag named-task routing
- r"default pulse wake",
- r"default wake behavior",
- r"\bquiet hours\b",
- r"wake frequency",
- r"autonomous wake",
- ]
- for pattern in wake_signals:
- if re.search(pattern, skill_text, re.IGNORECASE):
- return True
- return False
- def classify(root: Path, skill_text: str) -> str:
- """Return the agent_type along the gradient."""
- if not has_sanctum(root, skill_text):
- return "stateless"
- if has_autonomous_wake(root, skill_text):
- return "autonomous"
- return "memory"
- # --- main -------------------------------------------------------------------
- def build_payload(root: Path) -> dict:
- skill_path = root / "SKILL.md"
- skill_text = read_text(skill_path) if skill_path.is_file() else ""
- agent_type = classify(root, skill_text)
- is_memory_agent = agent_type in ("memory", "autonomous")
- files = []
- skill_md_tokens = 0
- for path in iter_files(root):
- tokens = count_tokens(read_text(path))
- rel = path.relative_to(root).as_posix()
- files.append({"path": rel, "tokens": tokens})
- if path == skill_path:
- skill_md_tokens = tokens
- return {
- "agent_type": agent_type,
- "is_memory_agent": is_memory_agent,
- "skill_md_tokens": skill_md_tokens,
- "files": files,
- }
- def main(argv: list[str] | None = None) -> int:
- p = argparse.ArgumentParser(
- description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- p.add_argument("agent_dir", help="path to the agent skill directory to analyze")
- args = p.parse_args(argv)
- root = Path(args.agent_dir).expanduser().resolve()
- if not root.is_dir():
- p.error(f"not a directory: {root}")
- print(json.dumps(build_payload(root)))
- return 0
- if __name__ == "__main__":
- sys.exit(main())
|