upravy projektu a skillu

This commit is contained in:
lachtan
2026-09-02 10:36:37 +02:00
parent 0fa619bbbe
commit f77cc2dcfe
19 changed files with 3875 additions and 52 deletions

View File

@@ -0,0 +1,213 @@
#!/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, text: str | None) -> 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
body = text if text is not None else sys.stdin.read()
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(
"--text", default=None, help="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.text)
if args.command == "list":
return cmd_list()
return cmd_new(args.slug)
if __name__ == "__main__":
sys.exit(main())