diff --git a/.gitignore b/.gitignore index 7a60b85..93b7cd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -__pycache__/ -*.pyc +anim/node_modules/ +anim/frames/ +anim/shots/ +.DS_Store diff --git a/README.md b/README.md index 05dbe84..2080c70 100644 --- a/README.md +++ b/README.md @@ -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 # search the raw log for detail a summary lost. -memo forget - # 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 "" +#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 ""` 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 ` 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 ""` 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 ` 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 search every memory ever recorded, verbatim +memo forget - 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 "" +``` + +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*. diff --git a/anim/amalgamem.mp4 b/anim/amalgamem.mp4 new file mode 100644 index 0000000..49d363a Binary files /dev/null and b/anim/amalgamem.mp4 differ diff --git a/anim/index.html b/anim/index.html new file mode 100644 index 0000000..d6c0c8c --- /dev/null +++ b/anim/index.html @@ -0,0 +1,68 @@ + + + + +AmalgaMem — how it works + + + + +
+ 0.0s + + space = play/pause · ←/→ = ±1s · click a scene to jump +
+
+ + + + diff --git a/anim/package-lock.json b/anim/package-lock.json new file mode 100644 index 0000000..897806e --- /dev/null +++ b/anim/package-lock.json @@ -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" + } + } +} diff --git a/anim/package.json b/anim/package.json new file mode 100644 index 0000000..45c742e --- /dev/null +++ b/anim/package.json @@ -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" + } +} diff --git a/anim/poster.png b/anim/poster.png new file mode 100644 index 0000000..8bf7a03 Binary files /dev/null and b/anim/poster.png differ diff --git a/anim/render.js b/anim/render.js new file mode 100644 index 0000000..e3251af --- /dev/null +++ b/anim/render.js @@ -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 }; diff --git a/anim/video.js b/anim/video.js new file mode 100644 index 0000000..b474920 --- /dev/null +++ b/anim/video.js @@ -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"); +} diff --git a/blocks.py b/blocks.py index ec7cbfc..51b6bb1 100644 --- a/blocks.py +++ b/blocks.py @@ -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 diff --git a/memo b/memo index c7ad7df..f524482 100755 --- a/memo +++ b/memo @@ -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 - drop a bad summary; sleep rebuilds it. memo import 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 ""` 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 ` 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) diff --git a/test.py b/test.py index 34e0141..24b03e2 100755 --- a/test.py +++ b/test.py @@ -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)