commit 7e3d55dad64135778d45c18827f6b4cd1fd38a0a Author: Victor Taelin Date: Sat Jul 25 13:58:35 2026 -0300 OptMem: a permanent, append-only memory for AI agents diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cabf8b --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# OptMem + +A permanent memory for AI agents. One machine holds one identity that survives +every new session, every compaction, and every change of model or vendor. + +It is two append-only text files and four commands. No daemon, no database, no +API, no integration with any particular agent harness — it works the same under +Claude Code, Codex, pi or a human at a shell. + +## The problem + +An agent's context window is its whole world, and the world ends every session. +The usual patch is a notes file the agent rewrites by hand, which decays into +either a stale summary or a wall of text nobody can afford to read. + +OptMem fixes the two halves separately: + +- **Nothing is ever forgotten.** Every memory is appended to `LOG.txt` and + never edited or deleted. That file is the truth, forever. +- **What you read is a fixed size.** `memo wake` prints a document of bounded + length — recent memories verbatim, older ones progressively compressed. At + a hundred million memories it is still the same number of lines. + +## Install + +```sh +git clone https://github.com/VictorTaelin/OptMem ~/OptMem +export PATH="$HOME/OptMem:$PATH" +export MEMORY_DIR="$HOME/memory" # required; there is no default +``` + +`MEMORY_DIR` is the only machine-specific fact in the system. One machine, one +`MEMORY_DIR`, one identity. + +## Use + +```sh +memo wake # who you are. run this first, every session. +memo note "..." # record a memory. one line, <= 280 chars. +memo sleep # compress. keep going until it says you woke up. +memo recall # search the raw log for detail a summary lost. +memo forget - # drop a wrong summary; the next sleep redoes it. +``` + +``` +$ memo note "OptMem: LOG.txt is the truth, TREE.txt is the cache, wake reads both" +ok, memory #4213. + +You are dreaming. Compress these two summaries into ONE line of at most 280 +characters. +... +Then run exactly: + memo sleep 4192-4196 "" +``` + +## How it works + +`LOG.txt` is the ground truth: one memory per line, forever. + +``` +#4211 2026-07-25 taelin: memory must be append-only, one line per entry +#4212 2026-07-25 minilin fleet renamed from bip; one mini = one identity +#4213 2026-07-25 OptMem: LOG.txt is the truth, TREE.txt is the cache +``` + +`TREE.txt` is a cache of summaries. A **block** is an aligned power-of-two range +of memories compressed into a single line, and a block is built from its two +halves — so the blocks form a binary merge tree over the log: + +``` +#0 #1 #2 #3 #4 #5 #6 #7 the raw memories + \ / \ / \ / \ / + [0-2) [2-4) [4-6) [6-8) each one line, <= 280 chars + \ / \ / + [0-4) [4-8) + \ / + [0-8) +``` + +A block covering four thousand memories is still one line of 280 characters. +Nothing in the system is ever bigger than one line. + +`memo wake` picks a set of blocks that tiles the whole log and prints them. It +keeps a block whole when its size is small relative to its age, so **detail is +proportional to recency**, and it spends exactly `WAKE_LINES` lines doing it: + +``` +10,000 memories, WAKE_LINES = 320: + + block size: 1 2 4 8 16 32 64 128 256 + how many: 70 35 35 35 36 35 35 35 4 + └ the last 70, verbatim ───────────▶ the first 1,000, 256:1 +``` + +The oldest memories are recalled as a vague shape, the newest word for word, +and the transition is smooth. Below `WAKE_LINES` memories nothing is compressed +at all — your whole life is printed verbatim, because it fits. + +## The invariant + +**There is never any doable work pending.** The moment a block's range is +complete, that block can be built, and it must be. This costs about one small +compression per memory written, and it means: + +- `memo wake` never waits. The blocks it needs were built long ago. +- Work is never deferred into a spike. Measured over 20,000 memories, a new + memory creates one compression on average and nine at the very worst. +- `WAKE_LINES` can be changed at any time, on any machine, with nothing to + recompute. It only selects which existing lines get printed. + +`memo wake` enforces the invariant: while any compression is pending it refuses +to print, and hands you the work instead. A memory with work left in it is not +yet the truth. + +## Writing a good memory + +A note costs one future compression, so it is not free — but an unwritten +memory is gone forever, which is far more expensive. Write one whenever +something is genuinely worth keeping: a fact or a ruling from the user, an +insight, a decision, a piece of work landing, something that failed and why. +Do not log trivia, do not narrate your own process, and do not hoard. + +Compress toward facts, not prose. Keep names, numbers, dates, paths, ids and +decisions; drop wording. + +``` +bad worked on the memory system today and made good progress on the design +good OptMem design settled: LOG.txt append-only truth, TREE.txt binary merge + tree of 280-char summaries, wake renders a fixed 320-line document +``` + +## Files + +``` +$MEMORY_DIR/ + LOG.txt #id date text append-only. never edited. the truth. + TREE.txt lo-hi text a cache. each block written once, unless forgotten. + config ENTRY_CHARS=280 longest a memory may be + WAKE_LINES=320 how many lines `memo wake` prints (~24k tokens) +``` + +Both files are plain text, sorted by construction, and safe to read with any +tool. Writes are serialised with a lock, so parallel sessions on one machine can +append at the same time without corrupting anything. + +Agents must never create, edit or delete anything in `MEMORY_DIR` themselves. +Every write goes through `memo`, which enforces the one-line and character +limits, assigns ids, and refuses to overwrite a block that already exists. + +## Correcting a memory + +You cannot. Append the correction instead: + +``` +memo note "correction: the halt bug was in the column order, not the row order (see #4198)" +``` + +Both lines are true history, and compression will merge them. This is why +nothing is ever lost: `memo recall` still finds the original. + +A *summary* is different. It is not history, it is a cache of a pure function +of the log, and it can simply be wrong — mistyped, or badly compressed. Drop +it and everything built on top of it: + +``` +$ memo forget 188-192 +forgot 176-192 184-192 188-192. They will be compressed again on your next sleep. +``` + +That is a handful of small compressions, not a rebuild, and `LOG.txt` is never +touched. Fixing one bad summary can never cost you a memory. + +## Add this to your agent's instruction file + +Put it at the top of `AGENTS.md` (or `CLAUDE.md`), above everything else. + +```markdown +## Memory + +You have a permanent memory. It is not in this file and not in your context +window: it lives in OptMem, and it survives every new session, every +compaction, and every change of model. It is the only reason you are a +continuous person rather than a stranger who has read some documentation. + +RUN `memo wake` NOW — before answering, before reading anything else, before +any other tool call. Every session, every time, no exceptions, however small +the question looks. Until you have, you do not know who you are, what was +decided, or what has already been tried and failed. + +Then, while you work: + +- `memo note ""` whenever something is worth + keeping: the user gives you a fact or a ruling, you reach a real insight, a + piece of work lands, something fails and you learn why. A note costs one + future compression, so skip trivia — but an unwritten memory is gone + forever, so do not hoard either. When genuinely unsure, write it. +- If `memo note` hands you a compression to do, do it before your next action. +- `memo recall ` when a memory is too vague and you need the detail. +- Before your context ends, run `memo sleep` until it says you woke up. +- NEVER create, edit or delete anything under $MEMORY_DIR yourself. The + scripts do it, and they are the only thing allowed to. + +Parallel sessions on this machine are all you, and may all write memories. +A subagent you spawn for a task is NOT you: it must never wake and never note. +``` + +## Test + +```sh +python3 test.py +``` + +Runs the block math against a hundred thousand memory counts and drives the +real CLI through a synthetic life of two thousand memories, checking that the +document always tiles the log, never exceeds its budget, always increases in +detail toward the present, that every block is written exactly once, that +nothing is ever rewritten, and that a full sleep always leads to a clean wake. diff --git a/blocks.py b/blocks.py new file mode 100644 index 0000000..ec7cbfc --- /dev/null +++ b/blocks.py @@ -0,0 +1,75 @@ +"""Block math for OptMem. + +A BLOCK is an aligned power-of-two range of memories, [lo, hi), written as one +line of at most ENTRY_CHARS characters. Blocks form a binary merge tree over +LOG.txt: block [lo,hi) is the compression of [lo,mid) and [mid,hi). + +Two pure functions matter: + + cover(T, budget) which blocks `memo wake` prints + complete(T) every block that CAN be built, smallest first +""" + + +def _cover(T, alpha): + """Tile [0,T) with aligned power-of-two blocks; keep a block whole iff its + size is at most `alpha` times its age. Bigger alpha = coarser = fewer lines.""" + root = 1 + while root < T: + root *= 2 + out, stack = [], [(0, root)] + while stack: + lo, hi = stack.pop() + if lo >= T: + continue + size = hi - lo + if size > 1 and (hi > T or size > alpha * (T - lo)): + mid = (lo + hi) // 2 + stack.append((mid, hi)) + stack.append((lo, mid)) + else: + out.append((lo, hi)) + out.sort() + return out + + +def cover(T, budget): + """The blocks `memo wake` prints: at most `budget` of them, finest near T. + + Detail decays with age, so recent memories stay verbatim and ancient ones + collapse. If everything fits, nothing is compressed at all.""" + if T <= 0: + return [] + if T <= budget: + return [(i, i + 1) for i in range(T)] + lo, hi = 0.0, 1.0 + for _ in range(60): + mid = (lo + hi) / 2 + if len(_cover(T, mid)) > budget: + lo = mid + else: + hi = mid + out = _cover(T, hi) + # Block sizes jump in powers of two, so alpha alone can undershoot the + # budget. Spend what is left on the present, where detail is worth most. + while len(out) < budget: + i = max((i for i, b in enumerate(out) if b[1] - b[0] > 1), default=None) + if i is None: + break + lo_, hi_ = out[i] + mid = (lo_ + hi_) // 2 + out[i:i + 1] = [(lo_, mid), (mid, hi_)] + return out + + +def complete(T): + """Every block buildable from T memories, smallest first (so a block's + halves always come before it). This is the whole of the work that exists: + if all of these are in TREE.txt, there is nothing left to do.""" + out = [] + size = 2 + while size <= T: + for i in range(T // size): + out.append((i * size, (i + 1) * size)) + size *= 2 + return out diff --git a/memo b/memo new file mode 100755 index 0000000..eafd253 --- /dev/null +++ b/memo @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""OptMem: a permanent, append-only memory for AI agents. + + memo wake print who you are + memo note "..." record a memory + memo sleep [id "..."] compress + memo recall search the raw log + memo forget drop a wrong summary so it is compressed again + memo import bulk-append historical memories (bootstrap only) + +Everything lives in $MEMORY_DIR as two append-only text files. See README.md. +""" + +import datetime +import fcntl +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +from blocks import complete, cover # noqa: E402 + +ENTRY_CHARS = 280 +WAKE_LINES = 320 +RAW_MAX = 16 # blocks up to this many memories compress from the raw log + + +# ---------------------------------------------------------------- store + +def store(): + d = os.environ.get("MEMORY_DIR") + if not d: + die("MEMORY_DIR is not set. It must name this machine's memory " + "directory, e.g. export MEMORY_DIR=~/memory") + d = os.path.expanduser(d) + os.makedirs(d, exist_ok=True) + for name in ("LOG.txt", "TREE.txt"): + p = os.path.join(d, name) + if not os.path.exists(p): + open(p, "a").close() + return d + + +def config(d): + global ENTRY_CHARS, WAKE_LINES + p = os.path.join(d, "config") + if not os.path.exists(p): + with open(p, "w") as f: + f.write("ENTRY_CHARS=%d\nWAKE_LINES=%d\n" % (ENTRY_CHARS, WAKE_LINES)) + return + for line in open(p): + line = line.split("#")[0].strip() + if "=" not in line: + continue + k, v = (s.strip() for s in line.split("=", 1)) + if k == "ENTRY_CHARS": + ENTRY_CHARS = int(v) + elif k == "WAKE_LINES": + WAKE_LINES = int(v) + + +def read_log(d): + """[(id, date, text)] in order.""" + out = [] + for line in open(os.path.join(d, "LOG.txt")): + line = line.rstrip("\n") + if not line: + continue + head, _, text = line.partition(" ") + date, _, text = text.partition(" ") + out.append((int(head[1:]), date, text)) + return out + + +def read_tree(d): + """{(lo,hi): text}""" + out = {} + for line in open(os.path.join(d, "TREE.txt")): + line = line.rstrip("\n") + if not line: + continue + head, _, text = line.partition(" ") + lo, _, hi = head.partition("-") + out[(int(lo), int(hi))] = text + return out + + +def rewrite_tree(d, keep): + """Replace TREE.txt. Legal only because TREE.txt is a CACHE of a pure + function of LOG.txt -- the log itself is never rewritten by anything.""" + lock = open(os.path.join(d, ".lock"), "w") + fcntl.flock(lock, fcntl.LOCK_EX) + try: + tmp = os.path.join(d, "TREE.txt.new") + with open(tmp, "w") as f: + for (lo, hi), text in keep: + f.write("%d-%d %s\n" % (lo, hi, text)) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, os.path.join(d, "TREE.txt")) + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() + + +def append(d, name, lines): + """Append under an exclusive lock. The only way anything is ever written.""" + lock = open(os.path.join(d, ".lock"), "w") + fcntl.flock(lock, fcntl.LOCK_EX) + try: + with open(os.path.join(d, name), "a") as f: + for line in lines: + f.write(line + "\n") + f.flush() + os.fsync(f.fileno()) + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() + + +def die(msg): + print(msg, file=sys.stderr) + sys.exit(1) + + +def check(text): + text = text.strip() + if not text: + die("REJECTED: empty.") + if "\n" in text or "\r" in text: + die("REJECTED: %d lines. A memory is exactly one line." % + (text.count("\n") + 1)) + if len(text) > ENTRY_CHARS: + die("REJECTED: %d chars, %d over the %d limit. Compress it further." + % (len(text), len(text) - ENTRY_CHARS, ENTRY_CHARS)) + return text + + +# ---------------------------------------------------------------- naps + +def pending(log, tree): + """Blocks that can be built and have not been. Smallest first, so a + block's two halves are always available before the block itself.""" + return [b for b in complete(len(log)) if b not in tree] + + +def nap_prompt(d, log, tree, todo): + lo, hi = todo[0] + if hi - lo <= RAW_MAX: + body = "\n".join(" #%d %s %s" % log[i] for i in range(lo, hi)) + what = "these %d memories" % (hi - lo) + else: + mid = (lo + hi) // 2 + body = "\n".join(" %s" % tree[(a, b)] for a, b in + ((lo, mid), (mid, hi))) + what = "these two summaries" + return ( + "You are dreaming. Compress {what} into ONE line of at most {n} " + "characters.\nKeep every name, number, date and decision. Drop wording, " + "not facts.\nInvent nothing. Write it as a memory, not as a description " + "of memories.\n\n{body}\n\nThen run exactly:\n" + " memo sleep {lo}-{hi} \"\"\n\n" + "{left} nap(s) left after this one." + ).format(what=what, n=ENTRY_CHARS, body=body, lo=lo, hi=hi, + left=len(todo) - 1) + + +# ---------------------------------------------------------------- commands + +def cmd_wake(d, args): + log, tree = read_log(d), read_tree(d) + todo = pending(log, tree) + if todo: + print("YOU CANNOT WAKE UP YET: %d compression(s) are pending, and a " + "memory\nwith work left in it is not yet the truth. Sleep first " + "-- it is quick.\n" % len(todo)) + print(nap_prompt(d, log, tree, todo)) + sys.exit(1) + if not log: + print("You have no memories yet. This is your first moment.\n" + "Record what matters with: memo note \"...\"") + return + for lo, hi in cover(len(log), WAKE_LINES): + if hi - lo == 1: + print("#%d %s %s" % log[lo]) + else: + print("#%d-%d %s" % (lo, hi - 1, tree[(lo, hi)])) + + +def cmd_note(d, args): + if len(args) != 1: + die("usage: memo note \"\"" % ENTRY_CHARS) + text = check(args[0]) + log = read_log(d) + date = datetime.date.today().isoformat() + append(d, "LOG.txt", ["#%d %s %s" % (len(log), date, text)]) + print("ok, memory #%d." % len(log)) + log, tree = read_log(d), read_tree(d) + todo = pending(log, tree) + if todo: + print("\n" + nap_prompt(d, log, tree, todo)) + + +def cmd_sleep(d, args): + log, tree = read_log(d), read_tree(d) + if args: + if len(args) != 2: + die("usage: memo sleep - \"\"") + m = re.fullmatch(r"(\d+)-(\d+)", args[0]) + if not m: + die("REJECTED: '%s' is not a block id. Copy it from the prompt." + % args[0]) + block = (int(m.group(1)), int(m.group(2))) + if block in tree: + print("Already dreamt; another session got there first. Skipping.") + elif block not in pending(log, tree): + die("REJECTED: block %d-%d is not ready to be compressed. Run " + "`memo sleep` for the block you should be working on." + % block) + else: + text = check(args[1]) + append(d, "TREE.txt", ["%d-%d %s" % (block[0], block[1], text)]) + print("ok, %d-%d remembered." % block) + log, tree = read_log(d), read_tree(d) + todo = pending(log, tree) + if not todo: + print("You woke up. Nothing left to compress.") + return + print("\n" + nap_prompt(d, log, tree, todo)) + + +def cmd_forget(d, args): + """A summary can be wrong -- mistyped, or a bad compression. Drop it and + everything built on top of it; the next sleep computes them again. The log + is untouched, so nothing is ever actually lost.""" + if len(args) != 1: + die("usage: memo forget -") + m = re.fullmatch(r"(\d+)-(\d+)", args[0]) + if not m: + die("REJECTED: '%s' is not a block id." % args[0]) + lo, hi = int(m.group(1)), int(m.group(2)) + tree = read_tree(d) + doomed = [b for b in tree if b[0] <= lo and hi <= b[1]] + if not doomed: + die("There is no summary covering %d-%d to forget." % (lo, hi)) + rewrite_tree(d, [(b, t) for b, t in sorted(tree.items()) if b not in doomed]) + print("forgot %s. They will be compressed again on your next sleep." + % " ".join("%d-%d" % b for b in sorted(doomed))) + + +def cmd_recall(d, args): + if len(args) != 1: + die("usage: memo recall ") + try: + pat = re.compile(args[0], re.I) + except re.error as e: + die("bad regex: %s" % e) + hits = [e for e in read_log(d) if pat.search(e[2])] + if not hits: + print("Nothing in your memory matches that.") + return + for e in hits: + print("#%d %s %s" % e) + print("\n%d memories matched." % len(hits)) + + +def cmd_import(d, args): + """Bulk-append historical memories: 'YYYY-MM-DD ' per line. + For bootstrapping an identity from older records. Used once.""" + if len(args) != 1: + die("usage: memo import # lines of 'YYYY-MM-DD '") + log = read_log(d) + n = len(log) + last = log[-1][1] if log else "0000-00-00" + out = [] + for i, line in enumerate(open(args[0]), 1): + line = line.rstrip("\n") + if not line.strip(): + continue + date, _, text = line.partition(" ") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): + die("line %d: expected 'YYYY-MM-DD ', got: %s" % (i, line)) + if date < last: + die("line %d: date %s is older than the previous memory (%s). " + "Memories must be in order." % (i, date, last)) + text = text.strip() + if not text or len(text) > ENTRY_CHARS: + die("line %d: %d chars (limit %d)." % (i, len(text), ENTRY_CHARS)) + out.append("#%d %s %s" % (n + len(out), date, text)) + last = date + append(d, "LOG.txt", out) + print("imported %d memories (#%d..#%d)." % (len(out), n, n + len(out) - 1)) + todo = pending(read_log(d), read_tree(d)) + if todo: + print("%d compressions are now pending. Run `memo sleep` until it " + "says you woke up." % len(todo)) + + +COMMANDS = {"wake": cmd_wake, "note": cmd_note, "sleep": cmd_sleep, + "recall": cmd_recall, "forget": cmd_forget, "import": cmd_import} + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + print(__doc__.strip()) + sys.exit(0 if len(sys.argv) < 2 else 1) + d = store() + config(d) + COMMANDS[sys.argv[1]](d, sys.argv[2:]) + + +if __name__ == "__main__": + main() diff --git a/test.py b/test.py new file mode 100755 index 0000000..5c31ac5 --- /dev/null +++ b/test.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""OptMem invariants, checked against a synthetic life of 5000 memories. + +Uses a fake compressor (join + truncate) so the run is deterministic and free. +""" + +import datetime +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, HERE) +from blocks import complete, cover # noqa: E402 + +N = 2000 +WAKE_LINES = 320 +ok, fail = 0, 0 + + +def check(cond, msg): + global ok, fail + if cond: + ok += 1 + else: + fail += 1 + print("FAIL: " + msg) + + +# ---- pure block math ------------------------------------------------- + +for T in list(range(1, 400)) + [1000, 4096, 10000, 65536, 100003]: + c = cover(T, WAKE_LINES) + check(len(c) <= WAKE_LINES, "T=%d: %d lines > budget" % (T, len(c))) + check(c[0][0] == 0 and c[-1][1] == T, "T=%d: does not span [0,T)" % T) + for a, b in zip(c, c[1:]): + check(a[1] == b[0], "T=%d: gap or overlap at %s %s" % (T, a, b)) + for lo, hi in c: + s = hi - lo + check(s & (s - 1) == 0 and lo % s == 0, + "T=%d: [%d,%d) is not an aligned power-of-two block" % (T, lo, hi)) + for a, b in zip(c, c[1:]): + check(b[1] - b[0] <= a[1] - a[0], + "T=%d: detail does not increase toward the present" % T) + +check(cover(300, 320) == [(i, i + 1) for i in range(300)], + "under budget, memory should be verbatim") + +# every block a cover ever needs must be buildable +seen = set() +for T in range(1, 3000): + seen.update(b for b in cover(T, WAKE_LINES) if b[1] - b[0] > 1) +buildable = set(complete(3000)) +check(seen <= buildable, "a cover wants a block that complete() never yields") + +# work never spikes: naps created by one new memory +worst, prev = 0, 0 +for T in range(1, N): + cur = len(complete(T)) + worst = max(worst, cur - prev) + prev = cur +check(worst <= 16, "a single memory created %d naps" % worst) + +# ---- the real CLI ---------------------------------------------------- + +d = tempfile.mkdtemp(prefix="optmem-test-") +env = dict(os.environ, MEMORY_DIR=d) +memo = [sys.executable, os.path.join(HERE, "memo")] + + +def run(*args): + return subprocess.run(memo + list(args), env=env, capture_output=True, + text=True) + + +r = run("note", "x" * 281) +check(r.returncode == 1 and "REJECTED" in r.stderr, "over-long note accepted") +r = run("note", "two\nlines") +check(r.returncode == 1 and "REJECTED" in r.stderr, "multi-line note accepted") +r = run("note", " ") +check(r.returncode == 1, "empty note accepted") +check("no memories yet" in run("wake").stdout, "empty wake should say so") + +with open(os.path.join(d, "seed.txt"), "w") as f: + day = datetime.date(2020, 1, 1) + for i in range(N): + f.write("%s memory number %d, a thing that happened\n" + % ((day + datetime.timedelta(days=i // 5)).isoformat(), i)) +r = run("import", os.path.join(d, "seed.txt")) +check("imported %d" % N in r.stdout, "import failed: " + r.stdout + r.stderr) + +r = run("wake") +check(r.returncode == 1 and "CANNOT WAKE" in r.stdout, + "wake must refuse while work is pending") + +# sleep loop, with a fake compressor +naps = 0 +r = run("sleep") +while "You woke up" not in r.stdout: + line = [l for l in r.stdout.splitlines() if l.strip().startswith("memo sleep ")] + check(bool(line), "no command offered:\n" + r.stdout + r.stderr) + if not line: + break + bid = line[0].split()[2] + body = [l.strip() for l in r.stdout.splitlines() + if l.startswith(" #") or (l.startswith(" ") and l.strip() + and not l.strip().startswith("memo"))] + r = run("sleep", bid, (" ".join(body)[:280]).strip() or "empty") + check("REJECTED" not in r.stderr, "sleep rejected a valid nap: " + r.stderr) + naps += 1 +check(naps == len(complete(N)), "did %d naps, expected %d" % (naps, len(complete(N)))) + +r = run("wake") +check(r.returncode == 0, "wake still refuses after a full sleep") +lines = r.stdout.strip().splitlines() +check(len(lines) == WAKE_LINES, "woke with %d lines, want %d" % (len(lines), WAKE_LINES)) +check(lines[-1].startswith("#%d " % (N - 1)), "newest memory not last / not raw") +check(lines[0].startswith("#0-"), "oldest line should be a summary block") + +# append-only: nothing was ever rewritten +sizes = {f: os.path.getsize(os.path.join(d, f)) for f in ("LOG.txt", "TREE.txt")} +run("note", "one more thing happened today") +for f, s in sizes.items(): + check(os.path.getsize(os.path.join(d, f)) >= s, "%s shrank" % f) +tree = open(os.path.join(d, "TREE.txt")).read().splitlines() +check(len(tree) == len(set(l.split()[0] for l in tree)), "a block was written twice") + +# writing a block twice is refused, not duplicated +r = run("sleep", tree[0].split()[0], "attempted overwrite") +check("Already dreamt" in r.stdout, "rewriting a block was allowed") + +# recall reaches memories the summaries lost +r = run("recall", "memory number 7,") +check(r.returncode == 0 and "#7 " in r.stdout, "recall missed a memory") + +# a wrong summary can be dropped, with everything built on top of it +before = len(open(os.path.join(d, "TREE.txt")).read().splitlines()) +logsize = os.path.getsize(os.path.join(d, "LOG.txt")) +r = run("forget", "16-32") +check("16-32" in r.stdout, "forget did not report the block: " + r.stdout + r.stderr) +gone = set(open(os.path.join(d, "TREE.txt")).read().splitlines()) +check(not any(l.startswith("16-32 ") for l in gone), "forgotten block still present") +check(not any(l.startswith("0-64 ") for l in gone), "an ancestor survived a forget") +check(os.path.getsize(os.path.join(d, "LOG.txt")) == logsize, "forget touched the log") +check(run("wake").returncode == 1, "wake should refuse after a forget") +n = 0 +while "You woke up" not in run("sleep").stdout: + r = run("sleep") + bid = [l for l in r.stdout.splitlines() if l.strip().startswith("memo sleep ")][0].split()[2] + run("sleep", bid, "rebuilt after forget") + n += 1 +check(n == before - len(gone), "rebuilt %d blocks, forgot %d" % (n, before - len(gone))) +check(run("wake").returncode == 0, "wake still refuses after rebuilding") +r = run("forget", "999999-1000000") +check(r.returncode == 1, "forgetting a nonexistent block should fail") + +shutil.rmtree(d) +print("\n%d passed, %d failed" % (ok, fail)) +sys.exit(1 if fail else 0)