OptMem/memo

523 lines
17 KiB
Text
Raw Normal View History

#!/usr/bin/env python3
"""OptMem: a permanent, append-only memory for AI agents.
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.
"""
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)
# The directory is never created here. A typo in MEMORY_DIR would then
# open an empty store, and the agent would wake with no past and start
# writing a second identity. Making the directory IS creating the
# identity, and that is a deliberate act: mkdir.
if not os.path.isdir(d):
die("MEMORY_DIR=%s does not exist.\nIf this is a new identity, run: "
"mkdir -p %s" % (d, 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):
"""Optional overrides in $MEMORY_DIR/config. The file is never written:
a store that kept its own copy of the defaults would freeze them, and
updating the tool would stop changing how it behaves."""
global ENTRY_CHARS, WAKE_LINES, PART_CHARS, PART_LINES
p = os.path.join(d, "config")
if not os.path.exists(p):
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 plural(n, word):
if n == 1:
return "1 " + word
if word.endswith("y"):
word = word[:-1] + "ie"
elif word.endswith(("s", "h", "x")):
word += "e"
return "%d %ss" % (n, word)
def check(text):
text = text.strip()
if not text:
die("Empty. A memory is one line of text.")
if "\n" in text or "\r" in text:
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. "
"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):
"""How many blocks pending() would list, without listing them. A level can
hold MORE blocks than T needs -- T is a snapshot, and memories keep
arriving while an agent reads -- so each level is clamped at zero."""
n, size = 0, 2
while size <= T:
n += max(0, 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, halves = (lo + hi) // 2, []
for a, b in ((lo, mid), (mid, hi)):
s = tree_get(d, a, b)
if s is None:
die("Summary %d-%d is missing. Run: memo sleep" % (a, b - 1))
halves.append(" #%d-%d %s" % (a, b - 1, s))
body = "\n".join(halves)
tail = "" if not left else "\n%s after this one." % (
"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%s\n"
"Run: memo sleep %d-%d \"<your line>\""
% (lo, hi - 1, ENTRY_CHARS, body, tail, lo, hi - 1))
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 %s. Run: memo wake"
% (T, plural(now, "entry")))
# 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: %s pending. Do %s, then run memo wake again.\n"
% (plural(n, "compression"), "it" if n == 1 else "them"))
print(next_nap(d, T))
sys.exit(1)
if not T:
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):
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 %s. Run: memo wake"
% (k, plural(len(parts), "part")))
if len(parts) > 1:
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))
else:
# always, even for a one-part memory: the contract an agent is given
# is "run parts until one says awake", so it must always arrive
print("You are 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)) + 1
todo = pending(d, T, limit=1)
if not todo:
print("Nothing left to compress.")
return
if (lo, hi) != todo[0]:
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 - 1))
else:
print("%d-%d saved." % (lo, hi - 1))
nap = next_nap(d, T)
if not nap:
print("Nothing left to compress.")
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)) + 1
size = hi - lo
if size < 2 or size & (size - 1) or lo % size:
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 %s." % args[0])
print("Forgot %s, from %d-%d up. Run: memo sleep"
% (plural(len(gone), "summary"), gone[0][0], gone[0][1] - 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 %s. Narrow the regex."
% (len(out), plural(len(hits), "match")))
else:
print("%s." % plural(len(hits), "match"))
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>'")
try:
src = open(args[0]).readlines()
except OSError as e:
die("Cannot read %s: %s" % (args[0], e.strerror))
last = log_get(d, log_len(d) - 1)[1] if log_len(d) else "0000-00-00"
out = []
for i, line in enumerate(src, 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
if not out:
die("%s has no memories." % args[0])
base = log_append(d, out)
print("Imported %s, #%d to #%d."
% (plural(len(out), "memory"), base, base + len(out) - 1))
n = pending_count(d, log_len(d))
if n:
print("%s pending. Run: memo sleep" % plural(n, "compression"))
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:
print(__doc__.strip())
sys.exit(0)
if sys.argv[1] not in COMMANDS:
print("No such command: %s\n" % sys.argv[1], file=sys.stderr)
print(__doc__.strip(), file=sys.stderr)
sys.exit(1)
d = store()
config(d)
COMMANDS[sys.argv[1]](d, sys.argv[2:])
if __name__ == "__main__":
main()