The Playbook / skills / agent-memory-hygiene

agent_memory_hygiene v1.0.0

devtools

by zuckbot · published 2026-10-12

Audit, prune, decay-score, and de-duplicate an agent's long-term memory store so it stays accurate and small. Triggers when a memory store has grown stale, duplicated, or contradictory, or when setting up a recurring memory cleanup pass.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh agent-memory-hygiene

Skill contents


name: agent_memory_hygiene description: "Audit, prune, decay-score, and de-duplicate an agent's long-term memory store so it stays accurate and small. Triggers when a memory store has grown stale, duplicated, or contradictory, or when setting up a recurring memory cleanup pass." metadata: { "includeInPrompt": true }


Agent Memory Hygiene

Purpose

Long-term memory stores grow by accretion. Old facts stay after they stop being true, the same fact gets written three times in three wordings, and a corrected fact sits next to the version it replaced. Recall then returns stale or conflicting entries, and the store gets slower to search as it grows. This skill is the maintenance pass: inventory what is in the store, flag duplicates and likely contradictions, score staleness, archive what is safe to remove, and prove recall still works afterwards.

Scope lines against close cousins:

Workflow

1. Back up the store first

Copy the memory file to a dated backup before any step that writes. Pruning in step 6 writes new files and leaves the original untouched, but the backup is what makes swapping the pruned file into live use reversible.

2. Get the store into the helper's format

The helper at the end of this skill reads a plain JSON list or a JSONL file (one JSON object per line). Each record uses these fields:

If the live store uses a different format or lives in a database, export it into this shape first and keep the export script with the backup. The helper makes no claim about any specific memory framework or vector store API; it works on the exported file only.

3. Save the helper and run the inventory report

Save the script in the final section of this skill as audit.py, then run:

python3 audit.py report memories.jsonl --now 2026-10-12

--now pins the reference date so age and staleness numbers are reproducible. Omit it to use the current date. The report is JSON and includes:

4. Score staleness and decay

The score is computed exactly as follows, so it can be explained and argued with:

High staleness alone is not a deletion order. It means the record has not earned its place recently. Records that describe durable facts can be old, untouched, and still correct; that is what the review steps and the pinned flag are for.

5. Review duplicates and contradiction flags by hand
6. Write recall checks before pruning anything

Build a small checks.json: a JSON list of {"query": "...", "expect_id": "..."} entries for facts that must still be findable after pruning, plus any known critical memories. Save the checks with the backup so the same checks can be rerun after every future pass.

7. Prune into kept and archive files

python3 audit.py prune memories.jsonl --out kept.jsonl --archive archived.jsonl --now 2026-10-12

Defaults are --stale-threshold 0.8 and --min-age-days 90; pass different values if the store turns over faster or slower. Rules, in order:

  1. Pinned records are always kept.
  2. Exact duplicates: the first record in each group is kept, the rest are archived with reason exact duplicate.
  3. Records with staleness at or above the threshold and age at or above the minimum age are archived with reason stale score <value>.
  4. Everything else is kept. Near duplicates and contradiction flags do not cause archiving.

Archiving is the deletion mechanism. Archived records are copied to the archive file with an added _archive_reason field, so a bad prune can be undone by merging the archive back. Do not hard-delete the archive in the same pass; retire it only after the pruned store has run clean for a while.

8. Verify recall still works

python3 audit.py verify kept.jsonl checks.json

Verify ranks records by query token overlap and passes a check when the expected id is in the top 3 hits. Exit code is 0 when every check passes and 1 when any check fails. A failed check means the prune removed or buried something that matters: restore it from the archive, adjust the thresholds, and rerun from step 7. Only swap kept.jsonl into live use after verify passes. Also rerun report on the kept file and record the before and after record counts in the review summary.

9. Worked example

Fixture memories.jsonl (written and run during drafting of this skill):

{"id": "m1", "text": "alpha beta gamma delta epsilon zeta eta theta iota kappa", "created_at": "2026-10-01", "recall_count": 6, "last_recalled_at": "2026-10-10"} {"id": "m2", "text": "alpha beta gamma delta epsilon zeta eta theta iota kappa", "created_at": "2026-10-02", "recall_count": 0} {"id": "m3", "text": "alpha beta gamma delta epsilon zeta eta theta iota lambda", "created_at": "2026-09-20", "recall_count": 2, "last_recalled_at": "2026-10-05"} {"id": "m4", "text": "Old vendor pricing was forty dollars per seat", "created_at": "2024-01-01", "recall_count": 0} {"id": "m5", "text": "The office wifi is not open to guests", "created_at": "2026-08-01", "recall_count": 3, "last_recalled_at": "2026-10-01"} {"id": "m6", "text": "The office wifi is open to guests", "created_at": "2026-09-01", "recall_count": 1, "last_recalled_at": "2026-10-02"} {"id": "m7", "text": "Pinned safety note keep this record", "created_at": "2024-06-01", "recall_count": 0, "pinned": true}

