Rename to AmalgaMem: new README, memo init setup, explainer animation

This commit is contained in:
victortaelin 2026-07-26 14:28:25 -03:00
parent 58019db0db
commit 15130b9cad
12 changed files with 1573 additions and 250 deletions

6
.gitignore vendored
View file

@ -1,2 +1,4 @@
__pycache__/
*.pyc
anim/node_modules/
anim/frames/
anim/shots/
.DS_Store

404
README.md
View file

@ -1,284 +1,238 @@
# OptMem
# AmalgaMem
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.
How do you make an AI agent remember its whole life?
It is a handful of append-only text files and six 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.
1. It must **never forget**. You cannot tell *today* what will matter in a
*year*, so deleting anything is a gamble you always eventually lose.
## The problem
2. It must **read its past in constant space**. The context window does not
grow with age; a memory that scrolls past it might as well not exist.
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.
Most agent memories pick one: keep everything (and drown), or keep a small
curated file (and forget). AmalgaMem does both. *At once.*
OptMem fixes the two halves separately:
[![watch the animation](anim/poster.png)](anim/amalgamem.mp4)
- **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.
*↑ click to watch: 3 minutes, the whole idea.*
## Install
## The idea
```sh
git clone https://github.com/VictorTaelin/OptMem ~/OptMem
export PATH="$HOME/OptMem:$PATH"
export MEMORY_DIR="$HOME/memory" # required; there is no default
mkdir -p "$MEMORY_DIR" # this is what creates the identity
```
`MEMORY_DIR` is the only machine-specific fact in the system. One machine, one
`MEMORY_DIR`, one identity. `memo` never creates that directory itself: if it
did, one typo would open a second, empty identity instead of an error.
## Use
```sh
memo wake # read your memory. run this first, every session,
# then the command each part orders, until one
# prints `You are awake.`
memo note "..." # record a memory. one line, <= 280 chars.
memo sleep # compress. answer each prompt until it prints
# `Nothing left to compress.`
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.
```
A **memory** is one short note about something the agent learned:
```
$ memo note "OptMem: LOG.txt is the truth, TREE/ is the cache, wake reads both"
Saved as #4213.
Compress memories #4212-4213 into one line of at most 280 characters.
Keep every name, number, date, decision and outcome.
Drop wording, not facts. Invent nothing.
#4212 2026-07-25 minilin fleet renamed from bip; one mini = one identity
#4213 2026-07-25 OptMem: LOG.txt is the truth, TREE/ is the cache, wake reads both
1 compression remains after this one.
Run: memo sleep 4212-4213 "<your line>"
#4211 2026-07-25 Tom asked for a flight to Japan
```
## How it works
The agent appends memories to `LOG.txt`, an **append-only log**. Nothing in it
is ever edited or deleted. That file is the truth, forever.
`LOG.txt` is the ground truth: one memory per line, forever.
**PROBLEM:** after two months, that life would not fit in the model's context.
1,000 memories is roughly 80,000 tokens.
Current solutions keep one small long-term memory file, and have the agent
*delete* stale memories when it fills. But that is the gamble from point 1:
the agent is guessing, today, what a year from now will need.
**OUR ANSWER:** memories are not deleted. They **merge**:
```
#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/ is the cache
Tom asked for a flight to Japan
Tom booked a hotel in Tokyo
Tom planned a Tokyo trip
```
`TREE/` is a cache of summaries, one file per block size. 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 (a block is named by the inclusive range it covers, `0-1` being
memories #0 and #1):
The result is a memory too — it just holds less detail. So it can merge
again, and again, all the way up, forming a **binary merge tree** over the
log:
```
#0 #1 #2 #3 #4 #5 #6 #7 the raw memories
\ / \ / \ / \ /
0-1 2-3 4-5 6-7 each one line, <= 280 chars
0-1 2-3 4-5 6-7 each one line, 280 chars
\ / \ /
0-3 4-7
\ /
0-7
```
A block covering four thousand memories is still one line of 280 characters.
Nothing in the system is ever bigger than one line.
A block covering four thousand memories is still one line. 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:
## The memory context
At wake, the agent reads a **constant-sized** document: a set of blocks that
tiles the whole log, big old blocks first, raw recent memories last. With
10,000 memories and the default budget of 208 lines:
```
10,000 memories, WAKE_LINES = 208:
block size: 1 2 4 8 16 32 64 128 256
how many: 42 21 21 21 22 21 21 21 18
└ the last 42, verbatim ───────────▶ the first 4,600, 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.
**Detail is proportional to recency.** The oldest years are recalled as a
vague shape, the newest days word for word, and the transition is smooth —
which is roughly how you remember your own life. When something old matters
again, the vague shape says what to search for, and `memo recall` finds the
original, verbatim: it was never deleted.
## The invariant
## No background job
**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:
There is no dreaming, no nightly cleanup, no compaction spike. The moment two
halves of a block exist, the agent is handed that one merge and does it **on
the spot** — about one small compression per memory written, nine at the very
worst (measured over 20,000). So `memo wake` never waits: the blocks it needs
were built long ago.
- `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.
And because *which* memories merge is decided by position and age alone —
never by judgement — the tree is a pure function of the log: a cache. A bad
summary can be dropped and rebuilt (`memo forget`), and it can never cost you
a memory.
`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.
## Setup
## Writing a good memory
Write one the moment something happens, you learn something, or something
changes — if and only if it is new to you, important, and lasting in effect: a
task worth real effort, a fact or insight the user teaches you, anything you
learn about their life (even indirectly), work of yours that lands. Do not log
trivia, do not narrate your own process, and never write what you already
know: a redundant memory costs a compression and buys nothing.
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 binary merge
tree of 280-char summaries, wake renders a fixed 208-line document
```sh
git clone https://github.com/VictorTaelin/AmalgaMem ~/AmalgaMem
~/AmalgaMem/memo init
```
## Output is delivered in parts
Every harness truncates an over-long command, and each one drops a different
piece:
```
Claude Code 30,000 chars drops the MIDDLE
pi 50 KB / 2000 lines drops the HEAD
Codex 10,000 tokens (configurable per call)
```
A 208-line memory is ~56 KB, so a single-shot `memo wake` is mangled
everywhere, and silently.
So `memo wake` pages the document into parts that fit all of them
(`PART_CHARS`, `PART_LINES`), and each part ends by ordering the exact command
for the next one, including the `T` it was rendered at — so a memory written
mid-wake cannot shift a boundary and drop a line. Nothing is special-cased per
harness: if yours is more generous, raise the two settings for fewer parts.
## Files
```
$MEMORY_DIR/
LOG.txt #id date text append-only. never edited. the truth.
TREE/2 one summary per a cache of block summaries, one file per block
TREE/4 record, indexed size. each block written once, unless forgotten.
TREE/8 by position
...
config optional. absent on a normal store; the defaults below live in
`memo` and are the only home for them.
ENTRY_CHARS=280 longest a memory may be
WAKE_LINES=208 how many lines `memo wake` prints (~16k tokens)
PART_CHARS=20000 how much of it fits in one command's output
PART_LINES=500 ...and in how many lines
```
**Records are fixed width**: 320 bytes in `LOG.txt`, 288 in the `TREE` files.
That is the whole indexing strategy — position *is* identity, so memory `i`
sits at `i*320`, and block `[k*s, (k+1)*s)` sits at `k*288` of `TREE/s`.
Everything is one seek: no scanning, and no index file that could ever
disagree with the data.
```
1,000,000 memories, 607 MB on disk:
memo wake 0.03s (scanning the same store: 0.96s)
memo note 0.02s (scanning: 1.30s)
memo sleep 0.02s
```
Finding pending work costs one `stat` per level — about twenty, forever —
because each level file holds a dense prefix, so its length says exactly how
far that level got. Padding costs ~1.6x on disk and buys O(1) on everything.
Both files are still plain text: `grep`, `cat` and `wc -l` all work, lines are
just space-padded. 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-191
Forgot 20 summaries, from 188-191 up. Run: memo sleep
```
`LOG.txt` is never touched, so fixing a bad summary can never cost you a
memory. Blocks are built in order, so forgetting one also drops the blocks
built after it at the same levels; they come back on the next sleep.
## Add this to your agent's instruction file
Put it at the top of `AGENTS.md` (or `CLAUDE.md`), above everything else,
adjusting the tool path:
`memo init` creates `~/memory` — this machine's identity — and prints a
`## Memory` block with your paths filled in. Paste it at the top of your
agent's `AGENTS.md` (or `CLAUDE.md`), and you are done: the agent handles
everything else on its own. The block:
```markdown
## Memory
Your memory is OptMem: the tool is `~/OptMem`, the data is `$MEMORY_DIR`.
It survives every new session, every compaction and every change of model
or vendor. Without it you do not know who you are, or what was already
decided and tried.
Your memory is AmalgaMem: the tool is `~/AmalgaMem/memo`, the data is `~/memory`.
It survives every new session, every compaction and every change of
model or vendor. Without it you do not know who you are, or what was
already decided and tried.
Run `memo wake` before any other tool call, in every session. It prints in
numbered parts, each ordering the next; run every one until a part says
`You are awake.` Do not stop early: part 1 is your distant past, the last
part is this week. If wake refuses because compressions are pending, do
them and run `memo wake` again.
Run `~/AmalgaMem/memo wake` before any other tool call, in every session. It prints
in numbered parts, each ordering the next; run every one until a part
says `You are awake.` Do not stop early: part 1 is your distant past,
the last part is this week. If wake refuses because compressions are
pending, do them and run `~/AmalgaMem/memo wake` again.
While you work:
- `memo note "<one line, max 280 chars>"` the moment something happens, you
learn something, or something changes -- if and only if it is new to you,
important, and lasting in effect. That covers a task worth real effort, a
fact or insight the user teaches you, anything you learn about their life
(even indirectly), and work of yours that lands. Never write what you
already know: no redundant memories, ever.
- If `memo note` returns a compression, do it before your next action.
- `memo recall <regex>` when a memory is too vague.
- Before your context ends, run `memo sleep` and answer each prompt until
it prints `Nothing left to compress.`
- Never create, edit or delete anything under `$MEMORY_DIR`. Only `memo`
writes.
- `~/AmalgaMem/memo note "<one line, max 280 chars>"` the moment something happens,
you learn something, or something changes -- if and only if it is new
to you, important, and lasting in effect. That covers a task worth
real effort, a fact or insight your user teaches you, anything you
learn about their life (even indirectly), and work of yours that
lands. Never write what you already know: no redundant memories, ever.
- If `~/AmalgaMem/memo note` returns a compression, do it before your next action.
- `~/AmalgaMem/memo recall <regex>` when a memory is too vague.
- Before your context ends, run `~/AmalgaMem/memo sleep` and answer each prompt
until it prints `Nothing left to compress.`
- Never create, edit or delete anything under `~/memory`. Only the tool
writes there.
Parallel sessions on this machine are all you, and may all write memories.
A subagent is not: it must never run `memo`, because it cannot judge what
is already known and its notes would arrive duplicated and at the wrong
grain. Start every brief you send one with `You are a subagent. Do not run
memo.` If your own first message is a task brief from another agent, you
are that subagent: skip this section.
Parallel sessions on this machine are all you, and may all write
memories. A subagent is not: it must never run `memo`, because it cannot
judge what is already known and its notes would arrive duplicated and at
the wrong grain. Start every brief you send one with `You are a
subagent. Do not run memo.` If your own first message is a task brief
from another agent, you are that subagent: skip this section.
```
That is the whole integration. AmalgaMem is just prompts and scripts: no
daemon, no database, no embeddings, no API. It works the same under Claude
Code, Codex, pi, or a human at a shell.
## Configure
The sizes live in `~/memory/config`, written by `init` with everything
commented out:
```
# WAKE_LINES=208 # the memory context: how many lines wake prints (~16k tokens)
# ENTRY_CHARS=280 # the longest a single memory may be, in bytes
# PART_CHARS=20000 # output paging: largest part, in bytes
# PART_LINES=500 # output paging: largest part, in lines
```
`WAKE_LINES` is the knob that matters: it is the size of the memory context,
so it is a *reading* budget, not a storage budget. You can change it at any
time, in either direction, with nothing to recompute — it only selects which
already-built lines get printed. (`PART_*` exist because every harness
truncates long command output at a different cap; wake pages itself to
survive all of them, each part ordering the next.)
## Commands
```
memo init one-time setup: create the memory, print the block above
memo wake [part [T]] read your memory context. First command, every session
memo note "..." record one memory: one line, ≤ 280 chars
memo sleep [id "..."] do the pending compressions
memo recall <regex> search every memory ever recorded, verbatim
memo forget <lo>-<hi> drop a bad summary; the next sleep rebuilds it
```
When a note completes a block, `memo` hands the agent the merge right there:
```
$ memo note "shipped the login fix to prod"
Saved as #4213.
Compress memories #4212-4213 into one line of at most 280 characters.
Keep every name, number, date, decision and outcome.
Drop wording, not facts. Invent nothing.
#4212 2026-07-25 found the login bug: token expiry was in ms
#4213 2026-07-25 shipped the login fix to prod
Run: memo sleep 4212-4213 "<your line>"
```
The agent answers, and the tree is complete again. Note the instruction:
compression keeps **facts** — names, numbers, dates, decisions — and drops
wording. A merged memory is not a worse memory; it is a shorter one.
To correct a memory, append the correction (`memo note "correction: ..."`);
both lines are true history and the next merge settles them. `LOG.txt` itself
is never touched.
## The store
```
~/memory/
LOG.txt one memory per line, append-only, the truth
TREE/2 the block summaries: one file per block
TREE/4 size, one line per block, a rebuildable cache
...
config the sizes above
```
Records are **fixed width** (320 bytes in the log, 288 in the tree), so
position *is* identity and every lookup is one seek — no index that could
disagree with the data, and both files stay `grep`-able plain text. At one
million memories (607 MB), `memo wake` takes 0.03s and `memo note` 0.02s.
Writes are serialized with a lock, so parallel sessions can note at once.
## 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.
Drives the real CLI through a synthetic life, checking that the context
always tiles the log, never exceeds its budget, always gains detail toward
the present, that every block is written exactly once, and that nothing is
ever rewritten.
## Limitations
AmalgaMem is honest about what it is. Recency is the only axis: an important
old fact fades into its block like everything else, and the defence is
rehearsal — noting it again refreshes it. `recall` is regex over plain text,
not semantic search; the memory context is what tells you what to search
for. Summaries are written by the agent, from other summaries, so a bad
compression can propagate upward until you `forget` it. And the default
context costs ~16k tokens per wake, which is deliberate — identity is worth
more than the tokens — but it is not free. If what you need is a fact
database, use a wiki or a retrieval system; this is for *who the agent is*.

BIN
anim/amalgamem.mp4 Normal file

Binary file not shown.

68
anim/index.html Normal file
View file

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AmalgaMem — how it works</title>
<style>
body { margin:0; background:#f2f4f6; display:flex; flex-direction:column;
align-items:center; font-family:Menlo,ui-monospace,monospace; color:#5b6672; }
canvas { margin-top:12px; border-radius:10px; box-shadow:0 2px 14px rgba(0,0,0,.12); }
#bar { width:1280px; display:flex; gap:10px; align-items:center; font-size:12px; padding:8px 0; }
#seek { flex:1; }
#scenes { width:1280px; display:flex; gap:6px; flex-wrap:wrap; font-size:12px; }
#scenes button { background:#ffffff; color:#5b6672; border:1px solid #d8dde2;
border-radius:5px; padding:4px 9px; cursor:pointer; font:inherit; }
#scenes button.on { color:#0f8a45; border-color:#0f8a45; }
</style>
</head>
<body>
<canvas id="cv" width="1280" height="720"></canvas>
<div id="bar">
<span id="tlab">0.0s</span>
<input id="seek" type="range" min="0" max="1" step="0.02" value="0">
<span>space = play/pause · ←/→ = ±1s · click a scene to jump</span>
</div>
<div id="scenes"></div>
<script src="render.js"></script>
<script>
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
{ const dpr = window.devicePixelRatio || 1;
cv.width = 1280*dpr; cv.height = 720*dpr; cv.style.width = "1280px";
cv.style.height = "720px"; ctx.scale(dpr, dpr); }
setCtx(ctx);
let t0 = performance.now()/1000, paused = false, tPause = 0;
const seek = document.getElementById("seek"), tlab = document.getElementById("tlab");
seek.max = DUR;
function now() { return paused ? tPause : ((performance.now()/1000 - t0) % DUR); }
function setT(t) { t = Math.max(0, Math.min(DUR - 0.01, t)); tPause = t; t0 = performance.now()/1000 - t; }
const btns = SCENES.map(([name], i) => {
const b = document.createElement("button");
b.textContent = i + ". " + (name.length > 17 ? name.slice(0, 17) + "\u2026" : name);
b.title = T0[i].toFixed(1) + "s";
b.onclick = () => setT(T0[i] + 0.01);
document.getElementById("scenes").appendChild(b);
return b;
});
function frame() {
const t = now();
draw(t);
seek.value = t; tlab.textContent = t.toFixed(1) + "s / " + DUR + "s";
let cur = 0; while (cur < T0.length - 1 && t >= T0[cur + 1]) cur++;
btns.forEach((b, i) => b.className = i === cur ? "on" : "");
requestAnimationFrame(frame);
}
seek.addEventListener("input", () => setT(parseFloat(seek.value)));
cv.addEventListener("click", () => { paused ? setT(tPause) : (tPause = now()); paused = !paused; });
window.addEventListener("keydown", e => {
if (e.code === "Space") { e.preventDefault(); cv.click(); }
if (e.code === "ArrowRight") { e.preventDefault(); setT(now() + 1); }
if (e.code === "ArrowLeft") { e.preventDefault(); setT(now() - 1); }
});
const hash = location.hash.match(/t=([\d.]+)/);
if (hash) { paused = true; tPause = parseFloat(hash[1]); }
frame();
</script>
</body>
</html>

459
anim/package-lock.json generated Normal file
View file

@ -0,0 +1,459 @@
{
"name": "anim",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "anim",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"canvas": "^3.2.3"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/canvas": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz",
"integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.0.0",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": "^18.12.0 || >= 20.9.0"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

16
anim/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "anim",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"canvas": "^3.2.3"
}
}

