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').
This commit is contained in:
victortaelin 2026-07-25 17:38:56 -03:00
parent af17f94c2f
commit 1f1ec1511e
3 changed files with 132 additions and 104 deletions

112
README.md
View file

@ -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 <regex> # search the raw log for detail a summary lost.
memo forget <lo>-<hi> # 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 "<your line>"
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 "<your line>"
```
## 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 "<one line, at most 280 chars>"` 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 <regex>` 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 "<one line, max 280 chars>"` 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 <regex>` 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

80
memo
View file

@ -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 <regex> search the raw log
memo forget <id> drop a wrong summary so it is compressed again
memo import <file> 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 <regex> search every memory ever recorded.
memo forget <lo>-<hi> drop a bad summary; sleep rebuilds it.
memo import <file> 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 \"<your line>\"\n"
"%d left after this." % (ENTRY_CHARS, body, lo, hi, left))
"%s after this one.\n"
"Run: memo sleep %d-%d \"<your line>\""
% (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 \"<one line>\"")
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 \"<one line, at most %d chars>\"" % 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,

44
test.py
View file

@ -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)