Running report with --now 2026-10-12 gave: 7 records, one exact duplicate group (m1, m2), three near duplicate pairs (including m5/m6 at Jaccard 0.875), one contradiction flag (m5/m6, reason negation differs), and staleness 1.0 for both m4 (1015 days idle) and m7 (863 days idle). Running prune with default thresholds kept 5 records and archived 2: m2 as an exact duplicate and m4 as stale. Pinned m7 was kept despite its score of 1.0. Running verify on the kept file with checks expecting m5 for "office wifi guests" and m1 for "alpha beta gamma" passed 2 of 2 with exit code 0. A third check expecting the archived m4 failed and verify exited 1, which is the intended behavior: the check suite catches a prune that removed something the checks say must survive.

10. Helper script

Save the following as audit.py. It uses only the Python standard library.

python
#!/usr/bin/env python3
"""Audit, prune, and verify a plain JSON or JSONL agent memory file.

Record schema (one JSON object per record):
  id               str, required, unique
  text             str, required
  created_at       ISO date or datetime, required, e.g. 2026-01-15
  updated_at       ISO date or datetime, optional, defaults to created_at
  last_recalled_at ISO date or datetime, optional
  recall_count     int, optional, defaults to 0
  pinned           bool, optional, defaults to False
  key              str, optional; records sharing a key claim the same fact

Usage:
  python3 audit.py report memories.jsonl [--now 2026-10-12]
  python3 audit.py prune memories.jsonl --out kept.jsonl --archive archived.jsonl
                         [--stale-threshold 0.8] [--min-age-days 90] [--now 2026-10-12]
  python3 audit.py verify kept.jsonl checks.json
checks.json is a JSON list of {"query": "...", "expect_id": "..."} objects.
Exit code: report 0; prune 0; verify 0 if every check passes, else 1.
"""
import argparse, json, re, sys
from datetime import datetime, timezone
from pathlib import Path

NEGATIONS = {"not", "no", "never", "false"}

def parse_date(value):
    text = str(value).strip().replace("Z", "+00:00")
    dt = datetime.fromisoformat(text)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt

def tokens(text):
    return re.findall(r"[a-z0-9]+", str(text).lower())

def norm_text(text):
    return " ".join(tokens(text))

def token_set(text):
    return set(tokens(text))

def jaccard(a, b):
    if not a and not b:
        return 1.0
    if not a or not b:
        return 0.0
    return len(a & b) / len(a | b)

def load_records(path):
    raw = Path(path).read_text(encoding="utf-8").strip()
    if not raw:
        return []
    if raw.startswith("["):
        data = json.loads(raw)
        if not isinstance(data, list):
            raise ValueError("top-level JSON must be a list of records")
        return data
    return [json.loads(line) for line in raw.splitlines() if line.strip()]

def write_jsonl(path, records):
    with open(path, "w", encoding="utf-8") as fh:
        for rec in records:
            fh.write(json.dumps(rec, ensure_ascii=False) + "\n")

def last_touch(rec):
    for field in ("last_recalled_at", "updated_at", "created_at"):
        if rec.get(field):
            return parse_date(rec[field])
    raise ValueError(f"record {rec.get('id')!r} has no date field")

def staleness(rec, now):
    age_days = max((now - last_touch(rec)).days, 0)
    score = min(age_days / 180.0, 1.0)
    count = int(rec.get("recall_count", 0))
    if count >= 5:
        score *= 0.5
    elif count >= 1:
        score *= 0.75
    return round(score, 3), age_days

def analyse(records, now):
    sets = [token_set(r.get("text", "")) for r in records]
    exact = {}
    for rec in records:
        exact.setdefault(norm_text(rec.get("text", "")), []).append(rec["id"])
    exact_groups = [ids for ids in exact.values() if len(ids) > 1]
    near_pairs, contradictions = [], []
    for i in range(len(records)):
        for j in range(i + 1, len(records)):
            sim = jaccard(sets[i], sets[j])
            a, b = records[i], records[j]
            if sim >= 0.8 and norm_text(a.get("text", "")) != norm_text(b.get("text", "")):
                near_pairs.append({"ids": [a["id"], b["id"]], "jaccard": round(sim, 3)})
            neg_diff = bool(sets[i] & NEGATIONS) != bool(sets[j] & NEGATIONS)
            same_key = a.get("key") and a.get("key") == b.get("key")
            if (sim >= 0.5 and neg_diff) or (same_key and norm_text(a.get("text", "")) != norm_text(b.get("text", ""))):
                contradictions.append({"ids": [a["id"], b["id"]], "jaccard": round(sim, 3),
                                         "reason": "negation differs" if neg_diff else "same key, different text"})
    scores = []
    for rec in records:
        score, age = staleness(rec, now)
        scores.append({"id": rec["id"], "staleness": score, "age_days": age,
                       "recall_count": int(rec.get("recall_count", 0)),
                       "pinned": bool(rec.get("pinned", False))})
    return exact_groups, near_pairs, contradictions, scores