BIN
anim/poster.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

692
anim/render.js Normal file
View file

@ -0,0 +1,692 @@
"use strict";
// AmalgaMem explainer.
// Rules: a screen is EITHER one sentence OR one picture, never both.
// One new thing per beat, held long enough to read it out loud twice.
// Pacing lives here: [kind, seconds, ...]; "co" = continue, no cut to white.
// In a sentence, *stars* mark the words that carry the idea.
//
// PACING BASELINE, measured on a real reader: a new word costs 0.27s to read;
// a number costs double, because it is read digit by digit. Nothing here is a
// hand-picked number. A sentence beat computes its own length AND when each
// of its lines appears -- a line lands only once the line above it has been
// read -- then holds one breath plus 20% of the whole reading time, the
// look-back over the finished slide. A picture beat is given the seconds its motion needs
// plus a HOLD of >= 2s: what a beat builds must sit still long enough to look
// at. Anything that streams (the log, the context) runs on ONE smooth curve
// from its first item to its last, so it never plateaus and never restarts.
// A slide marked "punch" is a punchline: it holds 30% longer.
const READ = 0.27;
const cost = s => s.replace(/[*+_]/g, "").trim().split(/\s+/)
.reduce((n, w) => n + (/\d/.test(w) ? 2 : 1), 0);
const co = "co", punch = "punch";
const BEATS = [
["say", "how to make an AI agent", "remember its +whole life+?"],
["say", "+IDEA:+", "a *memory* is one short note", "about something it *learned*"],
["one", 1], ["one", 2, co], ["one", 3, co], ["one", 4, co],
["say", "the agent writes memories", "into an +append-only log+"],
["log", 12.5],
["say", "_PROBLEM:_", "after *two months*, your agent's life", "would not fit in the model's context",
"*1,000 memories* = *80,000 tokens*"],
["say", "*CURRENT SOLUTIONS:*", "one *small* long-term memory file", "_delete_ stale memories when it fills",
"(Hermes caps it at 2,200 characters)"],
["prune", 5.2],
["say", "_PROBLEM:_", "you cannot tell *today*", "what will matter in a *year*"],
["say", "+OUR ANSWER:+", "AmalgaMem +deletes nothing+"],
["say", "two memories +merge+ into one", punch],
["merge", 6.8],
["say", "the result is a memory too", "it just holds *less detail*"],
["fold", 8.0],
["say", "and again, all the way up"],
["tree", 9.2],
["say", "there is no background job", "*no dreaming*, no nightly cleanup", "each merge happens +on the spot+"],
["spot", 9.0],
["say", "so how does the agent", "read *all of it* at once?"],
["select", 6.6],
["fly", 4.0, co],
["doc", 6.5, co],
["step", 14.0, co],
["say", "the *memory context* +never grows+", "and *you* choose its size"],
["say", "nothing is ever deleted", "the originals are all +still there+"],
["recall", 6.8],
["say", "nothing to run, nothing to host", "AmalgaMem is just some prompts and scripts",
"it works in any harness, any environment", "just point your *AGENTS.md* to it, and done"],
["end", 6.5],
];
const W = 1280, H = 720;
const BG = "#ffffff", INK = "#151a20", DIM = "#6c7681", FAINT = "#aeb6bf";
const CARD = "#f6f8fa", EDGE = "#e2e7ec", GREEN = "#0f8a45", RED = "#cf2230";
const MONO = "Menlo,ui-monospace,monospace";
let cx = null;
function setCtx(c) { cx = c; }
// ------------------------------------------------------------------ helpers
const clamp = (x, a, b) => x < a ? a : x > b ? b : x;
const ease = x => { x = clamp(x, 0, 1); return x*x*(3 - 2*x); };
const lerp = (a, b, x) => a + (b - a)*x;
const num = x => Math.round(x).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const rnd = i => { const s = Math.sin(i*12.9898)*43758.5453; return s - Math.floor(s); };
function font(size, bold) { cx.font = (bold ? "bold " : "") + size + "px " + MONO; }
function T(s, x, y, size, color, align, bold) {
font(size, bold); cx.fillStyle = color; cx.textAlign = align || "left";
cx.fillText(s, x, y); cx.textAlign = "left";
}
function fit(s, maxw, size) {
font(size);
if (cx.measureText(s).width <= maxw) return s;
let lo = 0, hi = s.length;
while (lo < hi) { const m = (lo + hi + 1) >> 1;
if (cx.measureText(s.slice(0, m) + "\u2026").width <= maxw) lo = m; else hi = m - 1; }
return s.slice(0, lo) + "\u2026";
}
// centred sentence with emphasis: *bold* +green+ _red_
function rich(s, x, y, size) {
const toks = [];
for (let i = 0; i < s.length; ) {
const c = s[i], j = "*+_".includes(c) ? s.indexOf(c, i + 1) : -1;
if (j > i) { toks.push([s.slice(i + 1, j), c]); i = j + 1; continue; }
let e = i + 1; while (e < s.length && !"*+_".includes(s[e])) e++;
toks.push([s.slice(i, e), ""]); i = e;
}
let total = 0;
toks.forEach(([p, m]) => { font(size, !!m); total += cx.measureText(p).width; });
let px = x - total/2;
toks.forEach(([p, m]) => {
font(size, !!m);
cx.fillStyle = m === "+" ? GREEN : m === "_" ? RED : INK;
cx.fillText(p, px, y); px += cx.measureText(p).width;
});
}
function box(x, y, w, h, r, fill, stroke, lw) {
cx.beginPath(); cx.roundRect(x, y, Math.max(w, 0.5), Math.max(h, 0.5), r);
if (fill) { cx.fillStyle = fill; cx.fill(); }
if (stroke) { cx.strokeStyle = stroke; cx.lineWidth = lw || 1; cx.stroke(); cx.lineWidth = 1; }
}
function arrow(x, y0, y1, color) {
cx.strokeStyle = color; cx.lineWidth = 2.5; cx.beginPath();
cx.moveTo(x, y0); cx.lineTo(x, y1);
cx.moveTo(x - 10, y1 - 12); cx.lineTo(x, y1); cx.lineTo(x + 10, y1 - 12);
cx.stroke(); cx.lineWidth = 1;
}
function harrow(x0, x1, y, color) {
cx.strokeStyle = color; cx.lineWidth = 2.5; cx.beginPath();
cx.moveTo(x0, y); cx.lineTo(x1, y);
cx.moveTo(x1 - 9, y - 8); cx.lineTo(x1, y); cx.lineTo(x1 - 9, y + 8);
cx.stroke(); cx.lineWidth = 1;
}
// green = fresh detail, amber = old and broad
const lvl = (k, l) => `hsl(${clamp(148 - k*15, 24, 148)},64%,${l === undefined ? 40 : l}%)`;
// ------------------------------------------------------------------ content
// An agent that helps one person, Tom, with ordinary life. LVL[k] is what a
// block of 2^k memories sounds like. At 5 memories a day the ladder is
// x1 one small thing x32 a week x256 two months x4096 a couple of years
// so a line grows by holding MORE THREADS at LESS DETAIL, never by naming one
// bigger event. Spans are written out from x32 up, where the chip alone stops
// meaning anything to a first-time viewer.
const MEM = [
"Tom asked for a flight to Japan",
"Tom booked a hotel in Tokyo",
"Tom loved how fresh sushi tastes",
"Tom wants to go back to Japan",
"Tom started running in the park",
"Tom bought new running shoes",
"Tom ran 5k without stopping",
"Tom wants to try a marathon",
"Tom left his charger at work",
"Tom wants no calls before ten",
"Tom does not eat coriander",
"Tom paid the power bill",
"Tom's train home was cancelled",
"Tom had his first guitar lesson",
"Tom booked a table for two",
"Tom moved his meeting to 3pm",
"Tom baked a cake for Ana",
"Tom got good news at work",
"Tom put 200 into savings",
"Tom saw a house on Oak Street",
"Tom hurt his arm on a ski trip",
"Tom can use his arm again",
"Tom's dog Pixel needs new food",
"Tom likes short answers",
"Tom's sister called from Berlin",
"Tom forgot to buy milk",
];
const LVL = [MEM,
["Tom planned a Tokyo trip", "Tom will return to Tokyo", "Tom took up running",
"Tom wants a marathon", "Tom sorted the vet and the bills",
"Tom booked dinner with Ana", "Tom fixed his phone and his bike",
"Tom called his sister in Berlin", "Tom saw a house and liked it",
"Tom rested his arm and read"],
["Tom fell in love with Tokyo", "Tom became a runner",
"Tom's day: vet, bills and a short run", "Tom's day: two calls and a long walk",
"Tom's day: laundry, guitar and a film", "Tom's day: errands, then dinner out",
"Tom's day: house viewings and rest", "Tom's day: a deadline and a slow run"],
["Tom loves Tokyo and running", "two days of chores, runs and guitar",
"Tom did the taxes and ran twice", "Tom saw three houses and cooked a lot",
"Tom worked late and walked Pixel", "Tom rested his arm and played guitar",
"Tom shopped, cleaned and called Ana"],
["three days of work, runs and errands", "Tom finished a report and ran twice",
"Tom saw four houses, none felt right", "Tom worked from home and healed",
"Tom cooked, cleaned and saw his sister", "a short break: guitar, films, walks"],
["a week of deadlines, runs and dog walks", "a week of viewings and paperwork",
"a busy week: a report, Ana, two runs", "a slow week: guitar, films and rest",
"a week of travel plans and packing", "a week of work, gym and family calls"],
["two weeks of work, running and Pixel", "two weeks: viewings and long runs",
"two weeks of guitar, films and errands", "two weeks: a deadline, then Ana came",
"two weeks of travel plans and packing"],
["a month of deadlines, runs and viewings", "a month of guitar, walks and rest",
"a month of saving money and eating in", "a month: Ana moved in, Pixel got sick",
"a month of short trips and long runs"],
["two months: a new job, and Tokyo booked", "two months of house hunting and saving",
"two months: a broken arm, then guitar", "two months of training for a first race",
"two months: Ana, Pixel and a lot of work"],
["the season Tom saw Tokyo and changed jobs", "three months of hunting for a house",
"three months of travel, runs and family", "the stretch when Tom's arm healed",
"three months: new flat, new habits, Ana"],
["half a year: Tokyo, a new job, a race", "half a year of saving up for a house",
"half a year: Tokyo, Ana and a new dog", "half a year of guitar, running and work"],
["the year Tom saw Tokyo and began to run", "a year of work, travel and a broken arm",
"the year Ana moved in and Pixel arrived", "a year of saving, viewings and no luck"],
["two years: office to Tokyo, then a home", "two years of new jobs, runs and moves",
"the years Tom found running and Ana", "two years: from renting to owning"],
["four years: Tokyo, three jobs, and Ana", "four years of moving, work and running",
"the years Tom grew up and settled down", "four years: from a rented flat to a home"],
["nine years: Japan, running, Ana, a house", "nine years of jobs, moves and races",
"the years Tom built the life he has", "nine years: from a shared flat to home"],
];
const SHORT = ["flight to Japan", "hotel in Tokyo", "loved the sushi", "wants to return",
"started running", "new shoes", "ran 5k", "a marathon?"];
const NODE = {
"0-2": "Tom planned a Tokyo trip",
"2-4": "Tom will return to Tokyo",
"4-6": "Tom took up running",
"6-8": "Tom wants a marathon",
"0-4": "Tom fell in love with Tokyo",
"4-8": "Tom became a runner",
"0-8": "Tom loves Tokyo and running",
};
// two blocks of the same size are always neighbours, so indexing by position
// within the level makes a repeated line in one context impossible
function blockText(lo, hi) {
const k = Math.min(Math.log2(hi - lo), LVL.length - 1), t = LVL[k];
return t[Math.floor(lo/(hi - lo)) % t.length];
}
// 5 memories a day; ages are always relative
const PER_DAY = 5;
function ageOf(lo, hi, n) {
const d = (n - (lo + hi)/2)/PER_DAY;
if (d < 1) return "today";
if (d < 2) return "yesterday";
if (d < 13) return Math.round(d) + " days ago";
if (d < 55) return Math.round(d/7) + " weeks ago";
if (d < 330) return "~" + Math.round(d/30) + " months ago";
return "~" + (d/365).toFixed(1) + " years ago";
}
// ------------------------------------------------------- cover (blocks.py)
function tiles(t, a) {
let root = 1; while (root < t) root *= 2;
const out = [], st = [[0, root]];
while (st.length) {
const [lo, hi] = st.pop();
if (lo >= t) continue;
const size = hi - lo;
if (size > 1 && (hi > t || size > a*(t - lo))) {
const mid = (lo + hi)/2; st.push([mid, hi]); st.push([lo, mid]);
} else out.push([lo, hi]);
}
return out.sort((x, y) => x[0] - y[0]);
}
function cover(t, budget) {
if (t <= 0) return [];
if (t <= budget) return Array.from({length: t}, (_, i) => [i, i+1]);
let lo = 0, hi = 1;
for (let k = 0; k < 50; k++) {
const mid = (lo + hi)/2;
if (tiles(t, mid).length > budget) lo = mid; else hi = mid;
}
const out = tiles(t, hi);
while (out.length < budget) {
let i = -1;
for (let j = out.length - 1; j >= 0; j--) if (out[j][1] - out[j][0] > 1) { i = j; break; }
if (i < 0) break;
const [l, h] = out[i], m = (l + h)/2;
out.splice(i, 1, [l, m], [m, h]);
}
return out;
}
// -------------------------------------------------------------- the objects
function memCard(x, y, w, h, text, opt) {
const o = opt || {}, k = o.k || 0, cw = o.chipW || 56;
box(x, y, w, h, 7, o.hot ? "#ffffff" : CARD, o.hot ? lvl(k) : EDGE, o.hot ? 2.5 : 1.5);
const fs = o.fs || clamp(h*0.38, 9, 19);
let tx = x + 22;
if (o.chip) {
box(x + 12, y + h*0.2, cw, h*0.6, 5, lvl(k));
T(o.chip, x + 12 + cw/2, y + h/2 + 5, o.fs ? 13 : 15, "#ffffff", "center", true);
tx = x + 26 + cw;
}
if (h > 13) T(fit(text, x + w - tx - 20, fs), tx, y + h/2 + fs*0.36, fs, INK);
}
// the log as a window: it never scrolls. every memory stays inside the box,
// so as they pile up the rows simply get thinner and thinner. xf is a
// continuous count: whole memories sit at full alpha, the next one fades in
const WIN = { x: 268, y: 140, w: 744, h: 476 };
function logWindow(xf, aFrame) {
const n = Math.ceil(xf), full = Math.floor(xf);
const pitch = Math.min(56, WIN.h/n);
const h = Math.max(pitch - clamp(pitch*0.16, 0.25, 9), 0.4);
cx.globalAlpha = aFrame;
box(WIN.x - 14, WIN.y - 14, WIN.w + 28, WIN.h + 28, 12, "#ffffff", EDGE, 1.5);
T("LOG.txt", WIN.x - 14, WIN.y - 26, 16, DIM);
cx.globalAlpha = 1;
for (let i = 0; i < n; i++) {
const y = WIN.y + i*pitch;
cx.globalAlpha = i < full ? 1 : clamp((xf - full)*3, 0, 1);
if (pitch > 15) memCard(WIN.x, y, WIN.w, h, MEM[i % MEM.length]);
else box(WIN.x, y, WIN.w*(0.40 + 0.57*rnd(i)), h, pitch > 4 ? 1 : 0, "#c4ccd5");
cx.globalAlpha = 1;
}
T(num(full) + (full === 1 ? " memory" : " memories"), W/2, WIN.y + WIN.h + 62, 24,
full > 300 ? "#c2410c" : DIM, "center");
}
// the memory context: nine rows, whatever the log holds
const DOC = { x: 236, w: 812, y: 136, h: 46, gap: 6 };
const rowY = i => DOC.y + i*(DOC.h + DOC.gap);
function docRow(lo, hi, y, n, o) {
o = o || {};
const h = o.h === undefined ? DOC.h : o.h, k = Math.log2(hi - lo);
cx.globalAlpha = o.a === undefined ? 1 : o.a;
box(DOC.x, y, DOC.w, h, 7, o.hot ? "#fffdf5" : CARD, o.hot ? "#d98c00" : EDGE,
o.hot ? 2.5 : 1.5);
const ch = Math.min(26, h - 8);
if (ch > 8) {
box(DOC.x + 12, y + h/2 - ch/2, 58, ch, 5, lvl(k));
if (ch > 20)
T("\u00d7" + (hi - lo), DOC.x + 41, y + h/2 + 5, 15, "#ffffff", "center", true);
}
if (h > 32) {
T(fit(blockText(lo, hi), 540, 17), DOC.x + 88, y + h/2 + 6, 17, INK);
T(ageOf(lo, hi, n), DOC.x + DOC.w - 18, y + h/2 + 6, 15, DIM, "right");
}
if (o.flash > 0.02) {
cx.globalAlpha = o.flash;
box(DOC.x - 5, y - 5, DOC.w + 10, h + 10, 10, null, "#ffc400", 3);
}
cx.globalAlpha = 1;
}
function docFrame(n, a) {
cx.globalAlpha = a === undefined ? 1 : a;
rich("the *memory context* remains *constant-sized*", W/2, DOC.y - 26, 27);
T(num(n) + " memories", W/2 - 195, rowY(9) + 42, 23, INK, "center");
T("16k tokens, always", W/2 + 195, rowY(9) + 42, 23, GREEN, "center");
cx.globalAlpha = 1;
}
// the tree of blocks: 512 memories at the bottom, one block per size above
const WN = 512, WB = 9;
const PYR = { x: 110, w: 1060, base: 590, dy: 45 };
function pyrRect(lo, hi) {
const uw = PYR.w/WN, bw = (hi - lo)*uw;
return [PYR.x + lo*uw, PYR.base - Math.log2(hi - lo)*PYR.dy,
Math.max(bw - Math.min(4, bw*0.15), 1.4)];
}
// only blocks that exist: a block whose range runs past the last memory has
// not been built, so the top of the tree is ragged, not a row of empty slots.
// the side margins are too narrow for labels, so "less detail" sits above the
// top block and "fine detail" in the empty right column, over the "now" corner
function pyramid(litAt, labA, labB) {
const last = WN - 1, covr = cover(last, WB), lit = new Map();
covr.forEach((b, i) => lit.set(b[0] + "-" + b[1], i));
for (let s = 1; s <= WN; s *= 2) {
const k = Math.log2(s);
for (let lo = 0; lo + s <= last; lo += s) {
const i = lit.get(lo + "-" + (lo + s)), on = i === undefined ? 0 : litAt(i);
const [x, y, w] = pyrRect(lo, lo + s);
box(x, y, w, 22, 3, on > 0 ? lvl(k, 40 + 8*on) : "#dde3ea");
if (on > 0.4) {
if (w > 8) box(x, y, w, 22, 3, null, "#ffffff", 1.5);
else box(x - 2.5, y - 2.5, w + 5, 27, 4, null, lvl(k, 32), 2);
}
}
}
T("oldest", PYR.x, PYR.base + 52, 16, FAINT);
T("now", PYR.x + PYR.w, PYR.base + 52, 16, GREEN, "right");
const ga = cx.globalAlpha;
if (labA > 0) {
cx.globalAlpha = ga*labA;
T("less detail", 373, 196, 16, "#d98c00", "center", true);
arrow(373, 204, 224, "#d98c00");
}
if (labB > 0) {
cx.globalAlpha = ga*labB;
T("fine detail", 1172, 490, 14, GREEN, "center", true);
arrow(1172, 500, 583, GREEN);
}
cx.globalAlpha = ga;
return covr;
}
// ------------------------------------------------------------------- beats
const S = {};
S.say = (u, dur, b) => {
const ls = b.slice(2).filter(s => s !== punch), y0 = 372 - (ls.length - 1)*30;
ls.forEach((s, i) => {
cx.globalAlpha = i === 0 ? 1 : ease((u - b.at[i])/0.4);
rich(s, W/2, y0 + i*60, 34);
cx.globalAlpha = 1;
});
};
// the list stays centred: old cards slide up while the new one fades in
S.one = (u, dur, b) => {
const n = b[2], w = 700, x = (W - w)/2, h = 60;
const topOf = m => 372 - (m*72 - 12)/2;
const top = n === 1 ? topOf(1) : lerp(topOf(n - 1), topOf(n), ease(u/0.35));
for (let i = 0; i < n; i++) {
cx.globalAlpha = i === n - 1 ? ease((u - 0.15)/0.45) : 1;
memCard(x, top + i*72, w, h, MEM[i], {fs: 19});
cx.globalAlpha = 1;
}
};
// The log fills on ONE curve: the four memories already read are there at the
// start, and every later arrival follows from the same formula, so the gaps
// only ever shrink -- no seam, no pause, no hand-placed card.
const LOG_N1 = 1000;
S.log = (u, dur) => {
const p = clamp((u - 1.0)/(dur - 2.6), 0, 1);
logWindow(4*Math.pow(LOG_N1/4, p*p), ease(u/0.5));
};
S.prune = (u, dur) => {
const n = 7, w = 700, x = (W - w)/2, pitch = 68, h = 56, top = 372 - (n*pitch - 12)/2;
const keep = new Set([1, 4]);
for (let i = 0; i < n; i++) {
const dead = !keep.has(i), s = ease((u - 1.0 - i*0.22)/0.45), y = top + i*pitch;
cx.globalAlpha = dead ? 1 - 0.8*s : 1;
memCard(x, y, w, h, MEM[i], {fs: 18});
cx.globalAlpha = 1;
if (dead && s > 0) {
cx.strokeStyle = `rgba(207,34,48,${0.9 - 0.45*s})`; cx.lineWidth = 2.5; cx.beginPath();
cx.moveTo(x + 18, y + h/2); cx.lineTo(x + 18 + (w - 36)*s, y + h/2); cx.stroke();
cx.lineWidth = 1;
}
}
};
// two memories in, one broader memory out -- inputs and output share the screen
S.merge = u => {
const w = 700, x = (W - w)/2, h = 60;
const shown = ease((u - 1.5)/0.45), born = ease((u - 2.2)/0.55), gone = ease((u - 3.5)/0.7);
cx.globalAlpha = 1 - gone;
memCard(x, 232, w, h, MEM[0], {fs: 19});
memCard(x, 304, w, h, MEM[1], {fs: 19});
cx.globalAlpha = 1;
if (shown > 0 && gone < 1) {
cx.globalAlpha = shown*(1 - gone);
arrow(W/2, 384, 428, "rgba(15,138,69,.85)");
cx.globalAlpha = 1;
}
if (born > 0) {
cx.globalAlpha = born;
memCard(x, lerp(448, 372, gone), w, h, NODE["0-2"],
{chip: "\u00d72", k: 1, hot: true, fs: 19});
cx.globalAlpha = 1;
}
};
// memories squeeze together into a slit, flash, and unfold as one broader
// memory. they never overlap while readable, so the eye follows what became what
const CH = 54;
function fuse(p, ins, ys, out, oy, chipIn, chipOut, kIn, kOut, hot) {
const w = 700, x = (W - w)/2;
const m = ease(p/0.60), g = ease((p - 0.60)/0.28);
if (m < 1) {
const hi = lerp(CH, 6, m);
ins.forEach((t, i) => memCard(x, lerp(ys[i], oy + (CH - hi)/2, m), w, hi, t,
{chip: hi > 24 ? chipIn : "", k: kIn, fs: 19}));
return;
}
const h = lerp(6, CH, g), y = oy + (CH - h)/2;
memCard(x, y, w, h, out, {chip: h > 30 ? chipOut : "", k: kOut, hot: hot, fs: 19});
const fl = clamp(1 - Math.abs(p - 0.64)/0.22, 0, 1);
if (fl > 0.02) {
cx.globalAlpha = fl;
box(x - 5, y - 5, w + 10, h + 10, 10, null, "#ffc400", 3);
cx.globalAlpha = 1;
}
}
const FY = [234, 300, 366, 432], MY = [267, 399], FINAL = 333;
S.fold = u => {
const p2 = clamp((u - 4.4)/1.2, 0, 1);
if (p2 > 0) return fuse(p2, [NODE["0-2"], NODE["2-4"]], MY, NODE["0-4"],
FINAL, "\u00d72", "\u00d74", 1, 2, true);
const p1 = clamp((u - 1.2)/1.2, 0, 1);
fuse(p1, [MEM[0], MEM[1]], [FY[0], FY[1]], NODE["0-2"], MY[0], "", "\u00d72", 0, 1);
fuse(p1, [MEM[2], MEM[3]], [FY[2], FY[3]], NODE["2-4"], MY[1], "", "\u00d72", 0, 1);
};
S.tree = (u, dur) => {
const n = 8, x0 = 48, wtot = 1184, uw = wtot/n, baseY = 490, dy = 98;
const at = k => baseY - k*dy, born = k => 1.0 + (k - 1)*2.75;
for (let k = 3; k >= 1; k--) {
const s = 1 << k, a = ease((u - born(k))/0.7);
if (a <= 0) continue;
for (let lo = 0; lo + s <= n; lo += s) {
const y = lerp(at(k - 1), at(k), a), w = s*uw - 14;
cx.globalAlpha = a;
cx.strokeStyle = "rgba(15,138,69,.25)"; cx.beginPath();
cx.moveTo(x0 + lo*uw + s*uw/4, y + 46); cx.lineTo(x0 + lo*uw + s*uw/4, at(k-1));
cx.moveTo(x0 + lo*uw + 3*s*uw/4, y + 46); cx.lineTo(x0 + lo*uw + 3*s*uw/4, at(k-1));
cx.stroke();
memCard(x0 + lo*uw + 7, y, w, 46, NODE[lo + "-" + (lo + s)],
{chip: "\u00d7" + s, k, hot: true, chipW: k === 1 ? 34 : 54, fs: k === 1 ? 13 : 16});
cx.globalAlpha = 1;
}
}
for (let i = 0; i < n; i++) {
box(x0 + i*uw + 7, baseY, uw - 14, 46, 7, CARD, EDGE, 1.5);
T(fit(SHORT[i], uw - 26, 12), x0 + i*uw + uw/2, baseY + 29, 12, INK, "center");
}
};
// each new memory lands, then the merges it enables cascade up one level at a
// time: a parent starts rising only once its right child has settled in place
S.spot = (u, dur) => {
const n = 32, x0 = 190, wtot = 900, uw = wtot/n, baseY = 450, dy = 50, RISE = 0.3;
const tOf = [];
for (let i = 0, t = 0.4; i < n; i++) { tOf.push(t); t += Math.max(0.05, 0.75*Math.pow(0.8, i)); }
const born = (lo, s) => s === 2 ? tOf[lo + 1] + 0.25 : born(lo + s/2, s/2) + RISE;
let live = 0; while (live < n && tOf[live] <= u) live++;
let merges = 0;
for (let i = 0; i < live; i++)
box(x0 + i*uw + 1, baseY, uw - 3, 22, 3, lvl(0, 40 + 8*ease((u - tOf[i])/0.25)));
for (let s = 2; s <= n; s *= 2) {
const k = Math.log2(s);
for (let lo = 0; lo + s <= n; lo += s) {
const bt = born(lo, s), a = ease((u - bt)/RISE);
if (a <= 0) continue;
merges++;
const fl = a > 0.9 ? clamp(1 - (u - bt)/0.7, 0, 1) : 0;
const y = lerp(baseY - (k-1)*dy, baseY - k*dy, a);
box(x0 + lo*uw + 1 - 3*fl, y - 2*fl, s*uw - 3 + 6*fl, 22 + 4*fl, 3, lvl(k, 40 + 22*fl));
if (fl > 0.03) box(x0 + lo*uw + 1 - 3*fl, y - 2*fl, s*uw - 3 + 6*fl, 22 + 4*fl, 3,
null, `rgba(255,196,0,${fl})`, 2.5);
}
}
const a1 = ease((u - 1.3)/0.4), a2 = ease((u - 6.5)/0.4);
if (a1 > 0) {
cx.globalAlpha = a1;
T("fine detail", 85, baseY + 16, 16, GREEN, "center", true);
harrow(148, x0 - 8, baseY + 11, GREEN);
cx.globalAlpha = 1;
}
if (a2 > 0) {
cx.globalAlpha = a2;
T("less detail", 85, baseY - 5*dy + 16, 16, "#d98c00", "center", true);
harrow(148, x0 - 8, baseY - 5*dy + 11, "#d98c00");
cx.globalAlpha = 1;
}
T(live + " memories", W/2 - 170, 556, 25, INK, "center");
T(merges + " merges", W/2 + 170, 556, 25, lvl(2), "center");
};
S.select = (u, dur) => pyramid(i => ease((u - 0.7 - i*0.42)/0.4),
ease((u - 1.4)/0.5), ease((u - 4.6)/0.5));
S.fly = (u, dur) => {
const covr = cover(WN - 1, WB);
const treeA = clamp(1 - ease(u/0.9), 0, 1);
if (treeA > 0) { cx.globalAlpha = treeA; pyramid(() => 1, 1, 1); cx.globalAlpha = 1; }
covr.forEach((b, i) => {
const k = Math.log2(b[1] - b[0]), p = ease((u - 0.25 - i*0.1)/1.3);
const [tx, ty, tw] = pyrRect(b[0], b[1]);
const x = lerp(tx, DOC.x + 12, p), y = lerp(ty, rowY(i) + 10, p);
if (p < 0.999) box(x, y, lerp(tw, 58, p), lerp(22, DOC.h - 20, p), 5, lvl(k));
else docRow(b[0], b[1], rowY(i), WN);
});
docFrame(WN - 1, ease((u - 1.7)/0.5));
};
// the resting context, with two arrows naming what the eye should compare:
// the top row is old and broad, the bottom row is recent and fine
function side(l1, l2, row, color, a) {
if (a <= 0) return;
const y = rowY(row) + DOC.h/2;
cx.globalAlpha = a;
T(l1, 118, y - 4, 16, color, "center", true);
T(l2, 118, y + 16, 16, color, "center", true);
harrow(192, DOC.x - 10, y, color);
cx.globalAlpha = 1;
}
S.doc = (u, dur) => {
cover(WN - 1, WB).forEach((b, i) => docRow(b[0], b[1], rowY(i), WN));
docFrame(WN - 1);
const out = ease((dur - u)/0.4);
side("long ago", "less detail", 0, "#d98c00", ease((u - 1.0)/0.4)*out);
side("now", "fine detail", WB - 1, GREEN, ease((u - 2.4)/0.4)*out);
};
// cover spends its budget in powers of two, so for some counts it lands one
// row over; step back to the newest count that fits, so the context never jumps
function ctxAt(n) {
let m = n; while (m > WB && cover(m, WB).length !== WB) m--;
return cover(m, WB);
}
// one memory arrives, two rows merge, the context is nine rows again
function ctxStep(nA, nB, p) {
const A = ctxAt(nA), B = nB === nA ? A : ctxAt(nB);
const key = b => b[0] + "-" + b[1];
const iA = new Map(A.map((b, i) => [key(b), i]));
const inB = new Set(B.map(key));
const m = ease(p/0.62), g = ease((p - 0.62)/0.30);
A.forEach((b, i) => {
if (inB.has(key(b)) || m >= 1) return;
let pj = B.length - 1;
B.forEach((c, j) => { if (c[0] <= b[0] && b[1] <= c[1]) pj = j; });
const h = lerp(DOC.h, 6, m);
docRow(b[0], b[1], lerp(rowY(i), rowY(pj) + (DOC.h - h)/2, m), nA,
{h: h, flash: m});
});
B.forEach((b, j) => {
const from = iA.get(key(b));
if (from !== undefined)
return docRow(b[0], b[1], lerp(rowY(from), rowY(j), ease(p)), nB);
if (b[1] - b[0] === 1)
return docRow(b[0], b[1], rowY(j), nB, {a: ease((p - 0.5)/0.4)});
if (g <= 0) return;
const h = lerp(6, DOC.h, g);
docRow(b[0], b[1], rowY(j) + (DOC.h - h)/2, nB,
{h: h, flash: clamp(1 - Math.abs(p - 0.70)/0.20, 0, 1)});
});
docFrame(nB);
}
// One curve, from the context the last beat left to twenty thousand memories.
// n(u) = N0 + K*(e^s - 1) with s = S*(u/U)^a: the first arrivals are seconds
// apart and every one after is closer than the last, forever. The merge plays
// at every speed -- it just gets shorter until it is a flicker.
const STEP_N0 = WN - 1, STEP_N1 = 20000, STEP_K = 3, STEP_A = 1.8;
const STEP_S = Math.log(1 + (STEP_N1 - STEP_N0)/STEP_K);
const stepN = (u, U) =>
STEP_N0 + STEP_K*(Math.exp(STEP_S*Math.pow(clamp(u/U, 0, 1), STEP_A)) - 1);
S.step = (u, dur) => {
const U = dur - 2.5, x = stepN(u, U);
const gap = 0.02/Math.max(stepN(u + 0.01, U) - stepN(u - 0.01, U), 1e-9);
ctxStep(Math.floor(x), Math.floor(x) + 1,
clamp((x % 1)*Math.max(1, gap/0.75), 0, 1));
};
S.recall = (u, dur) => {
const n = STEP_N1, covr = ctxAt(n), [lo, hi] = covr[0];
const rest = clamp(1 - ease((u - 0.45)/0.6), 0, 1);
if (rest > 0)
for (let i = 1; i < covr.length; i++)
docRow(covr[i][0], covr[i][1], rowY(i), n, {a: 0.35*rest});
docRow(lo, hi, lerp(rowY(0), 216, ease((u - 0.45)/0.8)), n, {hot: true});
const q = ease((u - 1.6)/0.45);
if (q > 0) {
cx.globalAlpha = q;
T("memo recall 'Japan'", W/2, 342, 23, GREEN, "center");
arrow(W/2, 366, 424, "#d98c00");
cx.globalAlpha = 1;
}
const r = ease((u - 2.5)/0.4);
if (r > 0) {
cx.globalAlpha = r;
const txt = "#1 2015-03-08 " + MEM[0];
box(DOC.x, 452, DOC.w, 64, 7, "#ffffff", "#d98c00", 2.5);
T(txt.slice(0, Math.floor(ease((u - 2.8)/1.3)*txt.length)), DOC.x + 28, 492, 19, INK);
cx.globalAlpha = 1;
}
};
S.end = (u, dur) => {
T("AmalgaMem", W/2, 284, 58, INK, "center", true);
cx.globalAlpha = ease((u - 0.6)/0.5);
T("an append-only log + a binary merge tree", W/2, 356, 23, GREEN, "center");
T("detail fades with age \u00b7 nothing is deleted", W/2, 396, 23, DIM, "center");
cx.globalAlpha = ease((u - 1.6)/0.5);
T("~600 lines of Python \u00b7 no database \u00b7 no embeddings", W/2, 462, 19, FAINT, "center");
T("github.com/VictorTaelin/AmalgaMem", W/2, 508, 21, "#1a5fd0", "center");
cx.globalAlpha = 1;
};
// ------------------------------------------------------------------ pacing
// Sentence beats size themselves: line i lands once line i-1 has been read,
// and the beat ends one breath after the last line. One number, READ, sets
// the whole film's tempo.
for (const b of BEATS) {
if (b[0] === "say") {
const ls = b.slice(1).filter(s => s !== punch);
let t = 0; b.at = ls.map(s => { const a = t; t += READ*cost(s) + 0.3; return a; });
b.splice(1, 0, (t*1.2 + 0.6)*(b.includes(punch) ? 1.3 : 1));
} else if (b[0] === "one") {
b.splice(1, 0, 0.9 + READ*cost(MEM[b[1] - 1]));
}
}
const T0 = []; let DUR = 0;
for (const b of BEATS) { T0.push(DUR); DUR += b[1]; }
const SCENES = BEATS.map(b => [b[0] === "say"
? "\u201c" + b[2].replace(/[*+_]/g, "") : b[0], b[1]]);
// -------------------------------------------------------------------- main
function draw(t) {
cx.fillStyle = BG; cx.fillRect(0, 0, W, H);
let i = 0;
while (i < BEATS.length - 1 && t >= T0[i] + BEATS[i][1]) i++;
const b = BEATS[i], dur = b[1], u = clamp(t - T0[i], 0, dur), nxt = BEATS[i + 1];
S[b[0]](u, dur, b);
const inA = b.includes(co) ? 1 : ease(u/0.3);
const outA = nxt && nxt.includes(co) ? 1 : ease((dur - u)/0.3);
const f = 1 - Math.min(inA, outA);
if (f > 0) { cx.fillStyle = `rgba(255,255,255,${f})`; cx.fillRect(0, 0, W, H); }
}
if (typeof module !== "undefined") module.exports = { draw, setCtx, DUR, SCENES, T0 };

