correctness pass: every printed command runs, no traceback ever reaches an agent

- the tool names itself in every command it prints. After `curl | sh`
  nothing is on PATH, so `Run: memo nap 0-1 ...` was command-not-found for
  every new user: the whole note -> merge -> nap loop died on the first turn.
- a typo in `config` stopped every command AND the recovery it named
  (`memo config`) read the same file, so nothing could fix it. It now names
  the file and the line.
- a filesystem error (read-only store, MEMORY_DIR at a file) printed a Python
  traceback. One handler reports it in the tool's own voice.
- `nap 1-2` answered 'already settled': an unaligned id read a different
  block's record. nap and forget now share one block-id parser.
- recall held the whole log in memory: 618 MB at a million memories, and a
  vague regex held every match too. One streaming pass, 16 MB.
- install.sh says so when the machine has no python3, instead of installing
  and then failing in env(1).
- anim: package.json was npm-init boilerplate; a comment named deleted blocks.py.
This commit is contained in:
victortaelin 2026-07-26 16:34:25 -03:00
parent bfdeae2170
commit b5041404c0
6 changed files with 196 additions and 123 deletions

View file

@ -14,6 +14,9 @@ It prints a `## Memory` block. Paste that at the top of your agent's
`AGENTS.md` (or `CLAUDE.md`), and you are done. Run the same line again to `AGENTS.md` (or `CLAUDE.md`), and you are done. Run the same line again to
update. update.
The tool lands at `~/.optmem/memo`. Put `~/.optmem` on your `PATH` to type
`memo` for short, as the rest of this page does.
## Commands ## Commands
| | | | | |
@ -49,7 +52,7 @@ a storage budget: change it whenever, in either direction, and nothing is
recomputed. recomputed.
Records are fixed width, so position *is* identity and every lookup is one Records are fixed width, so position *is* identity and every lookup is one
seek. At a million memories (607 MB), `wake` takes 0.03s. seek. At a million memories (608 MB), `wake` takes 0.03s.
Set `$MEMORY_DIR` to keep `memory/` elsewhere — a synced folder, a git repo. Set `$MEMORY_DIR` to keep `memory/` elsewhere — a synced folder, a git repo.

View file

