OptMem/memo
victortaelin 672cbbdb9c terse prompts; fix id race, torn writes, wake races, unbounded recall
Prompts were verbose and repeated the same story in every tool result, which
floods context and eats the output budget. They are now instructions only,
stated once. The 4-line wake footer is 'next: memo wake 2 246'.

Real harness caps, verified from source (Claude Code 30,000 chars, pi 50 KB /
2000 lines, Codex 10,000 tokens): the old PART_CHARS=8000 was sized against a
wrong 10 KiB figure and cost 8 calls per wake. 20000 costs 4.

Bugs found by audit:
- note assigned its id outside the lock: parallel sessions could collide
- a torn record from a crash misaligned every later record, permanently
- a note landing between two wake parts could shift a boundary and drop a line
  (wake parts now render as of an explicit T)
- recall printed unboundedly and was silently truncated by the harness
2026-07-25 15:43:08 -03:00

475 lines
15 KiB
Python
Executable file

#!/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)
Everything lives in $MEMORY_DIR. 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 cover # noqa: E402
ENTRY_CHARS = 280
WAKE_LINES = 256
RAW_MAX = 16 # blocks up to this many memories compress from the raw log
# Every harness truncates a command that prints too much, and each drops a
# different piece: Claude Code cuts the middle at 30,000 chars, pi cuts the
# head at 50 KB, Codex budgets 10,000 tokens. So the memory is handed over in
# parts that fit all of them. These are transport limits, not memory limits.
PART_CHARS = 20000
PART_LINES = 500
# Records are FIXED WIDTH, so a memory or a block is found by seeking to its
# offset -- no scanning, no index file to keep in sync. Position IS identity:
# memory i lives at i*LOG_REC of LOG.txt, and block [k*s,(k+1)*s) lives at
# k*TREE_REC of TREE/<s>. Padding costs ~2x on disk and buys O(1) everywhere.
LOG_REC = 320
TREE_REC = 288
# ---------------------------------------------------------------- store
def store():
d = os.environ.get("MEMORY_DIR")
if not d:
die("MEMORY_DIR is not set. Example: export MEMORY_DIR=~/memory")
d = os.path.expanduser(d)
os.makedirs(os.path.join(d, "TREE"), exist_ok=True)
p = os.path.join(d, "LOG.txt")
if not os.path.exists(p):
open(p, "a").close()
return d
def config(d):
global ENTRY_CHARS, WAKE_LINES, PART_CHARS, PART_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\nPART_CHARS=%d\nPART_LINES=%d\n"
% (ENTRY_CHARS, WAKE_LINES, PART_CHARS, PART_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)
elif k == "PART_CHARS":
PART_CHARS = int(v)
elif k == "PART_LINES":
PART_LINES = int(v)
if ENTRY_CHARS > min(TREE_REC - 8, LOG_REC - 40):
die("config: ENTRY_CHARS=%d does not fit the %d/%d-byte records."
% (ENTRY_CHARS, LOG_REC, TREE_REC))
def log_path(d):
return os.path.join(d, "LOG.txt")
def tree_path(d, size):
return os.path.join(d, "TREE", str(size))
def count(path, rec):
try:
return os.path.getsize(path) // rec
except OSError:
return 0
def log_len(d):
return count(log_path(d), LOG_REC)
def repair(path, rec):
"""Drop a partial trailing record left by a crash. It was never
acknowledged. Without this the next append lands at a wrong offset and
every later record is misaligned. Callers hold the lock."""
try:
n = os.path.getsize(path)
except OSError:
return
if n % rec:
with open(path, "r+b") as f:
f.truncate(n - n % rec)
def parse(line):
head, _, rest = line.partition(" ")
date, _, text = rest.partition(" ")
return int(head[1:]), date, text
def log_get(d, i):
"""(id, date, text) of memory i, in one seek."""
with open(log_path(d), "rb") as f:
f.seek(i * LOG_REC)
return parse(f.read(LOG_REC).decode().rstrip())
def log_slice(d, lo, hi):
"""Memories [lo,hi) in one read. Records are sliced as BYTES and decoded
one by one -- slicing decoded text would shift every boundary after the
first multi-byte character."""
with open(log_path(d), "rb") as f:
f.seek(lo * LOG_REC)
buf = f.read((hi - lo) * LOG_REC)
return [parse(buf[i * LOG_REC:(i + 1) * LOG_REC].decode().rstrip())
for i in range(hi - lo)]
def tree_get(d, lo, hi):
"""The summary of block [lo,hi), in one seek. None if not built yet."""
size = hi - lo
try:
with open(tree_path(d, size), "rb") as f:
f.seek((lo // size) * TREE_REC)
rec = f.read(TREE_REC)
except OSError:
return None
return rec.decode().rstrip() or None
def pad(text, rec):
b = text.encode()
if len(b) > rec - 1:
die("Too long: %d bytes. The record holds %d." % (len(b), rec - 1))
return b + b" " * (rec - 1 - len(b)) + b"\n"
def locked(d):
lock = open(os.path.join(d, ".lock"), "w")
fcntl.flock(lock, fcntl.LOCK_EX)
return lock
def log_append(d, items):
"""Append memories, items = [(date, text)]. The only way LOG.txt ever
changes. Ids are assigned INSIDE the lock: two sessions noting at the same
moment must not be handed the same id. Returns the first id used."""
lock = locked(d)
try:
repair(log_path(d), LOG_REC)
base = log_len(d)
with open(log_path(d), "ab") as f:
for k, (date, text) in enumerate(items):
f.write(pad("#%d %s %s" % (base + k, date, text), LOG_REC))
f.flush()
os.fsync(f.fileno())
return base
finally:
lock.close()
def tree_put(d, lo, hi, text):
"""Write block [lo,hi). Blocks are built in order, so this only ever
appends one record to one level file."""
size = hi - lo
lock = locked(d)
try:
p = tree_path(d, size)
repair(p, TREE_REC)
if count(p, TREE_REC) != lo // size:
return False
with open(p, "ab") as f:
f.write(pad(text, TREE_REC))
f.flush()
os.fsync(f.fileno())
return True
finally:
lock.close()
def tree_drop(d, lo, hi):
"""Forget block [lo,hi) and every block built from it, by truncating each
level back to that point. Later blocks at those levels go too and are
rebuilt; the log is never touched, so nothing is lost."""
gone, size = [], hi - lo
lock = locked(d)
try:
while size <= log_len(d):
p, k = tree_path(d, size), lo // size
n = count(p, TREE_REC)
if n > k:
gone += [(i * size, (i + 1) * size) for i in range(k, n)]
with open(p, "r+b") as f:
f.truncate(k * TREE_REC)
size *= 2
return gone
finally:
lock.close()
def die(msg):
print(msg, file=sys.stderr)
sys.exit(1)
def check(text):
text = text.strip()
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))
n = len(text.encode())
if n > ENTRY_CHARS:
die("Too long: %d bytes, limit %d. Accented characters cost 2 bytes. "
"Compress it further." % (n, ENTRY_CHARS))
return text
# ---------------------------------------------------------------- naps
def pending(d, T, limit=None):
"""Blocks that can be built and have not been, smallest first. Each level
file holds a dense prefix, so its length says exactly how far that level
got: this costs one stat per level, never a scan."""
todo, size = [], 2
while size <= T:
have = count(tree_path(d, size), TREE_REC)
for k in range(have, T // size):
todo.append((k * size, (k + 1) * size))
if limit and len(todo) >= limit:
return todo
size *= 2
return todo
def pending_count(d, T):
n, size = 0, 2
while size <= T:
n += T // size - count(tree_path(d, size), TREE_REC)
size *= 2
return n
def nap_prompt(d, lo, hi, left):
if hi - lo <= RAW_MAX:
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"
"%s\n\n"
"memo sleep %d-%d \"<your line>\"\n"
"%d left after this." % (ENTRY_CHARS, body, lo, hi, left))
def next_nap(d, T):
todo = pending(d, T, limit=1)
if not todo:
return None
lo, hi = todo[0]
return nap_prompt(d, lo, hi, pending_count(d, T) - 1)
# ---------------------------------------------------------------- commands
def paginate(lines):
"""Split the document into parts that survive any harness's output cap."""
parts, cur, size = [], [], 0
for line in lines:
n = len(line.encode()) + 1
if cur and (len(cur) >= PART_LINES or size + n > PART_CHARS):
parts.append(cur)
cur, size = [], 0
cur.append(line)
size += n
if cur:
parts.append(cur)
return parts
def cmd_wake(d, args):
now = log_len(d)
k, T = 1, now
if args:
if len(args) > 2 or not all(a.isdigit() for a in args):
die("usage: memo wake [part [T]]")
k = int(args[0])
if len(args) == 2:
T = int(args[1])
if T > now:
die("T=%d, but the memory holds %d entries." % (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(next_nap(d, T))
sys.exit(1)
if not T:
print("No memories yet. Record one with: memo note \"...\"")
return
lines = []
for lo, hi in cover(T, WAKE_LINES):
if hi - lo == 1:
lines.append("#%d %s %s" % log_get(d, lo))
else:
s = tree_get(d, lo, hi)
if s is None:
die("Summary %d-%d is missing. Run: memo sleep" % (lo, hi - 1))
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)))
if len(parts) > 1:
print("memory, part %d of %d, oldest first" % (k, len(parts)))
print("\n".join(parts[k - 1]))
if k < len(parts):
print("next: memo wake %d %d" % (k + 1, T))
elif len(parts) > 1:
print("awake.")
def cmd_note(d, args):
if len(args) != 1:
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)
nap = next_nap(d, i + 1)
if nap:
print("\n" + nap)
def cmd_sleep(d, args):
T = log_len(d)
if args:
if len(args) != 2:
die("usage: memo sleep <lo>-<hi> \"<one line>\"")
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))
todo = pending(d, T, limit=1)
if not todo:
die("Nothing pending.")
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]))
if not tree_put(d, lo, hi, check(args[1])):
print("Another session already wrote %d-%d." % (lo, hi))
else:
print("%d-%d saved." % (lo, hi))
nap = next_nap(d, T)
if not nap:
print("Nothing left to compress. You are awake.")
return
print("\n" + nap)
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 <lo>-<hi>")
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))
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))
gone = tree_drop(d, lo, hi)
if not gone:
die("No summary at %d-%d." % (lo, hi))
print("Forgot %d summaries, from %d-%d up. Run: memo sleep"
% (len(gone), gone[0][0], gone[0][1]))
def cmd_recall(d, args):
if len(args) != 1:
die("usage: memo recall <regex>")
try:
pat = re.compile(args[0], re.I)
except re.error as e:
die("bad regex: %s" % e)
hits = [e for e in log_slice(d, 0, log_len(d)) if pat.search(e[2])]
if not hits:
print("No match.")
return
# Newest first is what a search is usually for, and the output has to fit
# the same cap `wake` respects.
out, size = [], 0
for e in reversed(hits):
line = "#%d %s %s" % e
size += len(line.encode()) + 1
if size > PART_CHARS:
break
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)))
else:
print("%d matches." % len(hits))
def cmd_import(d, args):
"""Bulk-append historical memories: 'YYYY-MM-DD <text>' per line.
For bootstrapping an identity from older records. Used once."""
if len(args) != 1:
die("usage: memo import <file> # lines of 'YYYY-MM-DD <text>'")
last = log_get(d, log_len(d) - 1)[1] if log_len(d) 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 <text>', got: %s" % (i, line))
if date < last:
die("line %d: date %s precedes the previous memory (%s)."
% (i, date, last))
text = text.strip()
if not text or len(text.encode()) > ENTRY_CHARS:
die("line %d: %d bytes, limit %d." % (i, len(text.encode()), ENTRY_CHARS))
out.append((date, text))
last = date
base = log_append(d, out)
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)
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()