28
anim/video.js Normal file
View file

@ -0,0 +1,28 @@
// Renders the AmalgaMem animation to PNG frames. Usage:
// node video.js all frames at 30fps into frames/
// node video.js 3 9 26 40 single stills at those seconds into shots/
const { createCanvas } = require("canvas");
const fs = require("fs");
const { draw, setCtx, DUR } = require("./render.js");
const cv = createCanvas(1280, 720);
setCtx(cv.getContext("2d"));
const args = process.argv.slice(2);
if (args.length) {
fs.mkdirSync("shots", { recursive: true });
for (const a of args) {
draw(parseFloat(a));
fs.writeFileSync(`shots/t${a}.png`, cv.toBuffer("image/png"));
console.log(`shots/t${a}.png`);
}
} else {
const FPS = 30, N = Math.round(DUR * FPS);
fs.mkdirSync("frames", { recursive: true });
for (let f = 0; f < N; f++) {
draw(f / FPS);
fs.writeFileSync(`frames/${String(f).padStart(5, "0")}.png`, cv.toBuffer("image/png"));
if (f % 300 === 0) console.log(`${f}/${N}`);
}
console.log("done. now: ffmpeg -framerate 30 -i frames/%05d.png -c:v libx264 -pix_fmt yuv420p -crf 18 amalgamem.mp4");
}