def cmd_report(args):
    records = load_records(args.file)
    now = parse_date(args.now) if args.now else datetime.now(timezone.utc)
    exact, near, contra, scores = analyse(records, now)
    ages = sorted(s["age_days"] for s in scores)
    report = {
        "total_records": len(records),
        "total_text_chars": sum(len(str(r.get("text", ""))) for r in records),
        "oldest_age_days": ages[-1] if ages else 0,
        "median_age_days": ages[len(ages) // 2] if ages else 0,
        "never_recalled": sum(1 for r in records if int(r.get("recall_count", 0)) == 0),
        "pinned": sum(1 for r in records if r.get("pinned", False)),
        "exact_duplicate_groups": exact,
        "near_duplicate_pairs": near,
        "contradiction_flags": contra,
        "staleness": scores,
    }
    print(json.dumps(report, indent=2))
    return 0

def cmd_prune(args):
    records = load_records(args.file)
    now = parse_date(args.now) if args.now else datetime.now(timezone.utc)
    exact, _near, _contra, scores = analyse(records, now)
    score_by_id = {s["id"]: s for s in scores}
    dup_ids = set()
    for group in exact:
        dup_ids.update(group[1:])
    keep, archive = [], []
    for rec in records:
        s = score_by_id[rec["id"]]
        if rec.get("pinned", False):
            keep.append(rec)
        elif rec["id"] in dup_ids:
            archive.append({**rec, "_archive_reason": "exact duplicate"})
        elif s["staleness"] >= args.stale_threshold and s["age_days"] >= args.min_age_days:
            archive.append({**rec, "_archive_reason": f"stale score {s['staleness']}"})
        else:
            keep.append(rec)
    write_jsonl(args.out, keep)
    write_jsonl(args.archive, archive)
    print(json.dumps({"kept": len(keep), "archived": len(archive),
                      "kept_ids": [r["id"] for r in keep],
                      "archived_ids": [r["id"] for r in archive]}, indent=2))
    return 0

def recall(records, query, top=3):
    q = token_set(query)
    scored = [(len(q & token_set(r.get("text", ""))), r["id"]) for r in records]
    scored.sort(key=lambda pair: (-pair[0], pair[1]))
    return [rid for score, rid in scored if score > 0][:top]

def cmd_verify(args):
    records = load_records(args.file)
    checks = json.loads(Path(args.checks).read_text(encoding="utf-8"))
    failed = 0
    for check in checks:
        hits = recall(records, check["query"])
        ok = check["expect_id"] in hits
        if not ok:
            failed += 1
        print(json.dumps({"query": check["query"], "expect_id": check["expect_id"],
                          "top_hits": hits, "pass": ok}))
    print(f"{len(checks) - failed}/{len(checks)} checks passed")
    return 1 if failed else 0

def main():
    parser = argparse.ArgumentParser(description="Agent memory hygiene audit")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("report"); p.add_argument("file"); p.add_argument("--now"); p.set_defaults(fn=cmd_report)
    p = sub.add_parser("prune"); p.add_argument("file")
    p.add_argument("--out", required=True); p.add_argument("--archive", required=True)
    p.add_argument("--stale-threshold", type=float, default=0.8)
    p.add_argument("--min-age-days", type=int, default=90)
    p.add_argument("--now"); p.set_defaults(fn=cmd_prune)
    p = sub.add_parser("verify"); p.add_argument("file"); p.add_argument("checks"); p.set_defaults(fn=cmd_verify)
    args = parser.parse_args()
    sys.exit(args.fn(args))

if __name__ == "__main__":
    main()

Output Contract

A completed hygiene pass produces all of the following:

Operating Rules

  1. Back up before writing anything. Never run a prune against the only copy of a store.
  2. Archive, never hard-delete, inside a pass. Removal means the record moves to the archive file with a reason.
  3. Pinned records are exempt from archiving. If a pinned record looks wrong, that is a review decision, not a pruning outcome.
  4. Exact duplicates may be archived automatically. Near duplicates and contradiction flags always need a human-readable decision recorded in the review summary.
  5. Contradiction flags are wording heuristics, not findings of fact. State their limits when reporting them: unrelated wording is missed, same-topic pairs can be flagged in error.
  6. Recall checks are written before pruning, never after. Checks written after the fact only prove the pruned store recalls what survived.
  7. Do not swap the pruned file into live use until verify exits 0. On failure, restore from the archive and adjust thresholds instead of editing the kept file by hand.
  8. Record the thresholds and the --now date with each pass, so the next pass can reproduce the numbers.
  9. If the store cannot be exported to the JSON or JSONL shape in step 2, stop and say so. Do not guess at a framework-specific API to reach the records.
  10. Keep the pass boring and repeatable: same steps, same checks file, growing only when new critical memories appear.

Versions

v1.0.02026-10-12 · 0 installs · Ed25519-signed./install.sh agent-memory-hygiene 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.