OptMem: a permanent, append-only memory for AI agents
This commit is contained in:
commit
7e3d55dad6
5 changed files with 768 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
217
README.md
Normal file
217
README.md
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
# OptMem
|
||||||
|
|
||||||
|
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
|
||||||
|
API, no integration with any particular agent harness — it works the same under
|
||||||
|
Claude Code, Codex, pi or a human at a shell.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
An agent's context window is its whole world, and the world ends every session.
|
||||||
|
The usual patch is a notes file the agent rewrites by hand, which decays into
|
||||||
|
either a stale summary or a wall of text nobody can afford to read.
|
||||||
|
|
||||||
|
OptMem fixes the two halves separately:
|
||||||
|
|
||||||
|
- **Nothing is ever forgotten.** Every memory is appended to `LOG.txt` and
|
||||||
|
never edited or deleted. That file is the truth, forever.
|
||||||
|
- **What you read is a fixed size.** `memo wake` prints a document of bounded
|
||||||
|
length — recent memories verbatim, older ones progressively compressed. At
|
||||||
|
a hundred million memories it is still the same number of lines.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/VictorTaelin/OptMem ~/OptMem
|
||||||
|
export PATH="$HOME/OptMem:$PATH"
|
||||||
|
export MEMORY_DIR="$HOME/memory" # required; there is no default
|
||||||
|
```
|
||||||
|
|
||||||
|
`MEMORY_DIR` is the only machine-specific fact in the system. One machine, one
|
||||||
|
`MEMORY_DIR`, one identity.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```sh
|
||||||
|
memo wake # who you are. run this first, every session.
|
||||||
|
memo note "..." # record a memory. one line, <= 280 chars.
|
||||||
|
memo sleep # compress. keep going until it says you woke up.
|
||||||
|
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.
|
||||||
|
|
||||||
|
You are dreaming. Compress these two summaries into ONE line of at most 280
|
||||||
|
characters.
|
||||||
|
...
|
||||||
|
Then run exactly:
|
||||||
|
memo sleep 4192-4196 "<your line>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
`LOG.txt` is the ground truth: one memory per line, forever.
|
||||||
|
|
||||||
|
```
|
||||||
|
#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
|
||||||
|
```
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
```
|
||||||
|
#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)
|
||||||
|
```
|
||||||
|
|
||||||
|
A block covering four thousand memories is still one line of 280 characters.
|
||||||
|
Nothing in the system is ever bigger than one line.
|
||||||
|
|
||||||
|
`memo wake` picks a set of blocks that tiles the whole log and prints them. It
|
||||||
|
keeps a block whole when its size is small relative to its age, so **detail is
|
||||||
|
proportional to recency**, and it spends exactly `WAKE_LINES` lines doing it:
|
||||||
|
|
||||||
|
```
|
||||||
|
10,000 memories, WAKE_LINES = 320:
|
||||||
|
|
||||||
|
block size: 1 2 4 8 16 32 64 128 256
|
||||||
|
how many: 70 35 35 35 36 35 35 35 4
|
||||||
|
└ the last 70, verbatim ───────────▶ the first 1,000, 256:1
|
||||||
|
```
|
||||||
|
|
||||||
|
The oldest memories are recalled as a vague shape, the newest word for word,
|
||||||
|
and the transition is smooth. Below `WAKE_LINES` memories nothing is compressed
|
||||||
|
at all — your whole life is printed verbatim, because it fits.
|
||||||
|
|
||||||
|
## The invariant
|
||||||
|
|
||||||
|
**There is never any doable work pending.** The moment a block's range is
|
||||||
|
complete, that block can be built, and it must be. This costs about one small
|
||||||
|
compression per memory written, and it means:
|
||||||
|
|
||||||
|
- `memo wake` never waits. The blocks it needs were built long ago.
|
||||||
|
- Work is never deferred into a spike. Measured over 20,000 memories, a new
|
||||||
|
memory creates one compression on average and nine at the very worst.
|
||||||
|
- `WAKE_LINES` can be changed at any time, on any machine, with nothing to
|
||||||
|
recompute. It only selects which existing lines get printed.
|
||||||
|
|
||||||
|
`memo wake` enforces the invariant: while any compression is pending it refuses
|
||||||
|
to print, and hands you the work instead. A memory with work left in it is not
|
||||||
|
yet the truth.
|
||||||
|
|
||||||
|
## Writing a good memory
|
||||||
|
|
||||||
|
A note costs one future compression, so it is not free — but an unwritten
|
||||||
|
memory is gone forever, which is far more expensive. Write one whenever
|
||||||
|
something is genuinely worth keeping: a fact or a ruling from the user, an
|
||||||
|
insight, a decision, a piece of work landing, something that failed and why.
|
||||||
|
Do not log trivia, do not narrate your own process, and do not hoard.
|
||||||
|
|
||||||
|
Compress toward facts, not prose. Keep names, numbers, dates, paths, ids and
|
||||||
|
decisions; drop wording.
|
||||||
|
|
||||||
|
```
|
||||||
|
bad worked on the memory system today and made good progress on the design
|
||||||
|
good OptMem design settled: LOG.txt append-only truth, TREE.txt binary merge
|
||||||
|
tree of 280-char summaries, wake renders a fixed 320-line document
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
$MEMORY_DIR/
|
||||||
|
LOG.txt #id date text append-only. never edited. the truth.
|
||||||
|
TREE.txt lo-hi text a cache. each block written once, unless forgotten.
|
||||||
|
config ENTRY_CHARS=280 longest a memory may be
|
||||||
|
WAKE_LINES=320 how many lines `memo wake` prints (~24k tokens)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both files are plain text, sorted by construction, and safe to read with any
|
||||||
|
tool. Writes are serialised with a lock, so parallel sessions on one machine can
|
||||||
|
append at the same time without corrupting anything.
|
||||||
|
|
||||||
|
Agents must never create, edit or delete anything in `MEMORY_DIR` themselves.
|
||||||
|
Every write goes through `memo`, which enforces the one-line and character
|
||||||
|
limits, assigns ids, and refuses to overwrite a block that already exists.
|
||||||
|
|
||||||
|
## Correcting a memory
|
||||||
|
|
||||||
|
You cannot. Append the correction instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
memo note "correction: the halt bug was in the column order, not the row order (see #4198)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both lines are true history, and compression will merge them. This is why
|
||||||
|
nothing is ever lost: `memo recall` still finds the original.
|
||||||
|
|
||||||
|
A *summary* is different. It is not history, it is a cache of a pure function
|
||||||
|
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 176-192 184-192 188-192. They will be compressed again on your next sleep.
|
||||||
|
```
|
||||||
|
|
||||||
|
That is a handful of small compressions, not a rebuild, and `LOG.txt` is never
|
||||||
|
touched. Fixing one bad summary can never cost you a memory.
|
||||||
|
|
||||||
|
## Add this to your agent's instruction file
|
||||||
|
|
||||||
|
Put it at the top of `AGENTS.md` (or `CLAUDE.md`), above everything else.
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 test.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the block math against a hundred thousand memory counts and drives the
|
||||||
|
real CLI through a synthetic life of two thousand memories, checking that the
|
||||||
|
document always tiles the log, never exceeds its budget, always increases in
|
||||||
|
detail toward the present, that every block is written exactly once, that
|
||||||
|
nothing is ever rewritten, and that a full sleep always leads to a clean wake.
|
||||||
75
blocks.py
Normal file
75
blocks.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Block math for OptMem.
|
||||||
|
|
||||||
|
A BLOCK is an aligned power-of-two range of memories, [lo, hi), written as one
|
||||||
|
line of at most ENTRY_CHARS characters. Blocks form a binary merge tree over
|
||||||
|
LOG.txt: block [lo,hi) is the compression of [lo,mid) and [mid,hi).
|
||||||
|
|
||||||
|
Two pure functions matter:
|
||||||
|
|
||||||
|
cover(T, budget) which blocks `memo wake` prints
|
||||||
|
complete(T) every block that CAN be built, smallest first
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _cover(T, alpha):
|
||||||
|
"""Tile [0,T) with aligned power-of-two blocks; keep a block whole iff its
|
||||||
|
size is at most `alpha` times its age. Bigger alpha = coarser = fewer lines."""
|
||||||
|
root = 1
|
||||||
|
while root < T:
|
||||||
|
root *= 2
|
||||||
|
out, stack = [], [(0, root)]
|
||||||
|
while stack:
|
||||||
|
lo, hi = stack.pop()
|
||||||
|
if lo >= T:
|
||||||
|
continue
|
||||||
|
size = hi - lo
|
||||||
|
if size > 1 and (hi > T or size > alpha * (T - lo)):
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
stack.append((mid, hi))
|
||||||
|
stack.append((lo, mid))
|
||||||
|
else:
|
||||||
|
out.append((lo, hi))
|
||||||
|
out.sort()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cover(T, budget):
|
||||||
|
"""The blocks `memo wake` prints: at most `budget` of them, finest near T.
|
||||||
|
|
||||||
|
Detail decays with age, so recent memories stay verbatim and ancient ones
|
||||||
|
collapse. If everything fits, nothing is compressed at all."""
|
||||||
|
if T <= 0:
|
||||||
|
return []
|
||||||
|
if T <= budget:
|
||||||
|
return [(i, i + 1) for i in range(T)]
|
||||||
|
lo, hi = 0.0, 1.0
|
||||||
|
for _ in range(60):
|
||||||
|
mid = (lo + hi) / 2
|
||||||
|
if len(_cover(T, mid)) > budget:
|
||||||
|
lo = mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
out = _cover(T, hi)
|
||||||
|
# Block sizes jump in powers of two, so alpha alone can undershoot the
|
||||||
|
# budget. Spend what is left on the present, where detail is worth most.
|
||||||
|
while len(out) < budget:
|
||||||
|
i = max((i for i, b in enumerate(out) if b[1] - b[0] > 1), default=None)
|
||||||
|
if i is None:
|
||||||
|
break
|
||||||
|
lo_, hi_ = out[i]
|
||||||
|
mid = (lo_ + hi_) // 2
|
||||||
|
out[i:i + 1] = [(lo_, mid), (mid, hi_)]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def complete(T):
|
||||||
|
"""Every block buildable from T memories, smallest first (so a block's
|
||||||
|
halves always come before it). This is the whole of the work that exists:
|
||||||
|
if all of these are in TREE.txt, there is nothing left to do."""
|
||||||
|
out = []
|
||||||
|
size = 2
|
||||||
|
while size <= T:
|
||||||
|
for i in range(T // size):
|
||||||
|
out.append((i * size, (i + 1) * size))
|
||||||
|
size *= 2
|
||||||
|
return out
|
||||||
313
memo
Executable file
313
memo
Executable file
|
|
@ -0,0 +1,313 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OptMem: a permanent, append-only memory for AI agents.
|
||||||
|
|
||||||
|
memo wake 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 as two append-only text files. 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 complete, cover # noqa: E402
|
||||||
|
|
||||||
|
ENTRY_CHARS = 280
|
||||||
|
WAKE_LINES = 320
|
||||||
|
RAW_MAX = 16 # blocks up to this many memories compress from the raw log
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- store
|
||||||
|
|
||||||
|
def store():
|
||||||
|
d = os.environ.get("MEMORY_DIR")
|
||||||
|
if not d:
|
||||||
|
die("MEMORY_DIR is not set. It must name this machine's memory "
|
||||||
|
"directory, e.g. export MEMORY_DIR=~/memory")
|
||||||
|
d = os.path.expanduser(d)
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
for name in ("LOG.txt", "TREE.txt"):
|
||||||
|
p = os.path.join(d, name)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
open(p, "a").close()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def config(d):
|
||||||
|
global ENTRY_CHARS, WAKE_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\n" % (ENTRY_CHARS, WAKE_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)
|
||||||
|
|
||||||
|
|
||||||
|
def read_log(d):
|
||||||
|
"""[(id, date, text)] in order."""
|
||||||
|
out = []
|
||||||
|
for line in open(os.path.join(d, "LOG.txt")):
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
head, _, text = line.partition(" ")
|
||||||
|
date, _, text = text.partition(" ")
|
||||||
|
out.append((int(head[1:]), date, text))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def read_tree(d):
|
||||||
|
"""{(lo,hi): text}"""
|
||||||
|
out = {}
|
||||||
|
for line in open(os.path.join(d, "TREE.txt")):
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
head, _, text = line.partition(" ")
|
||||||
|
lo, _, hi = head.partition("-")
|
||||||
|
out[(int(lo), int(hi))] = text
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_tree(d, keep):
|
||||||
|
"""Replace TREE.txt. Legal only because TREE.txt is a CACHE of a pure
|
||||||
|
function of LOG.txt -- the log itself is never rewritten by anything."""
|
||||||
|
lock = open(os.path.join(d, ".lock"), "w")
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
tmp = os.path.join(d, "TREE.txt.new")
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
for (lo, hi), text in keep:
|
||||||
|
f.write("%d-%d %s\n" % (lo, hi, text))
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, os.path.join(d, "TREE.txt"))
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||||
|
lock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def append(d, name, lines):
|
||||||
|
"""Append under an exclusive lock. The only way anything is ever written."""
|
||||||
|
lock = open(os.path.join(d, ".lock"), "w")
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, name), "a") as f:
|
||||||
|
for line in lines:
|
||||||
|
f.write(line + "\n")
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||||
|
lock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg):
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def check(text):
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
die("REJECTED: empty.")
|
||||||
|
if "\n" in text or "\r" in text:
|
||||||
|
die("REJECTED: %d lines. A memory is exactly one line." %
|
||||||
|
(text.count("\n") + 1))
|
||||||
|
if len(text) > ENTRY_CHARS:
|
||||||
|
die("REJECTED: %d chars, %d over the %d limit. Compress it further."
|
||||||
|
% (len(text), len(text) - ENTRY_CHARS, ENTRY_CHARS))
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- naps
|
||||||
|
|
||||||
|
def pending(log, tree):
|
||||||
|
"""Blocks that can be built and have not been. Smallest first, so a
|
||||||
|
block's two halves are always available before the block itself."""
|
||||||
|
return [b for b in complete(len(log)) if b not in tree]
|
||||||
|
|
||||||
|
|
||||||
|
def nap_prompt(d, log, tree, todo):
|
||||||
|
lo, hi = todo[0]
|
||||||
|
if hi - lo <= RAW_MAX:
|
||||||
|
body = "\n".join(" #%d %s %s" % log[i] for i in range(lo, hi))
|
||||||
|
what = "these %d memories" % (hi - lo)
|
||||||
|
else:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
body = "\n".join(" %s" % tree[(a, b)] for a, b in
|
||||||
|
((lo, mid), (mid, hi)))
|
||||||
|
what = "these two summaries"
|
||||||
|
return (
|
||||||
|
"You are dreaming. Compress {what} into ONE line of at most {n} "
|
||||||
|
"characters.\nKeep every name, number, date and decision. Drop wording, "
|
||||||
|
"not facts.\nInvent nothing. Write it as a memory, not as a description "
|
||||||
|
"of memories.\n\n{body}\n\nThen run exactly:\n"
|
||||||
|
" memo sleep {lo}-{hi} \"<your line>\"\n\n"
|
||||||
|
"{left} nap(s) left after this one."
|
||||||
|
).format(what=what, n=ENTRY_CHARS, body=body, lo=lo, hi=hi,
|
||||||
|
left=len(todo) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
|
def cmd_wake(d, args):
|
||||||
|
log, tree = read_log(d), read_tree(d)
|
||||||
|
todo = pending(log, tree)
|
||||||
|
if todo:
|
||||||
|
print("YOU CANNOT WAKE UP YET: %d compression(s) are pending, and a "
|
||||||
|
"memory\nwith work left in it is not yet the truth. Sleep first "
|
||||||
|
"-- it is quick.\n" % len(todo))
|
||||||
|
print(nap_prompt(d, log, tree, todo))
|
||||||
|
sys.exit(1)
|
||||||
|
if not log:
|
||||||
|
print("You have no memories yet. This is your first moment.\n"
|
||||||
|
"Record what matters with: memo note \"...\"")
|
||||||
|
return
|
||||||
|
for lo, hi in cover(len(log), WAKE_LINES):
|
||||||
|
if hi - lo == 1:
|
||||||
|
print("#%d %s %s" % log[lo])
|
||||||
|
else:
|
||||||
|
print("#%d-%d %s" % (lo, hi - 1, tree[(lo, hi)]))
|
||||||
|
|
||||||
|
|
||||||
|
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])
|
||||||
|
log = read_log(d)
|
||||||
|
date = datetime.date.today().isoformat()
|
||||||
|
append(d, "LOG.txt", ["#%d %s %s" % (len(log), date, text)])
|
||||||
|
print("ok, memory #%d." % len(log))
|
||||||
|
log, tree = read_log(d), read_tree(d)
|
||||||
|
todo = pending(log, tree)
|
||||||
|
if todo:
|
||||||
|
print("\n" + nap_prompt(d, log, tree, todo))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sleep(d, args):
|
||||||
|
log, tree = read_log(d), read_tree(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("REJECTED: '%s' is not a block id. Copy it from the prompt."
|
||||||
|
% args[0])
|
||||||
|
block = (int(m.group(1)), int(m.group(2)))
|
||||||
|
if block in tree:
|
||||||
|
print("Already dreamt; another session got there first. Skipping.")
|
||||||
|
elif block not in pending(log, tree):
|
||||||
|
die("REJECTED: block %d-%d is not ready to be compressed. Run "
|
||||||
|
"`memo sleep` for the block you should be working on."
|
||||||
|
% block)
|
||||||
|
else:
|
||||||
|
text = check(args[1])
|
||||||
|
append(d, "TREE.txt", ["%d-%d %s" % (block[0], block[1], text)])
|
||||||
|
print("ok, %d-%d remembered." % block)
|
||||||
|
log, tree = read_log(d), read_tree(d)
|
||||||
|
todo = pending(log, tree)
|
||||||
|
if not todo:
|
||||||
|
print("You woke up. Nothing left to compress.")
|
||||||
|
return
|
||||||
|
print("\n" + nap_prompt(d, log, tree, todo))
|
||||||
|
|
||||||
|
|
||||||
|
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("REJECTED: '%s' is not a block id." % args[0])
|
||||||
|
lo, hi = int(m.group(1)), int(m.group(2))
|
||||||
|
tree = read_tree(d)
|
||||||
|
doomed = [b for b in tree if b[0] <= lo and hi <= b[1]]
|
||||||
|
if not doomed:
|
||||||
|
die("There is no summary covering %d-%d to forget." % (lo, hi))
|
||||||
|
rewrite_tree(d, [(b, t) for b, t in sorted(tree.items()) if b not in doomed])
|
||||||
|
print("forgot %s. They will be compressed again on your next sleep."
|
||||||
|
% " ".join("%d-%d" % b for b in sorted(doomed)))
|
||||||
|
|
||||||
|
|
||||||
|
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 read_log(d) if pat.search(e[2])]
|
||||||
|
if not hits:
|
||||||
|
print("Nothing in your memory matches that.")
|
||||||
|
return
|
||||||
|
for e in hits:
|
||||||
|
print("#%d %s %s" % e)
|
||||||
|
print("\n%d memories matched." % 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>'")
|
||||||
|
log = read_log(d)
|
||||||
|
n = len(log)
|
||||||
|
last = log[-1][1] if log 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 is older than the previous memory (%s). "
|
||||||
|
"Memories must be in order." % (i, date, last))
|
||||||
|
text = text.strip()
|
||||||
|
if not text or len(text) > ENTRY_CHARS:
|
||||||
|
die("line %d: %d chars (limit %d)." % (i, len(text), ENTRY_CHARS))
|
||||||
|
out.append("#%d %s %s" % (n + len(out), date, text))
|
||||||
|
last = date
|
||||||
|
append(d, "LOG.txt", out)
|
||||||
|
print("imported %d memories (#%d..#%d)." % (len(out), n, n + len(out) - 1))
|
||||||
|
todo = pending(read_log(d), read_tree(d))
|
||||||
|
if todo:
|
||||||
|
print("%d compressions are now pending. Run `memo sleep` until it "
|
||||||
|
"says you woke up." % len(todo))
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
161
test.py
Executable file
161
test.py
Executable file
|
|
@ -0,0 +1,161 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OptMem invariants, checked against a synthetic life of 5000 memories.
|
||||||
|
|
||||||
|
Uses a fake compressor (join + truncate) so the run is deterministic and free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
from blocks import complete, cover # noqa: E402
|
||||||
|
|
||||||
|
N = 2000
|
||||||
|
WAKE_LINES = 320
|
||||||
|
ok, fail = 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global ok, fail
|
||||||
|
if cond:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
print("FAIL: " + msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- pure block math -------------------------------------------------
|
||||||
|
|
||||||
|
for T in list(range(1, 400)) + [1000, 4096, 10000, 65536, 100003]:
|
||||||
|
c = cover(T, WAKE_LINES)
|
||||||
|
check(len(c) <= WAKE_LINES, "T=%d: %d lines > budget" % (T, len(c)))
|
||||||
|
check(c[0][0] == 0 and c[-1][1] == T, "T=%d: does not span [0,T)" % T)
|
||||||
|
for a, b in zip(c, c[1:]):
|
||||||
|
check(a[1] == b[0], "T=%d: gap or overlap at %s %s" % (T, a, b))
|
||||||
|
for lo, hi in c:
|
||||||
|
s = hi - lo
|
||||||
|
check(s & (s - 1) == 0 and lo % s == 0,
|
||||||
|
"T=%d: [%d,%d) is not an aligned power-of-two block" % (T, lo, hi))
|
||||||
|
for a, b in zip(c, c[1:]):
|
||||||
|
check(b[1] - b[0] <= a[1] - a[0],
|
||||||
|
"T=%d: detail does not increase toward the present" % T)
|
||||||
|
|
||||||
|
check(cover(300, 320) == [(i, i + 1) for i in range(300)],
|
||||||
|
"under budget, memory should be verbatim")
|
||||||
|
|
||||||
|
# every block a cover ever needs must be buildable
|
||||||
|
seen = set()
|
||||||
|
for T in range(1, 3000):
|
||||||
|
seen.update(b for b in cover(T, WAKE_LINES) if b[1] - b[0] > 1)
|
||||||
|
buildable = set(complete(3000))
|
||||||
|
check(seen <= buildable, "a cover wants a block that complete() never yields")
|
||||||
|
|
||||||
|
# work never spikes: naps created by one new memory
|
||||||
|
worst, prev = 0, 0
|
||||||
|
for T in range(1, N):
|
||||||
|
cur = len(complete(T))
|
||||||
|
worst = max(worst, cur - prev)
|
||||||
|
prev = cur
|
||||||
|
check(worst <= 16, "a single memory created %d naps" % worst)
|
||||||
|
|
||||||
|
# ---- the real CLI ----------------------------------------------------
|
||||||
|
|
||||||
|
d = tempfile.mkdtemp(prefix="optmem-test-")
|
||||||
|
env = dict(os.environ, MEMORY_DIR=d)
|
||||||
|
memo = [sys.executable, os.path.join(HERE, "memo")]
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args):
|
||||||
|
return subprocess.run(memo + list(args), env=env, capture_output=True,
|
||||||
|
text=True)
|
||||||
|
|
||||||
|
|
||||||
|
r = run("note", "x" * 281)
|
||||||
|
check(r.returncode == 1 and "REJECTED" in r.stderr, "over-long note accepted")
|
||||||
|
r = run("note", "two\nlines")
|
||||||
|
check(r.returncode == 1 and "REJECTED" 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")
|
||||||
|
|
||||||
|
with open(os.path.join(d, "seed.txt"), "w") as f:
|
||||||
|
day = datetime.date(2020, 1, 1)
|
||||||
|
for i in range(N):
|
||||||
|
f.write("%s memory number %d, a thing that happened\n"
|
||||||
|
% ((day + datetime.timedelta(days=i // 5)).isoformat(), i))
|
||||||
|
r = run("import", os.path.join(d, "seed.txt"))
|
||||||
|
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")
|
||||||
|
|
||||||
|
# sleep loop, with a fake compressor
|
||||||
|
naps = 0
|
||||||
|
r = run("sleep")
|
||||||
|
while "You woke up" not in r.stdout:
|
||||||
|
line = [l for l in r.stdout.splitlines() if l.strip().startswith("memo sleep ")]
|
||||||
|
check(bool(line), "no command offered:\n" + r.stdout + r.stderr)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
bid = line[0].split()[2]
|
||||||
|
body = [l.strip() for l in r.stdout.splitlines()
|
||||||
|
if l.startswith(" #") or (l.startswith(" ") and l.strip()
|
||||||
|
and not l.strip().startswith("memo"))]
|
||||||
|
r = run("sleep", bid, (" ".join(body)[:280]).strip() or "empty")
|
||||||
|
check("REJECTED" not in r.stderr, "sleep rejected a valid nap: " + r.stderr)
|
||||||
|
naps += 1
|
||||||
|
check(naps == len(complete(N)), "did %d naps, expected %d" % (naps, len(complete(N))))
|
||||||
|
|
||||||
|
r = run("wake")
|
||||||
|
check(r.returncode == 0, "wake still refuses after a full sleep")
|
||||||
|
lines = r.stdout.strip().splitlines()
|
||||||
|
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[0].startswith("#0-"), "oldest line should be a summary block")
|
||||||
|
|
||||||
|
# append-only: nothing was ever rewritten
|
||||||
|
sizes = {f: os.path.getsize(os.path.join(d, f)) for f in ("LOG.txt", "TREE.txt")}
|
||||||
|
run("note", "one more thing happened today")
|
||||||
|
for f, s in sizes.items():
|
||||||
|
check(os.path.getsize(os.path.join(d, f)) >= s, "%s shrank" % f)
|
||||||
|
tree = open(os.path.join(d, "TREE.txt")).read().splitlines()
|
||||||
|
check(len(tree) == len(set(l.split()[0] for l in tree)), "a block was written twice")
|
||||||
|
|
||||||
|
# writing a block twice is refused, not duplicated
|
||||||
|
r = run("sleep", tree[0].split()[0], "attempted overwrite")
|
||||||
|
check("Already dreamt" in r.stdout, "rewriting a block was allowed")
|
||||||
|
|
||||||
|
# recall reaches memories the summaries lost
|
||||||
|
r = run("recall", "memory number 7,")
|
||||||
|
check(r.returncode == 0 and "#7 " in r.stdout, "recall missed a memory")
|
||||||
|
|
||||||
|
# a wrong summary can be dropped, with everything built on top of it
|
||||||
|
before = len(open(os.path.join(d, "TREE.txt")).read().splitlines())
|
||||||
|
logsize = 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)
|
||||||
|
gone = set(open(os.path.join(d, "TREE.txt")).read().splitlines())
|
||||||
|
check(not any(l.startswith("16-32 ") for l in gone), "forgotten block still present")
|
||||||
|
check(not any(l.startswith("0-64 ") for l in gone), "an ancestor survived a forget")
|
||||||
|
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")
|
||||||
|
n = 0
|
||||||
|
while "You woke up" not in run("sleep").stdout:
|
||||||
|
r = run("sleep")
|
||||||
|
bid = [l for l in r.stdout.splitlines() if l.strip().startswith("memo sleep ")][0].split()[2]
|
||||||
|
run("sleep", bid, "rebuilt after forget")
|
||||||
|
n += 1
|
||||||
|
check(n == before - len(gone), "rebuilt %d blocks, forgot %d" % (n, before - len(gone)))
|
||||||
|
check(run("wake").returncode == 0, "wake still refuses after rebuilding")
|
||||||
|
r = run("forget", "999999-1000000")
|
||||||
|
check(r.returncode == 1, "forgetting a nonexistent block should fail")
|
||||||
|
|
||||||
|
shutil.rmtree(d)
|
||||||
|
print("\n%d passed, %d failed" % (ok, fail))
|
||||||
|
sys.exit(1 if fail else 0)
|
||||||
Loading…
Reference in a new issue