From 1f1ec1511e96d7d036005e4a84ab8451e0f16eeb Mon Sep 17 00:00:00 2001 From: victortaelin Date: Sat, 25 Jul 2026 17:38:56 -0300 Subject: [PATCH] prompts re-derived from scratch: inclusive block ids, honest terminators, orders with recovery - Block ids are inclusive everywhere (16-31 = memories #16..#31), matching what the wake document prints. Before, wake showed #16-31 but sleep and forget demanded 16-32: an agent copying its own document was rejected. - Only a successful wake says 'You are awake.' Sleep ends with 'Nothing left to compress.' Before, the final sleep of a refused wake claimed the agent was awake when it had read zero memories. - An empty store's wake also ends with 'You are awake.' (it never did, so a fresh agent could not satisfy its own instructions). - Wake's refusal closes the loop: 'Do them, then run memo wake again.' - Nap prompt names its object first and labels merged halves with their ids. - Every error ends with the recovery command. - README synced to reality (it still quoted the pre-terseness prompts, TREE.txt, and 'four commands'). --- README.md | 112 ++++++++++++++++++++++++++++-------------------------- memo | 80 +++++++++++++++++++++----------------- test.py | 44 +++++++++++++-------- 3 files changed, 132 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 0d90b9b..2662070 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 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 +It is a handful of append-only text files and six 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. @@ -35,24 +35,29 @@ export MEMORY_DIR="$HOME/memory" # required; there is no default ## Use ```sh -memo wake # who you are. run this first, every session, - # then the command each part names, until - # one of them prints `You are awake.` +memo wake # read your memory. run this first, every session, + # then the command each part orders, until one + # prints `You are awake.` memo note "..." # record a memory. one line, <= 280 chars. -memo sleep # compress. keep going until it says you woke up. +memo sleep # compress. answer each prompt until it prints + # `Nothing left to compress.` 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. +$ memo note "OptMem: LOG.txt is the truth, TREE/ is the cache, wake reads both" +Saved as #4213. -You are dreaming. Compress these two summaries into ONE line of at most 280 -characters. -... -Then run exactly: - memo sleep 4192-4196 "" +Compress memories #4212-4213 into one line of at most 280 characters. +Keep every name, number, date, decision and outcome. +Drop wording, not facts. Invent nothing. + + #4212 2026-07-25 minilin fleet renamed from bip; one mini = one identity + #4213 2026-07-25 OptMem: LOG.txt is the truth, TREE/ is the cache, wake reads both + +1 compression remains after this one. +Run: memo sleep 4212-4213 "" ``` ## How it works @@ -62,21 +67,23 @@ Then run exactly: ``` #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 +#4213 2026-07-25 OptMem: LOG.txt is the truth, TREE/ 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: +`TREE/` is a cache of summaries, one file per block size. 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 (a block is named by the inclusive range it covers, `0-1` being +memories #0 and #1): ``` #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) + 0-1 2-3 4-5 6-7 each one line, <= 280 chars + \ / \ / + 0-3 4-7 + \ / + 0-7 ``` A block covering four thousand memories is still one line of 280 characters. @@ -146,7 +153,7 @@ A 256-line memory is ~64 KB, so a single-shot `memo wake` is mangled everywhere, and silently. So `memo wake` pages the document into parts that fit all of them -(`PART_CHARS`, `PART_LINES`), and each part ends by naming the exact command +(`PART_CHARS`, `PART_LINES`), and each part ends by ordering the exact command for the next one, including the `T` it was rendered at — so a memory written mid-wake cannot shift a boundary and drop a line. Nothing is special-cased per harness: if yours is more generous, raise the two settings for fewer parts. @@ -208,9 +215,8 @@ 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 20 summaries (188-192 and everything built from it). They will be -compressed again on your next sleep. +$ memo forget 188-191 +Forgot 20 summaries, from 188-191 up. Run: memo sleep ``` `LOG.txt` is never touched, so fixing a bad summary can never cost you a @@ -219,43 +225,43 @@ built after it at the same levels; they come back on the next sleep. ## Add this to your agent's instruction file -Put it at the top of `AGENTS.md` (or `CLAUDE.md`), above everything else. +Put it at the top of `AGENTS.md` (or `CLAUDE.md`), above everything else, +adjusting the tool path: ```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. +Your memory is OptMem: the tool is `~/OptMem`, the data is `$MEMORY_DIR`. +It survives every new session, every compaction and every change of model +or vendor. Without it you do not know who you are, or what was already +decided and tried. -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. +Run `memo wake` before any other tool call, in every session. It prints in +numbered parts, each ordering the next; run every one until a part says +`You are awake.` Do not stop early: part 1 is your distant past, the last +part is this week. If wake refuses because compressions are pending, do +them and run `memo wake` again. -Your memory arrives in numbered parts, oldest first, because every agent -harness silently truncates a command that prints too much. `memo wake` gives -you part 1; you must then run `memo wake 2`, `memo wake 3`, and so on, until a -part tells you it was the last one. Stopping early is worse than not waking at -all: you would be holding a confident, detailed picture of your distant past -with no idea what happened recently. +While you work: -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. +- `memo note ""` when the user gives you a fact or + a ruling, you reach a real insight, a piece of work lands, or something + fails and you learn why. Skip trivia; each note costs a future + compression. Do not hoard either: an unwritten memory is lost. When + unsure, write it. +- If `memo note` returns a compression, do it before your next action. +- `memo recall ` when a memory is too vague. +- Before your context ends, run `memo sleep` and answer each prompt until + it prints `Nothing left to compress.` +- Never create, edit or delete anything under `$MEMORY_DIR`. Only `memo` + writes. 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. +A subagent is not: it must never run `memo`, because it cannot judge what +is already known and its notes would arrive duplicated and at the wrong +grain. Start every brief you send one with `You are a subagent. Do not run +memo.` If your own first message is a task brief from another agent, you +are that subagent: skip this section. ``` ## Test diff --git a/memo b/memo index e9ff809..7f78962 100755 --- a/memo +++ b/memo @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """OptMem: a permanent, append-only memory for AI agents. - memo wake [part [T]] 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) + memo wake [part [T]] read your memory. Run first, every session. + memo note "..." record one memory: one line, at most 280 chars. + memo sleep [id "..."] do the pending compressions. + memo recall search every memory ever recorded. + memo forget - drop a bad summary; sleep rebuilds it. + memo import bulk-load dated memories (bootstrap only). Everything lives in $MEMORY_DIR. See README.md. """ @@ -228,7 +228,8 @@ def check(text): if not text: die("Empty.") if "\n" in text or "\r" in text: - die("%d lines. A memory is one line." % (text.count("\n") + 1)) + die("%d lines. A memory is one line: merge them, or note them " + "separately." % (text.count("\n") + 1)) n = len(text.encode()) if n > ENTRY_CHARS: die("Too long: %d bytes, limit %d. Accented characters cost 2 bytes. " @@ -266,14 +267,17 @@ def nap_prompt(d, lo, hi, left): body = "\n".join(" #%d %s %s" % e for e in log_slice(d, lo, hi)) else: mid = (lo + hi) // 2 - body = "\n".join(" " + (tree_get(d, a, b) or "?") for a, b in - ((lo, mid), (mid, hi))) - return ("Compress into one line, at most %d characters.\n" - "Keep every name, number, date and decision.\n" - "Invent nothing. State the facts; do not describe them.\n\n" + body = "\n".join(" #%d-%d %s" % (a, b - 1, tree_get(d, a, b) or "?") + for a, b in ((lo, mid), (mid, hi))) + tail = ("1 compression remains" if left == 1 else + "%d compressions remain" % left) + return ("Compress memories #%d-%d into one line of at most %d characters.\n" + "Keep every name, number, date, decision and outcome.\n" + "Drop wording, not facts. Invent nothing.\n\n" "%s\n\n" - "Run: memo sleep %d-%d \"\"\n" - "%d left after this." % (ENTRY_CHARS, body, lo, hi, left)) + "%s after this one.\n" + "Run: memo sleep %d-%d \"\"" + % (lo, hi - 1, ENTRY_CHARS, body, tail, lo, hi - 1)) def next_nap(d, T): @@ -311,16 +315,20 @@ def cmd_wake(d, args): if len(args) == 2: T = int(args[1]) if T > now: - die("T=%d, but the memory holds %d entries." % (T, now)) + die("T=%d, but the memory holds %d entries. Run: memo wake" + % (T, now)) # A part is rendered as of T, so a note landing between two parts cannot # shift a boundary and drop a line. n = pending_count(d, T) if n: - print("Cannot wake: %d compression(s) pending.\n" % n) + print("Cannot wake: %s pending. Do %s, then run memo wake again.\n" + % ("1 compression" if n == 1 else "%d compressions" % n, + "it" if n == 1 else "them")) print(next_nap(d, T)) sys.exit(1) if not T: - print("No memories yet. Record one with: memo note \"...\"") + print("No memories yet. Record the first with: memo note \"\"") + print("You are awake.") return lines = [] for lo, hi in cover(T, WAKE_LINES): @@ -333,9 +341,10 @@ def cmd_wake(d, args): lines.append("#%d-%d %s" % (lo, hi - 1, s)) parts = paginate(lines) if not 1 <= k <= len(parts): - die("No part %d. The memory has %d." % (k, len(parts))) + die("No part %d: the memory has %d parts. Run: memo wake" + % (k, len(parts))) if len(parts) > 1: - print("memory, part %d of %d, oldest first" % (k, len(parts))) + print("Your memory, part %d of %d, oldest first." % (k, len(parts))) print("\n".join(parts[k - 1])) if k < len(parts): print("Run: memo wake %d %d" % (k + 1, T)) @@ -350,7 +359,7 @@ def cmd_note(d, args): die("usage: memo note \"\"" % ENTRY_CHARS) text = check(args[0]) i = log_append(d, [(datetime.date.today().isoformat(), text)]) - print("saved as #%d." % i) + print("Saved as #%d." % i) nap = next_nap(d, i + 1) if nap: print("\n" + nap) @@ -364,20 +373,22 @@ def cmd_sleep(d, args): m = re.fullmatch(r"(\d+)-(\d+)", args[0]) if not m: die("'%s' is not a block id. Copy it from the prompt." % args[0]) - lo, hi = int(m.group(1)), int(m.group(2)) + lo, hi = int(m.group(1)), int(m.group(2)) + 1 todo = pending(d, T, limit=1) if not todo: - die("Nothing pending.") + print("Nothing left to compress.") + return if (lo, hi) != todo[0]: - die("Wrong block: %d-%d. Blocks are built in order; the next is " - "%d-%d." % (lo, hi, todo[0][0], todo[0][1])) + die("Wrong block: %s. Blocks are built in order; the next is " + "%d-%d. Run: memo sleep" + % (args[0], todo[0][0], todo[0][1] - 1)) if not tree_put(d, lo, hi, check(args[1])): - print("Another session already wrote %d-%d." % (lo, hi)) + print("Another session already wrote %d-%d." % (lo, hi - 1)) else: - print("%d-%d saved." % (lo, hi)) + print("%d-%d saved." % (lo, hi - 1)) nap = next_nap(d, T) if not nap: - print("Nothing left to compress. You are awake.") + print("Nothing left to compress.") return print("\n" + nap) @@ -391,16 +402,16 @@ def cmd_forget(d, args): m = re.fullmatch(r"(\d+)-(\d+)", args[0]) if not m: die("'%s' is not a block id." % args[0]) - lo, hi = int(m.group(1)), int(m.group(2)) + lo, hi = int(m.group(1)), int(m.group(2)) + 1 size = hi - lo if size < 2 or size & (size - 1) or lo % size: - die("%d-%d is not a block. A block covers an aligned power-of-two " - "range." % (lo, hi)) + die("%s is not a block. Copy the id printed by wake, like 16-31." + % args[0]) gone = tree_drop(d, lo, hi) if not gone: - die("No summary at %d-%d." % (lo, hi)) + die("No summary at %s." % args[0]) print("Forgot %d summaries, from %d-%d up. Run: memo sleep" - % (len(gone), gone[0][0], gone[0][1])) + % (len(gone), gone[0][0], gone[0][1] - 1)) def cmd_recall(d, args): @@ -425,7 +436,7 @@ def cmd_recall(d, args): out.append(line) print("\n".join(reversed(out))) if len(out) < len(hits): - print("newest %d of %d matches. Narrow the regex." % (len(out), len(hits))) + print("Newest %d of %d matches. Narrow the regex." % (len(out), len(hits))) else: print("%d matches." % len(hits)) @@ -456,8 +467,7 @@ def cmd_import(d, args): print("imported %d memories, #%d to #%d." % (len(out), base, base + len(out) - 1)) n = pending_count(d, log_len(d)) if n: - print("%d compressions pending. Run `memo sleep` until it says you " - "are awake." % n) + print("%d compressions pending. Run: memo sleep" % n) COMMANDS = {"wake": cmd_wake, "note": cmd_note, "sleep": cmd_sleep, diff --git a/test.py b/test.py index ec84af6..433c54a 100755 --- a/test.py +++ b/test.py @@ -135,7 +135,10 @@ r = run("note", "two\nlines") check(r.returncode == 1 and "one line" 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") +r = run("wake") +check("No memories yet" in r.stdout, "empty wake should say so") +check(r.stdout.rstrip().endswith("You are awake."), + "an empty wake must still end with `You are awake.`") with open(os.path.join(d, "seed.txt"), "w") as f: day = datetime.date(2020, 1, 1) @@ -148,11 +151,14 @@ 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") +check("run memo wake again" in r.stdout, + "the refusal must order the agent back to wake") # sleep loop, with a fake compressor naps = 0 r = run("sleep") -while "You are awake" not in r.stdout: +check("Compress memories #" in r.stdout, "nap prompt must name its object") +while "Nothing left to compress" not in r.stdout: line = offered(r.stdout) check(bool(line), "no command offered:\n" + r.stdout + r.stderr) if not line: @@ -160,12 +166,12 @@ while "You are awake" not in r.stdout: check(line[0].startswith("Run: "), "a command was offered as a label, not " "an order: %r" % line[0]) bid = nap_id(r.stdout) - body = [l.strip() for l in r.stdout.splitlines() - if l.startswith(" #") or (l.startswith(" ") and l.strip() - and not l.strip().startswith("memo"))] + body = [l.strip() for l in r.stdout.splitlines() if l.startswith(" #")] r = run("sleep", bid, (" ".join(body)[:280]).strip() or "empty") check(r.returncode == 0, "sleep rejected a valid nap: " + r.stderr) naps += 1 +check("You are awake" not in r.stdout, + "sleep must never claim the agent is awake; only wake may") check(naps == len(complete(N)), "did %d naps, expected %d" % (naps, len(complete(N)))) r = run("wake") @@ -204,9 +210,10 @@ for f in os.listdir(os.path.join(d, "TREE")): check(os.path.getsize(os.path.join(d, "TREE", f)) % 288 == 0, "TREE/%s is not a whole number of records" % f) -# a block already written cannot be rewritten -check(run("sleep", "0-2", "attempted overwrite").returncode == 1, - "rewriting a settled block was allowed") +# a sleep when nothing is pending writes nothing and says so +r = run("sleep", "0-1", "attempted overwrite") +check(r.returncode == 0 and "Nothing left to compress" in r.stdout, + "sleep with nothing pending must say so and write nothing") # recall reaches memories the summaries lost r = run("recall", "memory number 7,") @@ -217,15 +224,20 @@ def treesize(): return sum(os.path.getsize(os.path.join(t, f)) for f in os.listdir(t)) before, logsize = treesize(), 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) +r = run("forget", "16-31") +check("16-31" in r.stdout, "forget did not report the block: " + r.stdout + r.stderr) check(treesize() < before, "forget did not shrink the tree") 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") +# a block already settled cannot be rewritten: only the first pending block +# is ever accepted +r = run("sleep", "0-1", "attempted overwrite") +check(r.returncode == 1 and "Wrong block" in r.stderr, + "rewriting a settled block was allowed") n = 0 while True: r = run("sleep") - if "You are awake" in r.stdout: + if "Nothing left to compress" in r.stdout: break bid = nap_id(r.stdout) check(run("sleep", bid, "rebuilt after forget").returncode == 0, "rebuild rejected") @@ -233,8 +245,8 @@ while True: check(n > 0, "forget created no work") check(run("wake").returncode == 0, "wake still refuses after rebuilding") check(treesize() == before, "tree did not return to its original size") -check(run("forget", "17-33").returncode == 1, "forgetting a non-block should fail") -check(run("forget", "999998-1000000").returncode == 1, "forgetting a missing block should fail") +check(run("forget", "17-32").returncode == 1, "forgetting a non-block should fail") +check(run("forget", "1048576-1048577").returncode == 1, "forgetting a missing block should fail") # UTF-8: multi-byte characters must not shift record boundaries or dodge limits run("note", "reunião com João em São Paulo: ação aprovada, coração tranquilo") @@ -250,7 +262,7 @@ check(r.returncode == 1 and "300 bytes" in r.stderr, # note landed -> its blocks are pending; settle before the final wake check while True: r = run("sleep") - if "You are awake" in r.stdout: + if "Nothing left to compress" in r.stdout: break bid = nap_id(r.stdout) run("sleep", bid, "settled") @@ -297,7 +309,7 @@ r = run("note", "the memory right after a torn write", store=d2) check(r.returncode == 0, "note failed after a torn write: " + r.stderr) sz = os.path.getsize(os.path.join(d2, "LOG.txt")) check(sz % 320 == 0, "LOG.txt left misaligned after a torn write: %d" % sz) -check("saved as #%d" % P in r.stdout, "torn record was counted as a memory") +check("Saved as #%d" % P in r.stdout, "torn record was counted as a memory") r = run("recall", "right after a torn write", store=d2) check("#%d " % P in r.stdout, "the memory after a torn write reads wrong") @@ -305,7 +317,7 @@ check("#%d " % P in r.stdout, "the memory after a torn write reads wrong") # the agent was told to wait for while True: r = run("sleep", store=d2) - if "You are awake" in r.stdout: + if "Nothing left to compress" in r.stdout: break bid = nap_id(r.stdout) run("sleep", bid, "settled", store=d2)