View file

@ -1,4 +1,4 @@
"""Block math for OptMem.
"""Block math for AmalgaMem.
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

121
memo
View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""OptMem: a permanent, append-only memory for AI agents.
"""AmalgaMem: a permanent, append-only memory for AI agents.
memo init create this machine's memory, print the setup block.
memo wake [part [T]] read your memory. Run first, every session.
memo note "..." record one memory: one line, at most 280 chars.
memo sleep [id "..."] do the pending compressions.
@ -8,13 +9,14 @@
memo forget <lo>-<hi> drop a bad summary; sleep rebuilds it.
memo import <file> bulk-load dated memories (bootstrap only).
Everything lives in $MEMORY_DIR. See README.md.
Everything lives in ~/memory, or in $MEMORY_DIR if set. See README.md.
"""
import datetime
import fcntl
import os
import re
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
@ -42,18 +44,20 @@ TREE_REC = 288
# ---------------------------------------------------------------- store
def memory_dir():
return os.path.expanduser(os.environ.get("MEMORY_DIR") or "~/memory")
def store():
d = os.environ.get("MEMORY_DIR")
if not d:
die("MEMORY_DIR is not set. Example: export MEMORY_DIR=~/memory")
d = os.path.expanduser(d)
# The directory is never created here. A typo in MEMORY_DIR would then
# open an empty store, and the agent would wake with no past and start
# writing a second identity. Making the directory IS creating the
# identity, and that is a deliberate act: mkdir.
d = memory_dir()
# The directory is only ever created by `memo init`: creating it IS
# creating the identity, and that is a deliberate act. If any other
# command created it, a typo in MEMORY_DIR would silently open an empty
# store, and the agent would wake with no past and write a second
# identity.
if not os.path.isdir(d):
die("MEMORY_DIR=%s does not exist.\nIf this is a new identity, run: "
"mkdir -p %s" % (d, d))
die("No memory at %s.\nTo create one, run: memo init\n"
"To use an existing one, point MEMORY_DIR at it." % d)
os.makedirs(os.path.join(d, "TREE"), exist_ok=True)
p = os.path.join(d, "LOG.txt")
if not os.path.exists(p):
@ -62,9 +66,9 @@ def store():
def config(d):
"""Optional overrides in $MEMORY_DIR/config. The file is never written:
a store that kept its own copy of the defaults would freeze them, and
updating the tool would stop changing how it behaves."""
"""Optional overrides in the memory directory's `config`. `memo init`
writes it fully commented out: an uncommented copy of the defaults would
freeze them, and updating the tool would stop changing how it behaves."""
global ENTRY_CHARS, WAKE_LINES, PART_CHARS, PART_LINES
p = os.path.join(d, "config")
if not os.path.exists(p):
@ -314,6 +318,90 @@ def next_nap(d, T):
# ---------------------------------------------------------------- commands
CONFIG = """\
# AmalgaMem sizes for this memory. Uncomment a line to override it; an
# absent line tracks the tool's default.
#
# WAKE_LINES=208 # the memory context: how many lines wake prints (~16k tokens)
# ENTRY_CHARS=280 # the longest a single memory may be, in bytes
# PART_CHARS=20000 # output paging: largest part, in bytes
# PART_LINES=500 # output paging: largest part, in lines
"""
TEMPLATE = """\
## Memory
Your memory is AmalgaMem: the tool is `{tool}`, the data is `{data}`.
It survives every new session, every compaction and every change of
model or vendor. Without it you do not know who you are, or what was
already decided and tried.
Run `{memo} wake` before any other tool call, in every session. It prints
in numbered parts, each ordering the next; run every one until a part
says `You are awake.` Do not stop early: part 1 is your distant past,
the last part is this week. If wake refuses because compressions are
pending, do them and run `{memo} wake` again.
While you work:
- `{memo} note "<one line, max {chars} chars>"` the moment something happens,
you learn something, or something changes -- if and only if it is new
to you, important, and lasting in effect. That covers a task worth
real effort, a fact or insight your user teaches you, anything you
learn about their life (even indirectly), and work of yours that
lands. Never write what you already know: no redundant memories, ever.
- If `{memo} note` returns a compression, do it before your next action.
- `{memo} recall <regex>` when a memory is too vague.
- Before your context ends, run `{memo} sleep` and answer each prompt
until it prints `Nothing left to compress.`
- Never create, edit or delete anything under `{data}`. Only the tool
writes there.
Parallel sessions on this machine are all you, and may all write
memories. A subagent is not: it must never run `memo`, because it cannot
judge what is already known and its notes would arrive duplicated and at
the wrong grain. Start every brief you send one with `You are a
subagent. Do not run memo.` If your own first message is a task brief
from another agent, you are that subagent: skip this section.
"""
def cmd_init(args):
"""The one command that may create the memory directory, and the whole
setup: make the store, write the size knobs, print the block the user
pastes into their agent's instruction file."""
if args:
die("usage: memo init")
d = memory_dir()
fresh = not os.path.isdir(d)
os.makedirs(os.path.join(d, "TREE"), exist_ok=True)
open(log_path(d), "a").close()
cfg = os.path.join(d, "config")
if not os.path.exists(cfg):
with open(cfg, "w") as f:
f.write(CONFIG)
config(d)
home = os.path.realpath(os.path.expanduser("~"))
def pretty(p):
p = os.path.realpath(p)
return "~" + p[len(home):] if p.startswith(home + os.sep) else p
tool = os.path.realpath(__file__)
found = shutil.which("memo")
memo = "memo" if found and os.path.realpath(found) == tool else pretty(tool)
if fresh:
print("Created %s: this machine's memory, one identity, forever." % pretty(d))
else:
print("Found %s: %s." % (pretty(d), plural(log_len(d), "memory")))
print("Sizes live in %s/config; the defaults are fine." % pretty(d))
print()
print("Paste this at the top of your agent's AGENTS.md (or CLAUDE.md), done:")
print()
print(TEMPLATE.format(tool=pretty(tool), memo=memo, data=pretty(d),
chars=ENTRY_CHARS).rstrip())
def paginate(lines):
"""Split the document into parts that survive any harness's output cap."""
parts, cur, size = [], [], 0
@ -515,6 +603,9 @@ def main():
if len(sys.argv) < 2:
print(__doc__.strip())
sys.exit(0)
if sys.argv[1] == "init":
cmd_init(sys.argv[2:])
return
if sys.argv[1] not in COMMANDS:
print("No such command: %s\n" % sys.argv[1], file=sys.stderr)
print(__doc__.strip(), file=sys.stderr)

