upravy projektu a skillu
This commit is contained in:
213
skills/project/scripts/project_cli.py
Executable file
213
skills/project/scripts/project_cli.py
Executable 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())
|
||||
Reference in New Issue
Block a user