228 lines
7.3 KiB
Python
Executable File
228 lines
7.3 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = []
|
|
# ///
|
|
"""project_cli.py — deterministic file operations for the /project skill.
|
|
|
|
The agent must never hand-edit projects/<slug>/memory.md: dates get invented and
|
|
appends get joined onto the previous line when the anchor is guessed. This script
|
|
owns both — the date comes from the system clock, the newline is guaranteed.
|
|
|
|
Nothing here ever shortens, rewrites or deletes stored content. `activate` may
|
|
omit older memory entries from its *output* when the whole project would exceed
|
|
the tool-result limit, but the files on disk are left untouched.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
# workspace/skills/project/scripts/project_cli.py -> parents[3] = workspace root.
|
|
WORKSPACE = Path(__file__).resolve().parents[3]
|
|
|
|
TIMEZONE = ZoneInfo("Europe/Prague")
|
|
PROJECT_FILES = ("prompt.md", "memory.md", "state.md")
|
|
|
|
# Tool results above `maxToolResultChars` (16000, server config.json) are offloaded
|
|
# to a file the agent then has to read back in pieces. Stay under it with a margin.
|
|
MAX_OUTPUT_CHARS = 14_400
|
|
|
|
|
|
def projects_dir() -> Path:
|
|
"""Root of the project store; PROJECTS_DIR overrides it for tests."""
|
|
override = os.environ.get("PROJECTS_DIR")
|
|
return Path(override) if override else WORKSPACE / "projects"
|
|
|
|
|
|
def project_path(slug: str) -> Path:
|
|
return projects_dir() / slug
|
|
|
|
|
|
def existing_slugs() -> list[str]:
|
|
root = projects_dir()
|
|
if not root.is_dir():
|
|
return []
|
|
return sorted(entry.name for entry in root.iterdir() if entry.is_dir())
|
|
|
|
|
|
def read_file(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
|
|
def ensure_project_files(directory: Path) -> None:
|
|
"""Create any missing project file as empty — a hand-made directory must work."""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
for name in PROJECT_FILES:
|
|
path = directory / name
|
|
if not path.exists():
|
|
path.write_text("", encoding="utf-8")
|
|
|
|
|
|
def format_size(size: int) -> str:
|
|
if size < 1024:
|
|
return f"{size}B"
|
|
return f"{size / 1024:.1f}K"
|
|
|
|
|
|
def fit_memory(memory: str, budget: int, slug: str) -> str:
|
|
"""Drop the oldest entries from the *output* until it fits the budget.
|
|
|
|
The file itself is never modified — the note tells the agent where the rest is.
|
|
"""
|
|
if len(memory) <= budget:
|
|
return memory
|
|
lines = memory.splitlines(keepends=True)
|
|
kept: list[str] = []
|
|
used = 0
|
|
for line in reversed(lines):
|
|
if used + len(line) > budget:
|
|
break
|
|
kept.append(line)
|
|
used += len(line)
|
|
kept.reverse()
|
|
omitted = len(lines) - len(kept)
|
|
note = (
|
|
f"[… {omitted} older entries not shown, full log: "
|
|
f"projects/{slug}/memory.md — read it when you need older context]\n"
|
|
)
|
|
return note + "".join(kept)
|
|
|
|
|
|
def cmd_activate(slug: str) -> int:
|
|
directory = project_path(slug)
|
|
if not directory.is_dir():
|
|
slugs = existing_slugs()
|
|
listing = ", ".join(slugs) if slugs else "(none)"
|
|
print(f"No such project: {slug}. Existing: {listing}", file=sys.stderr)
|
|
return 1
|
|
|
|
ensure_project_files(directory)
|
|
prompt = read_file(directory / "prompt.md")
|
|
memory = read_file(directory / "memory.md")
|
|
state = read_file(directory / "state.md")
|
|
|
|
# prompt.md and state.md always go out whole; only memory.md gives ground.
|
|
overhead = len(prompt) + len(state) + 200
|
|
memory_out = fit_memory(memory, max(MAX_OUTPUT_CHARS - overhead, 0), slug)
|
|
|
|
sections = [
|
|
f"### prompt.md\n{prompt}",
|
|
f"### memory.md\n{memory_out}",
|
|
f"### state.md\n{state}",
|
|
]
|
|
print("\n\n".join(section.rstrip() + "\n" for section in sections), end="")
|
|
if not state.strip():
|
|
print("\n[!] state.md is empty")
|
|
return 0
|
|
|
|
|
|
def cmd_log(slug: str, entry_file: str | None) -> int:
|
|
"""Append the entry read from `entry_file` (or stdin when None) to memory.md.
|
|
|
|
The text is never passed on the command line: the exec safety guard scans the
|
|
raw command string and misreads prose as a filesystem path (see SKILL.md).
|
|
"""
|
|
directory = project_path(slug)
|
|
if not directory.is_dir():
|
|
slugs = existing_slugs()
|
|
listing = ", ".join(slugs) if slugs else "(none)"
|
|
print(f"No such project: {slug}. Existing: {listing}", file=sys.stderr)
|
|
return 1
|
|
|
|
if entry_file is None:
|
|
body = sys.stdin.read()
|
|
else:
|
|
source = Path(entry_file)
|
|
if not source.is_file():
|
|
print(f"No such file: {entry_file}", file=sys.stderr)
|
|
return 1
|
|
body = source.read_text(encoding="utf-8")
|
|
body = body.strip()
|
|
if not body:
|
|
print("Nothing to log (empty input).", file=sys.stderr)
|
|
return 1
|
|
|
|
today = datetime.now(TIMEZONE).date().isoformat()
|
|
entry = f"- {today}: {body}\n"
|
|
|
|
memory_file = directory / "memory.md"
|
|
existing = read_file(memory_file)
|
|
# Guarantee the new entry starts on its own line, whatever the file ends with.
|
|
separator = "" if not existing or existing.endswith("\n") else "\n"
|
|
with memory_file.open("a", encoding="utf-8") as handle:
|
|
handle.write(separator + entry)
|
|
|
|
print(json.dumps({"appended": entry.rstrip("\n")}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
def cmd_list() -> int:
|
|
slugs = existing_slugs()
|
|
if not slugs:
|
|
print("(no projects yet)")
|
|
return 0
|
|
width = max(len(slug) for slug in slugs)
|
|
for slug in slugs:
|
|
directory = project_path(slug)
|
|
sizes = []
|
|
for name in PROJECT_FILES:
|
|
path = directory / name
|
|
size = path.stat().st_size if path.is_file() else 0
|
|
label = name.removesuffix(".md")
|
|
flag = " (!)" if name == "state.md" and size == 0 else ""
|
|
sizes.append(f"{label} {format_size(size)}{flag}")
|
|
print(f"{slug:<{width}} " + " ".join(sizes))
|
|
return 0
|
|
|
|
|
|
def cmd_new(slug: str) -> int:
|
|
directory = project_path(slug)
|
|
if directory.exists():
|
|
print(f"Project already exists: {slug}", file=sys.stderr)
|
|
return 1
|
|
ensure_project_files(directory)
|
|
print(json.dumps({"created": slug, "files": list(PROJECT_FILES)}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="File operations for the /project skill")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
activate = sub.add_parser("activate", help="Print a project's three files")
|
|
activate.add_argument("slug")
|
|
|
|
log = sub.add_parser("log", help="Append a dated entry to memory.md")
|
|
log.add_argument("slug")
|
|
log.add_argument(
|
|
"--file",
|
|
default=None,
|
|
help="Path to a file holding the entry text; if omitted, read from stdin",
|
|
)
|
|
|
|
sub.add_parser("list", help="List projects with file sizes")
|
|
|
|
new = sub.add_parser("new", help="Create an empty project")
|
|
new.add_argument("slug")
|
|
|
|
args = parser.parse_args()
|
|
if args.command == "activate":
|
|
return cmd_activate(args.slug)
|
|
if args.command == "log":
|
|
return cmd_log(args.slug, args.file)
|
|
if args.command == "list":
|
|
return cmd_list()
|
|
return cmd_new(args.slug)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|