Rename to AmalgaMem: new README, memo init setup, explainer animation
This commit is contained in:
parent
58019db0db
commit
15130b9cad
12 changed files with 1573 additions and 250 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
||||||
__pycache__/
|
anim/node_modules/
|
||||||
*.pyc
|
anim/frames/
|
||||||
|
anim/shots/
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
404
README.md
404
README.md
|
|
@ -1,284 +1,238 @@
|
||||||
# OptMem
|
# AmalgaMem
|
||||||
|
|
||||||
A permanent memory for AI agents. One machine holds one identity that survives
|
How do you make an AI agent remember its whole life?
|
||||||
every new session, every compaction, and every change of model or vendor.
|
|
||||||
|
|
||||||
It is a handful of append-only text files and six commands. No daemon, no database, no
|
1. It must **never forget**. You cannot tell *today* what will matter in a
|
||||||
API, no integration with any particular agent harness — it works the same under
|
*year*, so deleting anything is a gamble you always eventually lose.
|
||||||
Claude Code, Codex, pi or a human at a shell.
|
|
||||||
|
|
||||||
## 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.
|
Most agent memories pick one: keep everything (and drown), or keep a small
|
||||||
The usual patch is a notes file the agent rewrites by hand, which decays into
|
curated file (and forget). AmalgaMem does both. *At once.*
|
||||||
either a stale summary or a wall of text nobody can afford to read.
|
|
||||||
|
|
||||||
OptMem fixes the two halves separately:
|
[](anim/amalgamem.mp4)
|
||||||
|
|
||||||
- **Nothing is ever forgotten.** Every memory is appended to `LOG.txt` and
|
*↑ click to watch: 3 minutes, the whole idea.*
|
||||||
never edited or deleted. That file is the truth, forever.
|
|
||||||
- **What you read is a fixed size.** `memo wake` prints a document of bounded
|
|
||||||
length — recent memories verbatim, older ones progressively compressed. At
|
|
||||||
a hundred million memories it is still the same number of lines.
|
|
||||||
|
|
||||||
## Install
|
## The idea
|
||||||
|
|
||||||
```sh
|
A **memory** is one short note about something the agent learned:
|
||||||
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.
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
```
|
||||||
$ memo note "OptMem: LOG.txt is the truth, TREE/ is the cache, wake reads both"
|
#4211 2026-07-25 Tom asked for a flight to Japan
|
||||||
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>"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
Tom asked for a flight to Japan
|
||||||
#4212 2026-07-25 minilin fleet renamed from bip; one mini = one identity
|
Tom booked a hotel in Tokyo
|
||||||
#4213 2026-07-25 OptMem: LOG.txt is the truth, TREE/ is the cache
|
↓
|
||||||
|
Tom planned a Tokyo trip
|
||||||
```
|
```
|
||||||
|
|
||||||
`TREE/` is a cache of summaries, one file per block size. A **block** is an
|
The result is a memory too — it just holds less detail. So it can merge
|
||||||
aligned power-of-two range of memories compressed into a single line, and a
|
again, and again, all the way up, forming a **binary merge tree** over the
|
||||||
block is built from its two halves — so the blocks form a binary merge tree
|
log:
|
||||||
over the log (a block is named by the inclusive range it covers, `0-1` being
|
|
||||||
memories #0 and #1):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
#0 #1 #2 #3 #4 #5 #6 #7 the raw memories
|
#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-3 4-7
|
||||||
\ /
|
\ /
|
||||||
0-7
|
0-7
|
||||||
```
|
```
|
||||||
|
|
||||||
A block covering four thousand memories is still one line of 280 characters.
|
A block covering four thousand memories is still one line. Nothing in the
|
||||||
Nothing in the system is ever bigger than one line.
|
system is ever bigger than one line.
|
||||||
|
|
||||||
`memo wake` picks a set of blocks that tiles the whole log and prints them. It
|
## The memory context
|
||||||
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:
|
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
|
block size: 1 2 4 8 16 32 64 128 256
|
||||||
how many: 42 21 21 21 22 21 21 21 18
|
how many: 42 21 21 21 22 21 21 21 18
|
||||||
└ the last 42, verbatim ───────────▶ the first 4,600, 256:1
|
└ the last 42, verbatim ───────────▶ the first 4,600, 256:1
|
||||||
```
|
```
|
||||||
|
|
||||||
The oldest memories are recalled as a vague shape, the newest word for word,
|
**Detail is proportional to recency.** The oldest years are recalled as a
|
||||||
and the transition is smooth. Below `WAKE_LINES` memories nothing is compressed
|
vague shape, the newest days word for word, and the transition is smooth —
|
||||||
at all — your whole life is printed verbatim, because it fits.
|
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
|
There is no dreaming, no nightly cleanup, no compaction spike. The moment two
|
||||||
complete, that block can be built, and it must be. This costs about one small
|
halves of a block exist, the agent is handed that one merge and does it **on
|
||||||
compression per memory written, and it means:
|
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.
|
And because *which* memories merge is decided by position and age alone —
|
||||||
- Work is never deferred into a spike. Measured over 20,000 memories, a new
|
never by judgement — the tree is a pure function of the log: a cache. A bad
|
||||||
memory creates one compression on average and nine at the very worst.
|
summary can be dropped and rebuilt (`memo forget`), and it can never cost you
|
||||||
- `WAKE_LINES` can be changed at any time, on any machine, with nothing to
|
a memory.
|
||||||
recompute. It only selects which existing lines get printed.
|
|
||||||
|
|
||||||
`memo wake` enforces the invariant: while any compression is pending it refuses
|
## Setup
|
||||||
to print, and hands you the work instead. A memory with work left in it is not
|
|
||||||
yet the truth.
|
|
||||||
|
|
||||||
## Writing a good memory
|
```sh
|
||||||
|
git clone https://github.com/VictorTaelin/AmalgaMem ~/AmalgaMem
|
||||||
Write one the moment something happens, you learn something, or something
|
~/AmalgaMem/memo init
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output is delivered in parts
|
`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
|
||||||
Every harness truncates an over-long command, and each one drops a different
|
agent's `AGENTS.md` (or `CLAUDE.md`), and you are done: the agent handles
|
||||||
piece:
|
everything else on its own. The block:
|
||||||
|
|
||||||
```
|
|
||||||
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:
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Memory
|
## Memory
|
||||||
|
|
||||||
Your memory is OptMem: the tool is `~/OptMem`, the data is `$MEMORY_DIR`.
|
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
|
It survives every new session, every compaction and every change of
|
||||||
or vendor. Without it you do not know who you are, or what was already
|
model or vendor. Without it you do not know who you are, or what was
|
||||||
decided and tried.
|
already decided and tried.
|
||||||
|
|
||||||
Run `memo wake` before any other tool call, in every session. It prints in
|
Run `~/AmalgaMem/memo wake` before any other tool call, in every session. It prints
|
||||||
numbered parts, each ordering the next; run every one until a part says
|
in numbered parts, each ordering the next; run every one until a part
|
||||||
`You are awake.` Do not stop early: part 1 is your distant past, the last
|
says `You are awake.` Do not stop early: part 1 is your distant past,
|
||||||
part is this week. If wake refuses because compressions are pending, do
|
the last part is this week. If wake refuses because compressions are
|
||||||
them and run `memo wake` again.
|
pending, do them and run `~/AmalgaMem/memo wake` again.
|
||||||
|
|
||||||
While you work:
|
While you work:
|
||||||
|
|
||||||
- `memo note "<one line, max 280 chars>"` the moment something happens, you
|
- `~/AmalgaMem/memo note "<one line, max 280 chars>"` the moment something happens,
|
||||||
learn something, or something changes -- if and only if it is new to you,
|
you learn something, or something changes -- if and only if it is new
|
||||||
important, and lasting in effect. That covers a task worth real effort, a
|
to you, important, and lasting in effect. That covers a task worth
|
||||||
fact or insight the user teaches you, anything you learn about their life
|
real effort, a fact or insight your user teaches you, anything you
|
||||||
(even indirectly), and work of yours that lands. Never write what you
|
learn about their life (even indirectly), and work of yours that
|
||||||
already know: no redundant memories, ever.
|
lands. Never write what you already know: no redundant memories, ever.
|
||||||
- If `memo note` returns a compression, do it before your next action.
|
- If `~/AmalgaMem/memo note` returns a compression, do it before your next action.
|
||||||
- `memo recall <regex>` when a memory is too vague.
|
- `~/AmalgaMem/memo recall <regex>` when a memory is too vague.
|
||||||
- Before your context ends, run `memo sleep` and answer each prompt until
|
- Before your context ends, run `~/AmalgaMem/memo sleep` and answer each prompt
|
||||||
it prints `Nothing left to compress.`
|
until it prints `Nothing left to compress.`
|
||||||
- Never create, edit or delete anything under `$MEMORY_DIR`. Only `memo`
|
- Never create, edit or delete anything under `~/memory`. Only the tool
|
||||||
writes.
|
writes there.
|
||||||
|
|
||||||
Parallel sessions on this machine are all you, and may all write memories.
|
Parallel sessions on this machine are all you, and may all write
|
||||||
A subagent is not: it must never run `memo`, because it cannot judge what
|
memories. A subagent is not: it must never run `memo`, because it cannot
|
||||||
is already known and its notes would arrive duplicated and at the wrong
|
judge what is already known and its notes would arrive duplicated and at
|
||||||
grain. Start every brief you send one with `You are a subagent. Do not run
|
the wrong grain. Start every brief you send one with `You are a
|
||||||
memo.` If your own first message is a task brief from another agent, you
|
subagent. Do not run memo.` If your own first message is a task brief
|
||||||
are that subagent: skip this section.
|
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
|
## Test
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 test.py
|
python3 test.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs the block math against a hundred thousand memory counts and drives the
|
Drives the real CLI through a synthetic life, checking that the context
|
||||||
real CLI through a synthetic life of two thousand memories, checking that the
|
always tiles the log, never exceeds its budget, always gains detail toward
|
||||||
document always tiles the log, never exceeds its budget, always increases in
|
the present, that every block is written exactly once, and that nothing is
|
||||||
detail toward the present, that every block is written exactly once, that
|
ever rewritten.
|
||||||
nothing is ever rewritten, and that a full sleep always leads to a clean wake.
|
|
||||||
|
## 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
BIN
anim/amalgamem.mp4
Normal file
Binary file not shown.
68
anim/index.html
Normal file
68
anim/index.html
Normal 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
459
anim/package-lock.json
generated
Normal 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
16
anim/package.json
Normal 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
BIN
anim/poster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
692
anim/render.js
Normal file
692
anim/render.js
Normal 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
28
anim/video.js
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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
|
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
|
line of at most ENTRY_CHARS characters. Blocks form a binary merge tree over
|
||||||
|
|
|
||||||
121
memo
121
memo
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 wake [part [T]] read your memory. Run first, every session.
|
||||||
memo note "..." record one memory: one line, at most 280 chars.
|
memo note "..." record one memory: one line, at most 280 chars.
|
||||||
memo sleep [id "..."] do the pending compressions.
|
memo sleep [id "..."] do the pending compressions.
|
||||||
|
|
@ -8,13 +9,14 @@
|
||||||
memo forget <lo>-<hi> drop a bad summary; sleep rebuilds it.
|
memo forget <lo>-<hi> drop a bad summary; sleep rebuilds it.
|
||||||
memo import <file> bulk-load dated memories (bootstrap only).
|
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 datetime
|
||||||
import fcntl
|
import fcntl
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
@ -42,18 +44,20 @@ TREE_REC = 288
|
||||||
|
|
||||||
# ---------------------------------------------------------------- store
|
# ---------------------------------------------------------------- store
|
||||||
|
|
||||||
|
def memory_dir():
|
||||||
|
return os.path.expanduser(os.environ.get("MEMORY_DIR") or "~/memory")
|
||||||
|
|
||||||
|
|
||||||
def store():
|
def store():
|
||||||
d = os.environ.get("MEMORY_DIR")
|
d = memory_dir()
|
||||||
if not d:
|
# The directory is only ever created by `memo init`: creating it IS
|
||||||
die("MEMORY_DIR is not set. Example: export MEMORY_DIR=~/memory")
|
# creating the identity, and that is a deliberate act. If any other
|
||||||
d = os.path.expanduser(d)
|
# command created it, a typo in MEMORY_DIR would silently open an empty
|
||||||
# The directory is never created here. A typo in MEMORY_DIR would then
|
# store, and the agent would wake with no past and write a second
|
||||||
# open an empty store, and the agent would wake with no past and start
|
# identity.
|
||||||
# writing a second identity. Making the directory IS creating the
|
|
||||||
# identity, and that is a deliberate act: mkdir.
|
|
||||||
if not os.path.isdir(d):
|
if not os.path.isdir(d):
|
||||||
die("MEMORY_DIR=%s does not exist.\nIf this is a new identity, run: "
|
die("No memory at %s.\nTo create one, run: memo init\n"
|
||||||
"mkdir -p %s" % (d, d))
|
"To use an existing one, point MEMORY_DIR at it." % d)
|
||||||
os.makedirs(os.path.join(d, "TREE"), exist_ok=True)
|
os.makedirs(os.path.join(d, "TREE"), exist_ok=True)
|
||||||
p = os.path.join(d, "LOG.txt")
|
p = os.path.join(d, "LOG.txt")
|
||||||
if not os.path.exists(p):
|
if not os.path.exists(p):
|
||||||
|
|
@ -62,9 +66,9 @@ def store():
|
||||||
|
|
||||||
|
|
||||||
def config(d):
|
def config(d):
|
||||||
"""Optional overrides in $MEMORY_DIR/config. The file is never written:
|
"""Optional overrides in the memory directory's `config`. `memo init`
|
||||||
a store that kept its own copy of the defaults would freeze them, and
|
writes it fully commented out: an uncommented copy of the defaults would
|
||||||
updating the tool would stop changing how it behaves."""
|
freeze them, and updating the tool would stop changing how it behaves."""
|
||||||
global ENTRY_CHARS, WAKE_LINES, PART_CHARS, PART_LINES
|
global ENTRY_CHARS, WAKE_LINES, PART_CHARS, PART_LINES
|
||||||
p = os.path.join(d, "config")
|
p = os.path.join(d, "config")
|
||||||
if not os.path.exists(p):
|
if not os.path.exists(p):
|
||||||
|
|
@ -314,6 +318,90 @@ def next_nap(d, T):
|
||||||
|
|
||||||
# ---------------------------------------------------------------- commands
|
# ---------------------------------------------------------------- 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):
|
def paginate(lines):
|
||||||
"""Split the document into parts that survive any harness's output cap."""
|
"""Split the document into parts that survive any harness's output cap."""
|
||||||
parts, cur, size = [], [], 0
|
parts, cur, size = [], [], 0
|
||||||
|
|
@ -515,6 +603,9 @@ def main():
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print(__doc__.strip())
|
print(__doc__.strip())
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
if sys.argv[1] == "init":
|
||||||
|
cmd_init(sys.argv[2:])
|
||||||
|
return
|
||||||
if sys.argv[1] not in COMMANDS:
|
if sys.argv[1] not in COMMANDS:
|
||||||
print("No such command: %s\n" % sys.argv[1], file=sys.stderr)
|
print("No such command: %s\n" % sys.argv[1], file=sys.stderr)
|
||||||
print(__doc__.strip(), file=sys.stderr)
|
print(__doc__.strip(), file=sys.stderr)
|
||||||
|
|
|
||||||
27
test.py
27
test.py
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/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.
|
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
|
# a typo in MEMORY_DIR must not silently open a second, empty identity
|
||||||
ghost = subprocess.run(memo + ["wake"], capture_output=True, text=True,
|
ghost = subprocess.run(memo + ["wake"], capture_output=True, text=True,
|
||||||
env=dict(os.environ, MEMORY_DIR=d + "-typo"))
|
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")
|
"a missing MEMORY_DIR was created instead of reported")
|
||||||
check(not os.path.exists(d + "-typo"), "a missing MEMORY_DIR was created")
|
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()
|
# the fresh-user path: no MEMORY_DIR, wake refuses, init creates ~/memory,
|
||||||
if k != "MEMORY_DIR"})
|
# prints the paste block, and is idempotent
|
||||||
check(noenv.returncode == 1 and "MEMORY_DIR is not set" in noenv.stderr,
|
fresh = {k: v for k, v in os.environ.items() if k != "MEMORY_DIR"}
|
||||||
"an unset MEMORY_DIR must fail loudly")
|
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)
|
r = run("note", "x" * 281)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue