diff --git a/skills/distill-knowledge/SKILL.md b/skills/distill-knowledge/SKILL.md index e86c592..a120767 100755 --- a/skills/distill-knowledge/SKILL.md +++ b/skills/distill-knowledge/SKILL.md @@ -11,14 +11,14 @@ compatibility: Requires uv, ffmpeg, ffprobe, Python ≥3.10, and OPENAI_API_KEY license: MIT metadata: author: Dim Kharitonov (https://github.com/dimdasci) - version: "1.1.0" + version: "1.2.0" --- # Convert Recording → Knowledge Markdown Emit `outbox/{meeting-slug}/transcript.md`; screenshots inline when useful; structured docs only on request. `{meeting-slug}` = `kebab-case-topic-YYYYMMDD`. Never touch `inbox/` or `knowledge/`. -References (load on demand): [setup](references/setup.md) · [output templates](references/output-templates.md) · [ffmpeg](references/ffmpeg.md) · [transcribe CLI](references/transcribe-cli.md) · [prep audio CLI](references/prep-audio-cli.md) · [chunked transcription](references/chunked-transcription.md) · [structured docs](references/structured-docs.md) · spot-check: [`scripts/extract_clip.py`](scripts/extract_clip.py). +References (load on demand): [setup](references/setup.md) · [output templates](references/output-templates.md) · [ffmpeg](references/ffmpeg.md) · [transcribe CLI](references/transcribe-cli.md) · [prep audio CLI](references/prep-audio-cli.md) · [chunked transcription](references/chunked-transcription.md) · [structured docs](references/structured-docs.md) · [cleanup CLI](scripts/cleanup.py) · spot-check: [`scripts/extract_clip.py`](scripts/extract_clip.py). Scripts run via `uv run --script` (PEP 723). All support `--help`. On first run verify [setup prerequisites](references/setup.md). @@ -85,9 +85,19 @@ Technical / multilingual / mumbled audio → VTT-aligned path strongly preferred ### Steps 7–10 — Structured docs (conditional) -**Gate 2** — ask: structured docs or transcript only? If transcript only → report + stop. +**Gate 2** — ask: structured docs or transcript only? If transcript only → cleanup + report + stop. -Otherwise → [structured docs reference](references/structured-docs.md): plan topics, emit `summary.md` + `topics/{slug}.md`, report, stop. +Otherwise → [structured docs reference](references/structured-docs.md): plan topics, emit `summary.md` + `topics/{slug}.md`, cleanup, report, stop. + +### Step 11 — Cleanup (mandatory, after user accepts) + +Once the user confirms the results are acceptable, remove temporary prep artifacts via [`cleanup.py`](scripts/cleanup.py): + +```bash +uv run --script scripts/cleanup.py --slug {meeting-slug} +``` + +The script verifies `outbox/{meeting-slug}/transcript.md` exists before deleting `tmp/prep/{meeting-slug}/`. Use `--dry-run` to preview. **Never use `rm` directly — all temp removal goes through this script.** ## Fidelity rule @@ -107,3 +117,4 @@ When two transcripts agree on meaning → faithful. When they disagree and neith - **Don't load rich `--prompt`** on non-diarize model. Prompts leak as fabrications. Vocab list + 1-line topic max. See [prompt-hallucination warning](references/transcribe-cli.md#prompt-hallucination-warning). - **Don't auto-pick VTT decisions.** Always surface assessment; user confirms at Gate 1. - **Don't write outside `outbox/{meeting-slug}/`.** Temp files → `tmp/`; finals → `outbox/`. +- **Don't run `rm` on temp files.** Always use [`cleanup.py`](scripts/cleanup.py) — it validates output exists and only operates under `tmp/`. diff --git a/skills/distill-knowledge/scripts/cleanup.py b/skills/distill-knowledge/scripts/cleanup.py new file mode 100755 index 0000000..a1a647a --- /dev/null +++ b/skills/distill-knowledge/scripts/cleanup.py @@ -0,0 +1,138 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Remove temporary prep artifacts after the user accepts the transcript. + +Safety constraints: +- Only deletes directories under `/tmp/`. +- Refuses to act if the final transcript is missing in outbox. +- Never shells out to `rm`; uses Python's shutil.rmtree. + +Run with: + uv run --script skills/distill-knowledge/scripts/cleanup.py \ + --slug [--project-root .] +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path +from typing import NoReturn + + +def _die(message: str, code: int = 1) -> NoReturn: + print(f"Error: {message}", file=sys.stderr) + raise SystemExit(code) + + +def _resolve_project_root(raw: str) -> Path: + """Resolve and validate the project root directory.""" + root = Path(raw).resolve() + if not root.is_dir(): + _die(f"Project root is not a directory: {root}") + return root + + +def _assert_under_tmp(path: Path, tmp_root: Path) -> None: + """Hard guard: path must be strictly inside tmp_root.""" + try: + path.resolve().relative_to(tmp_root.resolve()) + except ValueError: + _die( + f"Refusing to delete {path}: it is not under {tmp_root}. " + "This script only removes directories inside the tmp/ folder." + ) + + +def _dir_size(path: Path) -> int: + """Total bytes of all files in a directory tree.""" + return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + + +def _human_size(nbytes: int) -> str: + """Format byte count as a human-readable string.""" + for unit in ("B", "KB", "MB", "GB"): + if nbytes < 1024: + return f"{nbytes:.1f} {unit}" + nbytes /= 1024 # type: ignore[assignment] + return f"{nbytes:.1f} TB" + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Remove temporary prep artifacts for a completed meeting." + ) + parser.add_argument( + "--slug", + required=True, + help="Meeting slug (e.g. architecture-dima-mark-20260211)", + ) + parser.add_argument( + "--project-root", + default=".", + help="Project root directory (default: current directory)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be deleted without actually deleting", + ) + + args = parser.parse_args() + slug: str = args.slug + root = _resolve_project_root(args.project_root) + tmp_root = root / "tmp" + prep_dir = tmp_root / "prep" / slug + outbox_dir = root / "outbox" / slug + transcript = outbox_dir / "transcript.md" + + # --- Pre-flight checks --- + + if not tmp_root.is_dir(): + _die(f"tmp/ directory does not exist: {tmp_root}") + + if not prep_dir.is_dir(): + _die(f"Nothing to clean: {prep_dir} does not exist") + + _assert_under_tmp(prep_dir, tmp_root) + + if not transcript.is_file(): + _die( + f"Final transcript not found at {transcript}. " + "Refusing to delete temp artifacts before the output is confirmed. " + "Finish the transcript first, then re-run cleanup." + ) + + # --- Inventory --- + + files = list(prep_dir.rglob("*")) + file_count = sum(1 for f in files if f.is_file()) + dir_count = sum(1 for f in files if f.is_dir()) + total_bytes = _dir_size(prep_dir) + + print(f"Slug: {slug}", file=sys.stderr) + print(f"Prep dir: {prep_dir}", file=sys.stderr) + print(f"Transcript: {transcript} ✓", file=sys.stderr) + print(f"Files: {file_count}", file=sys.stderr) + print(f"Directories: {dir_count}", file=sys.stderr) + print(f"Size: {_human_size(total_bytes)}", file=sys.stderr) + + if args.dry_run: + print( + "\n[dry-run] Would delete the above. Pass without --dry-run to execute.", + file=sys.stderr, + ) + return + + # --- Delete --- + + shutil.rmtree(prep_dir) + print(f"\nDeleted {prep_dir} — freed {_human_size(total_bytes)}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/distill-knowledge/scripts/tests/test_cleanup.py b/skills/distill-knowledge/scripts/tests/test_cleanup.py new file mode 100644 index 0000000..c754dd4 --- /dev/null +++ b/skills/distill-knowledge/scripts/tests/test_cleanup.py @@ -0,0 +1,106 @@ +"""Tests for cleanup.py safety logic.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +SCRIPT = str(Path(__file__).resolve().parent.parent / "cleanup.py") + + +def _run( + tmp_path: Path, slug: str, *, dry_run: bool = False, extra_args: list[str] | None = None +) -> subprocess.CompletedProcess[str]: + cmd = [ + sys.executable, + SCRIPT, + "--slug", + slug, + "--project-root", + str(tmp_path), + ] + if dry_run: + cmd.append("--dry-run") + if extra_args: + cmd.extend(extra_args) + return subprocess.run(cmd, capture_output=True, text=True) + + +def _setup_project(tmp_path: Path, slug: str, *, with_transcript: bool = True) -> None: + """Create minimal project structure with tmp/prep and optionally outbox.""" + prep = tmp_path / "tmp" / "prep" / slug + prep.mkdir(parents=True) + # Create some temp files + (prep / "stripped.ogg").write_bytes(b"\x00" * 1024) + (prep / "manifest.json").write_text(json.dumps({"version": 1})) + chunks = prep / "chunks" + chunks.mkdir() + (chunks / "chunk_00.ogg").write_bytes(b"\x00" * 512) + + if with_transcript: + outbox = tmp_path / "outbox" / slug + outbox.mkdir(parents=True) + (outbox / "transcript.md").write_text("# Test transcript\n") + + +def test_refuses_without_transcript(tmp_path: Path) -> None: + """Cleanup must refuse if outbox transcript is missing.""" + slug = "no-transcript-slug" + _setup_project(tmp_path, slug, with_transcript=False) + + result = _run(tmp_path, slug) + assert result.returncode != 0 + assert "Final transcript not found" in result.stderr + # Prep dir must still exist + assert (tmp_path / "tmp" / "prep" / slug).is_dir() + + +def test_refuses_nonexistent_slug(tmp_path: Path) -> None: + """Cleanup must fail if prep dir doesn't exist.""" + (tmp_path / "tmp").mkdir(parents=True) + result = _run(tmp_path, "ghost-slug") + assert result.returncode != 0 + assert "does not exist" in result.stderr + + +def test_dry_run_preserves_files(tmp_path: Path) -> None: + """Dry run should report but not delete.""" + slug = "dry-run-test" + _setup_project(tmp_path, slug) + + result = _run(tmp_path, slug, dry_run=True) + assert result.returncode == 0 + assert "dry-run" in result.stderr + # Nothing deleted + assert (tmp_path / "tmp" / "prep" / slug / "stripped.ogg").exists() + + +def test_actual_cleanup(tmp_path: Path) -> None: + """Actual cleanup should remove prep dir when transcript exists.""" + slug = "real-cleanup" + _setup_project(tmp_path, slug) + + prep = tmp_path / "tmp" / "prep" / slug + assert prep.is_dir() + + result = _run(tmp_path, slug) + assert result.returncode == 0 + assert "Deleted" in result.stderr + assert "freed" in result.stderr + # Prep dir gone + assert not prep.exists() + # Transcript untouched + assert (tmp_path / "outbox" / slug / "transcript.md").is_file() + + +def test_reports_file_count_and_size(tmp_path: Path) -> None: + """Dry run output should include file count and size.""" + slug = "inventory-check" + _setup_project(tmp_path, slug) + + result = _run(tmp_path, slug, dry_run=True) + assert "Files:" in result.stderr + assert "Size:" in result.stderr + assert "Directories:" in result.stderr