← back to Knowledge Layer Skill

knowledge-layer/scripts/kl_scan.py

132 lines

#!/usr/bin/env python3
"""
kl_scan.py — incremental compile work-list for a Knowledge Layer.

The whole point of the Karpathy/gBrain pattern is that you DON'T recompile the
whole wiki every time. This script content-hashes everything under `sources/`,
diffs against `.kl/manifest.json`, and prints exactly what changed so the
compile agent only touches NEW / CHANGED sources (and knows which entries a
REMOVED source used to support).

Usage:
    kl_scan.py [<kl-root>]              # show the work-list (read-only)
    kl_scan.py [<kl-root>] --json       # same, machine-readable
    kl_scan.py [<kl-root>] --commit     # mark current sources as compiled

Run WITHOUT --commit before a compile pass (to get the work-list) and WITH
--commit only after you have finished writing the wiki entries for those
sources — otherwise the manifest will claim work is done that isn't.

Exit codes: 0 = nothing to do, 10 = there is pending work, 2 = error.
"""
import argparse
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".rst", ".org", ".json",
                 ".yaml", ".yml", ".csv", ".html", ".htm", ".vtt", ".srt"}


def sha256(p: Path) -> str:
    h = hashlib.sha256()
    with p.open("rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def scan_sources(root: Path) -> dict:
    src = root / "sources"
    out = {}
    if not src.is_dir():
        return out
    for p in sorted(src.rglob("*")):
        if not p.is_file() or p.name.startswith("."):
            continue
        if p.suffix.lower() not in TEXT_SUFFIXES:
            # still track binaries by hash so their removal is noticed
            pass
        rel = p.relative_to(root).as_posix()
        st = p.stat()
        out[rel] = {"hash": sha256(p), "bytes": st.st_size}
    return out


def load_manifest(root: Path) -> dict:
    m = root / ".kl" / "manifest.json"
    if m.exists():
        try:
            return json.loads(m.read_text())
        except json.JSONDecodeError:
            pass
    return {"version": 1, "compiled_at": None, "sources": {}}


def main() -> int:
    ap = argparse.ArgumentParser(description="Incremental compile work-list.")
    ap.add_argument("root", nargs="?", default="knowledge-layer")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    ap.add_argument("--commit", action="store_true",
                    help="record current sources as compiled")
    args = ap.parse_args()

    root = Path(args.root).resolve()
    if not (root / ".kl").is_dir():
        print(f"ERROR: {root} is not a Knowledge Layer (run kl_init.py first).",
              file=sys.stderr)
        return 2

    manifest = load_manifest(root)
    prev = manifest.get("sources", {})
    now = scan_sources(root)

    new = [k for k in now if k not in prev]
    changed = [k for k in now if k in prev and now[k]["hash"] != prev[k]["hash"]]
    removed = [k for k in prev if k not in now]

    if args.commit:
        manifest["sources"] = now
        manifest["compiled_at"] = datetime.now(timezone.utc).isoformat()
        (root / ".kl" / "manifest.json").write_text(
            json.dumps(manifest, indent=2) + "\n")
        print(f"committed: {len(now)} sources marked compiled at "
              f"{manifest['compiled_at']}")
        return 0

    pending = len(new) + len(changed) + len(removed)
    if args.json:
        print(json.dumps({"new": new, "changed": changed, "removed": removed,
                          "total_sources": len(now),
                          "last_compiled": manifest.get("compiled_at")}, indent=2))
    else:
        print(f"Knowledge Layer: {root}")
        print(f"last compiled: {manifest.get('compiled_at') or 'never'}")
        print(f"sources: {len(now)} tracked\n")
        if not pending:
            print("Up to date — no NEW/CHANGED/REMOVED sources.")
        else:
            if new:
                print(f"NEW ({len(new)}) — compile into typed entries:")
                for k in new:
                    print(f"  + {k}")
            if changed:
                print(f"CHANGED ({len(changed)}) — re-read & merge into existing "
                      f"entries (surface contradictions, don't silently overwrite):")
                for k in changed:
                    print(f"  ~ {k}")
            if removed:
                print(f"REMOVED ({len(removed)}) — review entries that cited these; "
                      f"drop unsupported claims or mark them stale:")
                for k in removed:
                    print(f"  - {k}")
            print("\nAfter you finish writing entries, run with --commit.")

    return 10 if pending else 0


if __name__ == "__main__":
    sys.exit(main())