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.
./install.sh agent-memory-hygienename: 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 }
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:
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.
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:
id (required): unique string.text (required): the memory content.created_at (required): ISO date or datetime, for example 2026-01-15.updated_at (optional): ISO date or datetime; defaults to created_at.last_recalled_at (optional): ISO date or datetime of the last successful recall.recall_count (optional): integer, defaults to 0.pinned (optional): boolean, defaults to false. Pinned records are never archived.key (optional): string. Records sharing a key are claims about the same fact.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.
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:
total_records, total_text_chars: store size.oldest_age_days, median_age_days, never_recalled, pinned: the age and use profile.exact_duplicate_groups: groups of ids whose normalized text is identical (lowercased, punctuation stripped, whitespace collapsed).near_duplicate_pairs: pairs with token-set Jaccard similarity at or above 0.8 that are not exact duplicates.contradiction_flags: pairs flagged as likely contradictions (see step 5 for how weak or strong that signal is).staleness: one entry per record with its score, age in days, recall count, and pinned state.The score is computed exactly as follows, so it can be explained and argued with:
--now and the record's last touch, where last touch is last_recalled_at if present, else updated_at, else created_at.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.
not, no, never, false), or when two records share a key but have different normalized text. Treat these as candidates, not proof. The check is wording-based: it misses contradictions stated in unrelated words, and it can flag pairs that merely discuss the same topic. Decide each flagged pair from source and date evidence outside the script, keep the surviving text, and archive the loser with a note in the review summary. Never let the script resolve a contradiction.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.
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:
exact duplicate.stale score <value>.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.
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.
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.
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()
A completed hygiene pass produces all of the following:
kept.jsonl: the pruned store, not yet swapped into live use until verify passes.archived.jsonl: every removed record with its _archive_reason field.kept.jsonl against checks.json, showing every check passing.--now date with each pass, so the next pass can reproduce the numbers.publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.