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:
parent
bfdeae2170
commit
b5041404c0
6 changed files with 196 additions and 123 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]];
|
||||||
|
|
|
||||||
|
|
@ -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
237
memo
|
|
@ -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
40
test.py
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue