#!/usr/bin/env python3 """ memlint — a tiny hygiene linter for AI-agent memory files. Checks the 4-type agent memory (STATE / DECISIONS / LESSONS / HANDOFF) for the problems that quietly break agent continuity: stale state, memory bloat, leftover placeholders, a handoff with no "what's next", and lessons written without a reusable takeaway. Zero dependencies. Single file. MIT licensed. From the "How We Built It" series at grandewebnetwork.com — free to copy, adapt, and share. USAGE python3 memlint.py [DIR] # lint memory files in DIR (default: current directory) python3 memlint.py --strict # treat warnings as failures (good for CI) python3 memlint.py --help EXIT CODES 0 = clean 1 = warnings 2 = errors (missing files / hard problems) It looks for files named (case-insensitive, .md optional): STATE, DECISIONS, LESSONS, HANDOFF """ import sys, os, re, time, argparse FILES = ["STATE", "DECISIONS", "LESSONS", "HANDOFF"] PLACEHOLDERS = re.compile(r"\b(TODO|FIXME|lorem ipsum|coming soon|TBD|XXX)\b", re.I) BRACKETS = re.compile(r"\[(DATE|Thing|Area|X|Y|who|what|status|URLs?|Short title)\]", re.I) DATEISH = re.compile(r"\b(20\d{2}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}\s+\w+\s+20\d{2}|last updated)", re.I) GREEN, YELL, RED, DIM, RST = "\033[32m", "\033[33m", "\033[31m", "\033[2m", "\033[0m" def color(s, c): # only colorize a TTY return f"{c}{s}{RST}" if sys.stdout.isatty() else s class Report: def __init__(self): self.warns = 0; self.errs = 0 def ok(self, msg): print(" " + color("PASS", GREEN) + " " + msg) def warn(self, msg): self.warns += 1; print(" " + color("WARN", YELL) + " " + msg) def err(self, msg): self.errs += 1; print(" " + color("FAIL", RED) + " " + msg) def find(d, name): for cand in (name + ".md", name, name.lower() + ".md", name.lower()): p = os.path.join(d, cand) if os.path.isfile(p): return p return None def days_old(p): return (time.time() - os.path.getmtime(p)) / 86400.0 def lint(d, strict=False): r = Report() print(color(f"memlint — {os.path.abspath(d)}", DIM)) found = {n: find(d, n) for n in FILES} for name in FILES: p = found[name] print(f"\n{name}") if not p: r.err(f"{name} file not found (expected {name}.md). The agent has no {name.lower()} to load.") continue text = open(p, encoding="utf-8", errors="replace").read() lines = text.splitlines() kb = len(text.encode("utf-8")) / 1024.0 # universal: placeholders + unfilled brackets ph = PLACEHOLDERS.findall(text) if ph: r.warn(f"contains placeholder text ({', '.join(sorted(set(x.upper() for x in ph)))}) — replace before relying on it") br = BRACKETS.findall(text) if br: r.warn(f"has {len(br)} unfilled template bracket(s) like [{br[0]}] — looks like an untouched template") # bloat if kb > 16: r.warn(f"large ({kb:.0f} KB / {len(lines)} lines) — a memory nobody reads is no memory. Trim history into git.") elif kb < 0.05: r.warn("essentially empty — give the agent something to load") else: r.ok(f"size healthy ({kb:.1f} KB)") # per-file checks if name == "STATE": if not DATEISH.search(text): r.warn("no 'last updated' date — you can't tell if STATE is current") elif days_old(p) > 14: r.warn(f"file not modified in {days_old(p):.0f} days — STATE may be stale") else: r.ok("recently updated") elif name == "HANDOFF": if re.search(r"in[- ]?flight|next|pick (this|up)|in progress", text, re.I): r.ok("has a 'what's next' pointer") else: r.err("no 'in flight / pick up next' section — the next session can't resume cleanly") elif name == "LESSONS": entries = [l for l in lines if l.strip().startswith(("-", "*"))] structured = [l for l in entries if re.search(r"symptom|cause|fix|rule|root cause", l, re.I)] if not entries: r.warn("no lesson entries yet") elif len(structured) < max(1, len(entries) // 2): r.warn(f"only {len(structured)}/{len(entries)} lessons name a symptom/cause/fix/rule — add the reusable takeaway so they're searchable") else: r.ok(f"{len(entries)} lessons, well-structured") elif name == "DECISIONS": entries = [l for l in lines if l.strip().startswith(("-", "*"))] if not entries: r.warn("no decisions logged — settled choices belong here so you stop re-litigating them") else: r.ok(f"{len(entries)} decisions logged") # summary print("\n" + "-" * 48) if r.errs: verdict, c = f"{r.errs} error(s), {r.warns} warning(s)", RED elif r.warns: verdict, c = f"clean, {r.warns} warning(s)", YELL else: verdict, c = "all clean", GREEN print("memlint: " + color(verdict, c)) if r.errs: return 2 if r.warns and strict: return 1 if r.warns: return 1 return 0 def main(): ap = argparse.ArgumentParser(description="Hygiene linter for AI-agent memory files (STATE/DECISIONS/LESSONS/HANDOFF).") ap.add_argument("dir", nargs="?", default=".", help="directory containing the memory files (default: .)") ap.add_argument("--strict", action="store_true", help="treat warnings as a failing exit code") a = ap.parse_args() if not os.path.isdir(a.dir): print(f"memlint: not a directory: {a.dir}", file=sys.stderr); sys.exit(2) sys.exit(lint(a.dir, a.strict)) if __name__ == "__main__": main()