@ -1,14 +1,7 @@
{ {
"name": "anim", "name": "optmem-anim",
"version": "1.0.0", "private": true,
"description": "", "description": "Renders the OptMem explainer: render.js is the film, video.js writes the frames.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"canvas": "^3.2.3" "canvas": "^3.2.3"

View file

@ -228,7 +228,7 @@ function ageOf(lo, hi, n) {
return "~" + (d/365).toFixed(1) + " years ago"; return "~" + (d/365).toFixed(1) + " years ago";
} }
// ------------------------------------------------------- cover (blocks.py) // ----------------------------------------------- cover (a port of ../memo)
function tiles(t, a) { function tiles(t, a) {
let root = 1; while (root < t) root *= 2; let root = 1; while (root < t) root *= 2;
const out = [], st = [[0, root]]; const out = [], st = [[0, root]];

View file

@ -7,6 +7,12 @@
set -e set -e
DIR="$HOME/.optmem" DIR="$HOME/.optmem"
command -v python3 >/dev/null || {
echo "OptMem is one Python file, and this machine has no python3." >&2
echo "Install python3, then run this line again." >&2
exit 1
}
mkdir -p "$DIR" mkdir -p "$DIR"
curl -fsSL https://raw.githubusercontent.com/VictorTaelin/OptMem/main/memo -o "$DIR/memo.new" curl -fsSL https://raw.githubusercontent.com/VictorTaelin/OptMem/main/memo -o "$DIR/memo.new"
mv "$DIR/memo.new" "$DIR/memo" mv "$DIR/memo.new" "$DIR/memo"

237
memo
View file

@ -1,16 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""OptMem: a permanent, append-only memory for AI agents. """OptMem: a permanent, append-only memory for AI agents.
memo init create this machine's memory, print the setup block. {memo} init create this memory; print the setup block.
memo wake [part [T]] read your memory. Run first, every session. {memo} wake [part [T]] read your memory. Run first, every session.
memo note "..." record one memory: one short line. {memo} note "..." record one memory: one short line.
memo nap [id "..."] do the pending compressions. {memo} nap [id "..."] do the pending compressions.
memo recall <regex> search every memory ever recorded. {memo} recall <regex> search every memory ever recorded.
memo forget <lo>-<hi> drop a bad summary; nap rebuilds it. {memo} forget <lo>-<hi> drop a bad summary; nap rebuilds it.
memo config [NAME=N] show this memory's sizes, or change one. {memo} config [NAME=N] show this memory's sizes, or change one.
memo import <file> bulk-load dated memories (bootstrap only). {memo} import <file> bulk-load dated memories (bootstrap only).
The memories live in ~/.optmem/memory, or in $MEMORY_DIR if set. See README.md. The memories live in ~/.optmem/memory, or in $MEMORY_DIR if set.
See github.com/VictorTaelin/OptMem.
""" """
import datetime import datetime
@ -18,6 +19,18 @@ import fcntl
import os import os
import re import re
import sys import sys
from collections import deque
def pretty(p):
"""A path as the user would type it: keep symlinks, fold $HOME to ~."""
p, home = os.path.abspath(p), os.path.expanduser("~")
return "~" + p[len(home):] if p.startswith(home + os.sep) else p
# Every command this tool prints has to RUN. After `curl | sh` nothing is on
# PATH, so a bare `memo nap 0-1 "..."` would not: the tool names itself.
ME = pretty(__file__)
# The sizes a memory may override in its own `config` file: the default, and # The sizes a memory may override in its own `config` file: the default, and
# what it means. `memo config` shows and edits them. The globals below start # what it means. `memo config` shows and edits them. The globals below start
@ -120,8 +133,8 @@ def store():
# store, and the agent would wake with no past and write a second # store, and the agent would wake with no past and write a second
# identity. # identity.
if not os.path.isdir(d): if not os.path.isdir(d):
die("No memory at %s.\nTo create one, run: memo init\n" die("No memory at %s.\nTo create one, run: %s init\n"
"To use an existing one, point MEMORY_DIR at it." % d) "To use an existing one, point MEMORY_DIR at it." % (d, ME))
os.makedirs(os.path.join(d, "TREE"), exist_ok=True) os.makedirs(os.path.join(d, "TREE"), exist_ok=True)
p = os.path.join(d, "LOG.txt") p = os.path.join(d, "LOG.txt")
if not os.path.exists(p): if not os.path.exists(p):
@ -129,14 +142,16 @@ def store():
return d return d
def size(k, v): def size(k, v, where=""):
"""Validate one knob, wherever it came from: the config file or argv.""" """Validate one knob, wherever it came from: the config file or argv. A
bad knob stops every command, so the message has to say where it is
written -- `memo config` cannot fix a file it also refuses to read."""
if not v.isdigit() or int(v) < 1: if not v.isdigit() or int(v) < 1:
die("%s must be a positive whole number, not '%s'." % (k, v)) die("%s%s must be a positive whole number, not '%s'." % (where, k, v))
top = min(TREE_REC - 8, LOG_REC - 40) top = min(TREE_REC - 8, LOG_REC - 40)
if k == "ENTRY_CHARS" and int(v) > top: if k == "ENTRY_CHARS" and int(v) > top:
die("ENTRY_CHARS is at most %d: a memory has to fit the fixed-width " die("%sENTRY_CHARS is at most %d: a memory has to fit the fixed-width "
"records." % top) "records." % (where, top))
return int(v) return int(v)
@ -146,14 +161,17 @@ def overrides(d):
p = os.path.join(d, "config") p = os.path.join(d, "config")
if not os.path.exists(p): if not os.path.exists(p):
return out return out
for line in open(p): for n, line in enumerate(open(p), 1):
line = line.split("#")[0].strip() line = line.split("#")[0].strip()
if "=" not in line: if "=" not in line:
continue continue
k, v = (s.strip() for s in line.split("=", 1)) k, v = line.split("=", 1)
k, v = k.strip().upper(), v.strip()
where = "%s line %d: " % (pretty(p), n)
if k not in KNOBS: if k not in KNOBS:
die("config: %s is not a size. Run: memo config" % k) die("%s%s is not a size. Delete the line, or name one of: %s."
out[k] = size(k, v) % (where, k, ", ".join(KNOBS)))
out[k] = size(k, v, where)
return out return out
@ -168,7 +186,7 @@ def write_config(d, over):
"""Rewrite `config`: every knob on its own line, commented out unless this """Rewrite `config`: every knob on its own line, commented out unless this
memory overrides it.""" memory overrides it."""
out = ["# OptMem sizes for this memory. A commented line means: follow the", out = ["# OptMem sizes for this memory. A commented line means: follow the",
"# tool's default. Edit with `memo config NAME=VALUE`.", ""] "# tool's default. Edit with `%s config NAME=VALUE`." % ME, ""]
for k, (default, what) in KNOBS.items(): for k, (default, what) in KNOBS.items():
out.append("%-2s%-12s = %-6d # %s" out.append("%-2s%-12s = %-6d # %s"
% ("" if k in over else "# ", k, over.get(k, default), what)) % ("" if k in over else "# ", k, over.get(k, default), what))
@ -214,22 +232,36 @@ def parse(line):
return int(head[1:]), date, text return int(head[1:]), date, text
def log_get(d, i): def records(buf):
"""(id, date, text) of memory i, in one seek.""" """Decode a run of log records. They are sliced as BYTES and decoded one
with open(log_path(d), "rb") as f: by one -- slicing decoded text would shift every boundary after the first
f.seek(i * LOG_REC) multi-byte character."""
return parse(f.read(LOG_REC).decode().rstrip()) return [parse(buf[i * LOG_REC:(i + 1) * LOG_REC].decode().rstrip())
for i in range(len(buf) // LOG_REC)]
def log_slice(d, lo, hi): def log_slice(d, lo, hi):
"""Memories [lo,hi) in one read. Records are sliced as BYTES and decoded """Memories [lo,hi) in one read."""
one by one -- slicing decoded text would shift every boundary after the
first multi-byte character."""
with open(log_path(d), "rb") as f: with open(log_path(d), "rb") as f:
f.seek(lo * LOG_REC) f.seek(lo * LOG_REC)
buf = f.read((hi - lo) * LOG_REC) return records(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 log_get(d, i):
"""(id, date, text) of memory i, in one seek."""
return log_slice(d, i, i + 1)[0]
def log_scan(d):
"""Every memory, streamed. A search reads the whole log by nature, but it
must not HOLD it: at a million memories that is 300 MB."""
with open(log_path(d), "rb") as f:
while True:
buf = f.read(LOG_REC * 4096)
if not buf:
return
for e in records(buf):
yield e
def tree_get(d, lo, hi): def tree_get(d, lo, hi):
@ -329,6 +361,20 @@ def plural(n, word):
return "%d %ss" % (n, word) return "%d %ss" % (n, word)
def block_id(s):
"""Parse `<lo>-<hi>` as wake and the nap prompts print it: inclusive at
both ends, and a real block -- an aligned power-of-two range. Without the
shape check, `4-5` and `5-6` read the same record."""
m = re.fullmatch(r"(\d+)-(\d+)", s)
if not m:
die("'%s' is not a block id. Copy it from the prompt." % s)
lo, hi = int(m.group(1)), int(m.group(2)) + 1
n = hi - lo
if n < 2 or n & (n - 1) or lo % n:
die("%s is not a block. Copy the id printed by wake, like 16-31." % s)
return lo, hi
def check(text): def check(text):
text = text.strip() text = text.strip()
if not text: if not text:
@ -379,7 +425,7 @@ def nap_prompt(d, lo, hi, left):
for a, b in ((lo, mid), (mid, hi)): for a, b in ((lo, mid), (mid, hi)):
s = tree_get(d, a, b) s = tree_get(d, a, b)
if s is None: if s is None:
die("Summary %d-%d is missing. Run: memo nap" % (a, b - 1)) die("Summary %d-%d is missing. Run: %s nap" % (a, b - 1, ME))
halves.append(" #%d-%d %s" % (a, b - 1, s)) halves.append(" #%d-%d %s" % (a, b - 1, s))
body = "\n".join(halves) body = "\n".join(halves)
tail = "" if not left else "\n%s after this one." % ( tail = "" if not left else "\n%s after this one." % (
@ -389,8 +435,8 @@ def nap_prompt(d, lo, hi, left):
"Keep what has lasting effect, drop what does not. Invent " "Keep what has lasting effect, drop what does not. Invent "
"nothing.\n\n" "nothing.\n\n"
"%s\n%s\n" "%s\n%s\n"
"Run: memo nap %d-%d \"<your line>\"" "Run: %s nap %d-%d \"<your line>\""
% (lo, hi - 1, ENTRY_CHARS, body, tail, lo, hi - 1)) % (lo, hi - 1, ENTRY_CHARS, body, tail, ME, lo, hi - 1))
def next_nap(d, T): def next_nap(d, T):
@ -448,20 +494,13 @@ def cmd_init(d, args):
pastes into their agent's instruction file. Re-running it is safe: it pastes into their agent's instruction file. Re-running it is safe: it
only ever creates what is missing, and never rewrites what is there.""" only ever creates what is missing, and never rewrites what is there."""
if args: if args:
die("usage: memo init") die("usage: %s init" % ME)
fresh = not os.path.isdir(d) fresh = not os.path.isdir(d)
os.makedirs(os.path.join(d, "TREE"), exist_ok=True) os.makedirs(os.path.join(d, "TREE"), exist_ok=True)
open(log_path(d), "a").close() open(log_path(d), "a").close()
if not os.path.exists(os.path.join(d, "config")): if not os.path.exists(os.path.join(d, "config")):
write_config(d, {}) write_config(d, {})
config(d) config(d)
home = os.path.expanduser("~")
def pretty(p):
# as the user would type it: keep symlinks, fold $HOME to ~
p = os.path.abspath(p)
return "~" + p[len(home):] if p.startswith(home + os.sep) else p
if fresh: if fresh:
print("Created %s: this machine's memory, one identity, forever." % pretty(d)) print("Created %s: this machine's memory, one identity, forever." % pretty(d))
else: else:
@ -470,8 +509,7 @@ def cmd_init(d, args):
print() print()
print("Paste this at the top of your agent's AGENTS.md (or CLAUDE.md), done:") print("Paste this at the top of your agent's AGENTS.md (or CLAUDE.md), done:")
print() print()
print(TEMPLATE.format(memo=pretty(__file__), data=pretty(d), print(TEMPLATE.format(memo=ME, data=pretty(d), chars=ENTRY_CHARS).rstrip())
chars=ENTRY_CHARS).rstrip())
def paginate(lines): def paginate(lines):
@ -494,17 +532,18 @@ def cmd_wake(d, args):
k, T = 1, now k, T = 1, now
if args: if args:
if len(args) > 2 or not all(a.isdigit() for a in args): if len(args) > 2 or not all(a.isdigit() for a in args):
die("usage: memo wake [part [T]]") die("usage: %s wake [part [T]]" % ME)
k = int(args[0]) k = int(args[0])
if len(args) == 2: if len(args) == 2:
T = int(args[1]) T = int(args[1])
if T > now: if T > now:
die("T=%d, but the memory holds %s. Run: memo wake" die("T=%d, but the memory holds %s. Run: %s wake"
% (T, plural(now, "entry"))) % (T, plural(now, "entry"), ME))
# A part is rendered as of T, so a note landing between two parts cannot # A part is rendered as of T, so a note landing between two parts cannot
# shift a boundary and drop a line. # shift a boundary and drop a line.
if not T: if not T:
print("No memories yet. Record the first with: memo note \"<one line>\"") print("No memories yet. Record the first with: %s note \"<one line>\""
% ME)
print("You are awake.") print("You are awake.")
return return
lines = [] lines = []
@ -519,17 +558,17 @@ def cmd_wake(d, args):
# is handed over after the read instead, costing no round # is handed over after the read instead, costing no round
# trip. # trip.
print("Cannot wake: the memory context needs #%d-%d, which is " print("Cannot wake: the memory context needs #%d-%d, which is "
"not compressed yet.\nDo the %s below, then run memo " "not compressed yet.\nDo the %s below, then run %s "
"wake again.\n" "wake again.\n"
% (lo, hi - 1, % (lo, hi - 1,
plural(pending_count(d, T), "compression"))) plural(pending_count(d, T), "compression"), ME))
print(next_nap(d, T)) print(next_nap(d, T))
sys.exit(1) sys.exit(1)
lines.append("#%d-%d %s" % (lo, hi - 1, s)) lines.append("#%d-%d %s" % (lo, hi - 1, s))
parts = paginate(lines) parts = paginate(lines)
if not 1 <= k <= len(parts): if not 1 <= k <= len(parts):
die("No part %d: the memory has %s. Run: memo wake" die("No part %d: the memory has %s. Run: %s wake"
% (k, plural(len(parts), "part"))) % (k, plural(len(parts), "part"), ME))
if len(parts) > 1: if len(parts) > 1:
# The count is here so the T in `memo wake 2 296` reads as what it # The count is here so the T in `memo wake 2 296` reads as what it
# is: the snapshot this document was written from. # is: the snapshot this document was written from.
@ -540,7 +579,7 @@ def cmd_wake(d, args):
# This footer is the only instruction that survives every harness's # This footer is the only instruction that survives every harness's
# truncation (pi drops the HEAD of a long output), so it has to say # truncation (pi drops the HEAD of a long output), so it has to say
# both that the read is unfinished and how to continue it. # both that the read is unfinished and how to continue it.
print("Not awake yet. Run: memo wake %d %d" % (k + 1, T)) print("Not awake yet. Run: %s wake %d %d" % (ME, k + 1, T))
else: else:
# always, even for a one-part memory: the contract an agent is given # 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 # is "run parts until one says awake", so it must always arrive
@ -552,7 +591,8 @@ def cmd_wake(d, args):
def cmd_note(d, args): def cmd_note(d, args):
if len(args) != 1: if len(args) != 1:
die("usage: memo note \"<one line, at most %d chars>\"" % ENTRY_CHARS) die("usage: %s note \"<one line, at most %d chars>\""
% (ME, ENTRY_CHARS))
text = check(args[0]) text = check(args[0])
i = log_append(d, [(datetime.date.today().isoformat(), text)]) i = log_append(d, [(datetime.date.today().isoformat(), text)])
print("Saved as #%d." % i) print("Saved as #%d." % i)
@ -566,11 +606,8 @@ def cmd_nap(d, args):
if args: if args:
said = True said = True
if len(args) != 2: if len(args) != 2:
die("usage: memo nap <lo>-<hi> \"<one line>\"") die("usage: %s nap <lo>-<hi> \"<one line>\"" % ME)
m = re.fullmatch(r"(\d+)-(\d+)", args[0]) lo, hi = block_id(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) todo = pending(d, T, limit=1)
if not todo: if not todo:
print("Nothing left to compress.") print("Nothing left to compress.")
@ -580,8 +617,8 @@ def cmd_nap(d, args):
print("%d-%d is already settled." % (lo, hi - 1)) print("%d-%d is already settled." % (lo, hi - 1))
else: else:
die("Wrong block: %s. Blocks are built in order; the next is " die("Wrong block: %s. Blocks are built in order; the next is "
"%d-%d. Run: memo nap" "%d-%d. Run: %s nap"
% (args[0], todo[0][0], todo[0][1] - 1)) % (args[0], todo[0][0], todo[0][1] - 1, ME))
elif not tree_put(d, lo, hi, check(args[1])): elif not tree_put(d, lo, hi, check(args[1])):
print("%d-%d was settled or forgotten meanwhile." % (lo, hi - 1)) print("%d-%d was settled or forgotten meanwhile." % (lo, hi - 1))
else: else:
@ -602,8 +639,8 @@ def cmd_config(d, args):
k, eq, v = a.partition("=") k, eq, v = a.partition("=")
k = k.strip().upper() k = k.strip().upper()
if not eq or k not in KNOBS: if not eq or k not in KNOBS:
die("usage: memo config [NAME=VALUE ...] # NAME one of %s" die("usage: %s config [NAME=VALUE ...] # NAME one of %s"
% ", ".join(KNOBS)) % (ME, ", ".join(KNOBS)))
if v.strip(): if v.strip():
over[k] = size(k, v.strip()) over[k] = size(k, v.strip())
else: else:
@ -621,60 +658,51 @@ def cmd_forget(d, args):
everything built on top of it; the next nap computes them again. The log everything built on top of it; the next nap computes them again. The log
is untouched, so nothing is ever actually lost.""" is untouched, so nothing is ever actually lost."""
if len(args) != 1: if len(args) != 1:
die("usage: memo forget <lo>-<hi>") die("usage: %s forget <lo>-<hi>" % ME)
m = re.fullmatch(r"(\d+)-(\d+)", args[0]) gone = tree_drop(d, *block_id(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: if not gone:
die("No summary at %s." % args[0]) die("No summary at %s." % args[0])
print("Forgot %s, from %d-%d up. Run: memo nap" print("Forgot %s, from %d-%d up. Run: %s nap"
% (plural(len(gone), "summary"), gone[0][0], gone[0][1] - 1)) % (plural(len(gone), "summary"), gone[0][0], gone[0][1] - 1, ME))
def cmd_recall(d, args): def cmd_recall(d, args):
if len(args) != 1: if len(args) != 1:
die("usage: memo recall <regex>") die("usage: %s recall <regex>" % ME)
try: try:
pat = re.compile(args[0], re.I) pat = re.compile(args[0], re.I)
except re.error as e: except re.error as e:
die("bad regex: %s" % e) die("bad regex: %s" % e)
hits = [e for e in log_slice(d, 0, log_len(d)) # One pass, keeping only the newest matches that fit the cap `wake`
if pat.search("#%d %s %s" % e)] # respects -- a vague regex matches the whole log, and the whole log does
# not fit in a harness's output or in memory.
hits, out, size = 0, deque(), 0
for e in log_scan(d):
line = "#%d %s %s" % e
if not pat.search(line):
continue
hits += 1
out.append(line)
size += len(line.encode()) + 1
while size > PART_CHARS:
size -= len(out.popleft().encode()) + 1
if not hits: if not hits:
print("No match.") print("No match.")
return return
# Newest first is what a search is usually for, and the output has to fit print("\n".join(out))
# the same cap `wake` respects. if len(out) < hits:
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." print("Newest %d of %s. Narrow the regex."
% (len(out), plural(len(hits), "match"))) % (len(out), plural(hits, "match")))
else: else:
print("%s." % plural(len(hits), "match")) print("%s." % plural(hits, "match"))
def cmd_import(d, args): def cmd_import(d, args):
"""Bulk-append historical memories: 'YYYY-MM-DD <text>' per line. """Bulk-append historical memories: 'YYYY-MM-DD <text>' per line.
For bootstrapping an identity from older records. Used once.""" For bootstrapping an identity from older records. Used once."""
if len(args) != 1: if len(args) != 1:
die("usage: memo import <file> # lines of 'YYYY-MM-DD <text>'") die("usage: %s import <file> # lines of 'YYYY-MM-DD <text>'" % ME)
try:
src = open(args[0]).readlines() 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" last = log_get(d, log_len(d) - 1)[1] if log_len(d) else "0000-00-00"
out = [] out = []
for i, line in enumerate(src, 1): for i, line in enumerate(src, 1):
@ -699,7 +727,7 @@ def cmd_import(d, args):
% (plural(len(out), "memory"), base, base + len(out) - 1)) % (plural(len(out), "memory"), base, base + len(out) - 1))
n = pending_count(d, log_len(d)) n = pending_count(d, log_len(d))
if n: if n:
print("%s pending. Run: memo nap" % plural(n, "compression")) print("%s pending. Run: %s nap" % (plural(n, "compression"), ME))
COMMANDS = {"init": cmd_init, "wake": cmd_wake, "note": cmd_note, COMMANDS = {"init": cmd_init, "wake": cmd_wake, "note": cmd_note,
@ -708,23 +736,30 @@ COMMANDS = {"init": cmd_init, "wake": cmd_wake, "note": cmd_note,
def main(): def main():
usage = __doc__.strip().format(memo=ME)
if len(sys.argv) < 2: if len(sys.argv) < 2:
print(__doc__.strip()) print(usage)
sys.exit(0) sys.exit(0)
cmd = sys.argv[1] cmd = sys.argv[1]
if cmd not in COMMANDS: if cmd not in COMMANDS:
print("No such command: %s\n" % cmd, file=sys.stderr) print("No such command: %s\n" % cmd, file=sys.stderr)
print(__doc__.strip(), file=sys.stderr) print(usage, file=sys.stderr)
sys.exit(1) sys.exit(1)
try:
# `init` is the only command that may run without an existing memory: # `init` is the only command that may run without an existing memory:
# it is the one that creates it. Every other command refuses, so a typo # it is the one that creates it. Every other command refuses, so a
# in MEMORY_DIR is an error instead of a second, empty identity. # typo in MEMORY_DIR is an error instead of a second, empty identity.
if cmd == "init": if cmd == "init":
cmd_init(memory_dir(), sys.argv[2:]) cmd_init(memory_dir(), sys.argv[2:])
return return
d = store() d = store()
config(d) config(d)
COMMANDS[cmd](d, sys.argv[2:]) COMMANDS[cmd](d, sys.argv[2:])
except OSError as e:
# The filesystem is the one thing the tool does not control. Report it
# the way every other failure is reported: a Python traceback tells an
# agent nothing it can act on.
die("%s: %s." % (pretty(e.filename or memory_dir()), e.strerror or e))
if __name__ == "__main__": if __name__ == "__main__":

40
test.py
View file

@ -163,6 +163,42 @@ woke = subprocess.run(memo + ["wake"], capture_output=True, text=True, env=fresh
check(woke.returncode == 0 and "You are awake." in woke.stdout, check(woke.returncode == 0 and "You are awake." in woke.stdout,
"after init, wake must work with zero configuration") "after init, wake must work with zero configuration")
# Every command the tool prints must RUN on the machine it printed it on.
# `curl | sh` puts nothing on PATH, so a bare `memo nap ...` would not: the
# whole loop (note -> merge prompt -> nap) dies on `command not found`.
bare = dict(fresh, PATH="/usr/bin:/bin")
subprocess.run(memo + ["note", "the first thing that happened"], env=bare,
capture_output=True)
asked = subprocess.run(memo + ["note", "the second thing that happened"],
env=bare, capture_output=True, text=True)
order = [l[5:] for l in asked.stdout.splitlines() if l.startswith("Run: ")]
check(len(order) == 1, "note did not order a compression: " + asked.stdout)
obeyed = subprocess.run(order[0].replace('"<your line>"', '"both things"'),
shell=True, env=bare, capture_output=True, text=True)
check(obeyed.returncode == 0 and "saved" in obeyed.stdout,
"the order the tool printed does not run with nothing on PATH: %r -> %s"
% (order[0], obeyed.stderr.strip()))
# a size written by hand into `config` must not brick the tool with a
# recovery that is itself broken: name the file and the line
badcfg = os.path.join(fresh["HOME"], ".optmem", "memory", "config")
with open(badcfg, "a") as f:
f.write("WAKE_LNES = 100\n")
for c in (["wake"], ["config"]):
r_ = subprocess.run(memo + c, capture_output=True, text=True, env=fresh)
check(r_.returncode == 1 and "config line" in r_.stderr
and "WAKE_LNES" in r_.stderr,
"a typo in config does not say where it is: " + r_.stderr)
open(badcfg, "w").write("")
# the filesystem is the one thing the tool does not control: report it in the
# tool's own voice, never as a Python traceback
r_ = subprocess.run(memo + ["init"], capture_output=True, text=True,
env=dict(fresh, MEMORY_DIR=MEMO)) # a file, not a store
check(r_.returncode == 1 and "Traceback" not in r_.stderr
and "Not a directory" in r_.stderr,
"a filesystem error printed a traceback: " + r_.stderr)
r = run("note", "x" * 281) r = run("note", "x" * 281)
check(r.returncode == 1 and "Too long" in r.stderr, "over-long note accepted") check(r.returncode == 1 and "Too long" in r.stderr, "over-long note accepted")
@ -188,7 +224,7 @@ check(not os.path.exists(os.path.join(d, "config")),
r = run("wake") r = run("wake")
check(r.returncode == 1 and "Cannot wake" in r.stdout, check(r.returncode == 1 and "Cannot wake" in r.stdout,
"wake must refuse while work is pending") "wake must refuse while work is pending")
check("run memo wake again" in r.stdout, check("wake again" in r.stdout,
"the refusal must order the agent back to wake") "the refusal must order the agent back to wake")
check("None" not in r.stdout, "the refusal printed a Python None") check("None" not in r.stdout, "the refusal printed a Python None")
@ -233,7 +269,7 @@ lines = [l for p in parts for l in p]
check(len(lines) == WAKE_LINES, "woke with %d lines, want %d" % (len(lines), WAKE_LINES)) 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[-1].startswith("#%d " % (N - 1)), "newest memory not last / not raw")
check(lines[0].startswith("#0-"), "oldest line should be a summary block") check(lines[0].startswith("#0-"), "oldest line should be a summary block")
check("Run: memo wake 2" in run("wake").stdout, check(re.search(r"Run: \S*memo wake 2", run("wake").stdout),
"part 1 must ORDER the next command, not label it") "part 1 must ORDER the next command, not label it")
check("You are awake." in run("wake", str(len(parts))).stdout, check("You are awake." in run("wake", str(len(parts))).stdout,
"last part must say it is last") "last part must say it is last")