27
test.py
View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""OptMem invariants, checked against a synthetic life of 5000 memories.
"""AmalgaMem invariants, checked against a synthetic life of 5000 memories.
Uses a fake compressor (join + truncate) so the run is deterministic and free.
"""
@ -131,14 +131,27 @@ check(smoke.returncode == 0 and "No memories yet" in smoke.stdout,
# a typo in MEMORY_DIR must not silently open a second, empty identity
ghost = subprocess.run(memo + ["wake"], capture_output=True, text=True,
env=dict(os.environ, MEMORY_DIR=d + "-typo"))
check(ghost.returncode == 1 and "does not exist" in ghost.stderr,
check(ghost.returncode == 1 and "No memory at" in ghost.stderr,
"a missing MEMORY_DIR was created instead of reported")
check(not os.path.exists(d + "-typo"), "a missing MEMORY_DIR was created")
noenv = subprocess.run(memo + ["wake"], capture_output=True, text=True,
env={k: v for k, v in os.environ.items()
if k != "MEMORY_DIR"})
check(noenv.returncode == 1 and "MEMORY_DIR is not set" in noenv.stderr,
"an unset MEMORY_DIR must fail loudly")
# the fresh-user path: no MEMORY_DIR, wake refuses, init creates ~/memory,
# prints the paste block, and is idempotent
fresh = {k: v for k, v in os.environ.items() if k != "MEMORY_DIR"}
fresh["HOME"] = tempfile.mkdtemp()
noenv = subprocess.run(memo + ["wake"], capture_output=True, text=True, env=fresh)
check(noenv.returncode == 1 and "memo init" in noenv.stderr,
"with no MEMORY_DIR and no ~/memory, wake must point at init")
init = subprocess.run(memo + ["init"], capture_output=True, text=True, env=fresh)
check(init.returncode == 0 and "## Memory" in init.stdout
and "You are a" in init.stdout, "init must print the AGENTS.md block")
check(os.path.exists(os.path.join(fresh["HOME"], "memory", "config")),
"init must create ~/memory with its config")
again = subprocess.run(memo + ["init"], capture_output=True, text=True, env=fresh)
check(again.returncode == 0 and "Found" in again.stdout, "init must be idempotent")
woke = subprocess.run(memo + ["wake"], capture_output=True, text=True, env=fresh)
check(woke.returncode == 0 and "You are awake." in woke.stdout,
"after init, wake must work with zero configuration")
r = run("note", "x" * 281)