diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 2ffc3ffd..fb747743 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -6,7 +6,7 @@ # image to a registry, and `packages: none` below says so in this file (with other # permissions named, GitHub gives none for packages anyway). Pins are bumped by a daily # check that opens a pull request, and a pull request that touches them has its lockfile -# checked against package.json. Never on a run: `memory launch` uses the tag it is given. +# checked against package.json. Never on a run: `amem launch` uses the tag it is given. name: image on: @@ -83,7 +83,8 @@ jobs: with: toolchain: stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - - name: Build the memory binary + - name: Build the amem binary + # staged as `memory`: the Dockerfile copies that file to /opt/memory/memory run: cargo build --release --locked -p amem && cp target/release/amem images/harness/memory - name: Build the image run: docker build --pull -t harness:${{ github.sha }} images/harness diff --git a/AGENTS.md b/AGENTS.md index 6062748b..2402bed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,9 +36,24 @@ before you override the instruction. installs `make check` as the pre-commit hook. Some changes touch only Markdown files outside `crates/` and `taps/`. For this rule, an `AGENTS.md` in `crates/` or `taps/` counts as Markdown. The hook and CI do not run `make check` for such a change, because - nothing builds from those files or reads them. `scripts/docs-only.sh` decides which - changes are of this type. Its test holds the premise that nothing builds from those - files or reads them. + nothing builds from those files. `scripts/docs-only.sh` decides which changes are of + this type. Its test holds the premise that no crate or tap embeds those files or reads + them. Some scripts read them, and no check runs these scripts: + + - `scripts/seed-corpus.py` seeds the demo record from `docs/` and + `examples/decisions/` (`make demo`). `golden/docs.toml` and `evals/demo.toml` score + answers against that text. + - Act 11 of the demo (`scripts/demo/11-limits.sh`) prints the two lists at the top of + `README.md`. + + The demo seeds its record only once. So, after you change a file in `docs/` or + `examples/decisions/`, seed a new record with `make demo-ready RESET=1`. Then run + `make golden` and act 6 (`make demo-all HARNESS=codex ACTS=6`). Act 6 needs a key + for llm.apache.org, in `local/.virtual-key` or `LLMAO_KEY`. Without a key, the act + says "skipped" and checks nothing. Neither command fails on a miss. Read what they + print: golden's "Answers naming no entry in this record" and "Missed" lists, and the + `passed` column of act 6's `memory` row. After you change those two lists, run act 11 + (`make demo-all HARNESS=codex ACTS=11`). - **Show that a new assertion fails before you trust it.** Run it against the code as it was before your change. Put what it reported in the pull request. If a test only ever passed, nobody has shown that it tests anything. Assert the rule, not a bound on the @@ -113,9 +128,10 @@ of the Claude Code harness that belongs to the repository is committed under `.c | `.claude/commands/reader-test.md` | the prompts for a docs-only branch, instead of that pass: a reader that has only the changed files and their diff, and a fact check against the code | If the guard refuses something that this repository asks for, the guard has a bug. Add -the case to the test and fix the guard. Do not work around the bug. All other files in -`.claude/` stay ignored. An agent that is not Claude Code gets none of this. The same -rules apply to that agent, and it learns them when it reads them. +the case to the test and fix the guard. Do not work around the bug. `.gitignore` ignores +`.claude/settings.local.json` and `.claude/worktrees/`. Do not commit other files in +`.claude/` unless they belong to the repository. An agent that is not Claude Code gets +none of this. The same rules apply to that agent, and it learns them when it reads them. ## Project shape diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ed34169..0d39f18c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ make check # format, clippy, tests, guard, docs-only and conformance tests | `make conformance` | `scripts/conformance.py`: the seven M0 steps over MCP, against a new record that this build serves. `URL=` points it at a different server. `OPERATOR=` gives it the export, import and reindex of that server, for the last two steps | | `make test` | only `cargo test` | | `make fmt` | `cargo fmt`, which changes the files in place | -| `make run ARGS='...'` | the `memory` binary, for example `ARGS='init /tmp/m --domain dom-me'` | +| `make run ARGS='...'` | the `amem` binary, for example `ARGS='init --root /tmp/m --domain dom-me'` | | `make verify` | `check`, and also `cargo deny`, `cargo machete`, coverage against `COVERAGE_MIN`, and the browser builds of `dm-core`, `dm-policy` and `dm-reader`. CI runs this target | | `make msrv-check` | `cargo check` of the workspace and of the browser builds, with the Rust release that `rust-version` in `Cargo.toml` names. It needs that toolchain: `rustup toolchain install --profile minimal --target wasm32-unknown-unknown`. It is not part of `check` or `verify`. CI runs it as a separate job | | `make e2e` | the browser tests in `e2e/`. Chromium in the Playwright image drives the pages. The tests put screenshots in `e2e/screenshots/`. It needs Podman or Docker, not Node. It is not part of `check` or `verify`. Run it for a change to the pages. See [e2e/README.md](e2e/README.md) | diff --git a/Makefile b/Makefile index 429895c9..f35bd957 100644 --- a/Makefile +++ b/Makefile @@ -230,7 +230,7 @@ windows-target: @rustup target list --installed 2>/dev/null | grep -q '^x86_64-pc-windows-gnu$$' || \ { echo "the C API binding is linted for Windows; run: rustup target add x86_64-pc-windows-gnu" >&2; exit 1; } -# wslcsdk.dll, the WSL containers C API, beside the Windows memory.exe: pinned and checked. +# wslcsdk.dll, the WSL containers C API, beside the Windows amem.exe: pinned and checked. wslcsdk: sh scripts/fetch-wslcsdk.sh "$(or $(DEST),target/release)" diff --git a/PLAN.md b/PLAN.md index fd57c983..c5a54678 100644 --- a/PLAN.md +++ b/PLAN.md @@ -74,7 +74,7 @@ that run turns up. | Milestone | Adds | Proves | |---|---|---| -| M1, recall and erasure first cuts done 3 October | `recall`; erasure with stub and receipt; ceilings checked on every outbound call; HTTP binding. The last two hold already: the egress refuses a session label above the destination's ceiling before anything leaves, on every call, and `/mcp` on the egress is the memory server over MCP's streamable HTTP transport. Erasure, `amem erase --reason `, is the paper's §4 in the small: the owner only, once per entry, and not on the MCP surface, since an irreversible action escalates to a person. The receipt is written first, naming the versions about to be removed and saying the erasure has not finished; a run that stops short, an index another process holds mid-query say, leaves that receipt and the next run finishes from it. The stub is written over the current version and over every archived one, so no version keeps the body; it keeps the frontmatter with `status: erased` and no summary, no title when the person asked or the material was sensitive, and for an erasure the person asked for, neither author nor sources. The index drops the row, re-indexes the stub, merges its full-text segments, vacuums and checkpoints its log, and says so when another connection kept the log from being truncated, so the body's bytes leave the file rather than wait to be overwritten; the record's status now wins over the index's row on every `get`, so a stub is never served because the index is behind. Entries that cite the erased one, in the body, the sources or as the entry superseded, are flagged with a review date of today for curation to re-derive or erase; the bundles served from the root for that domain are removed; the session's push note forgets the id. The receipt, `record//.erasures/.yaml`, is the erasure record and the coverage in one: who, the reason class, when, every version removed with the digest of the text it had, the ids flagged, and the copies out of reach named as unresolved: exports issued before, transcripts of sessions that read it, the queries kept in the read log, and the history and handoffs that are not scanned. The digests are consulted: `import` refuses an entry whose text a receipt in the target domain lists as removed, so a bundle exported before the erasure cannot bring the body back, and names it. Proven by tests that erase an entry a bundle and three citing entries hold and read every byte under the root with the read log on, re-import the older bundle, hold the index from another connection and finish on the second run, and refuse before the record step when the receipt cannot be written. An adversarial pass found the re-import, the unfinishable run, the vacuum that claimed what it had not done and the read log's queries; all four are fixed above. Not yet: entries written by sessions that read the erased one, which needs the read log to carry ids; erasure through a role policy on behalf of the person; a receipt that reaches an upstream host. Recall is three pieces. The pack: one function with no model behind it that turns a question, or a batch of queries, into the top hits with their sections and what they link to, inside a token budget, at one of two depths, summaries or sections; a message from the start, so the same object can arrive over a wire as well as from a local index. The deputy: a trait the server defines and the launcher implements over the egress, so the server never holds a credential; synthesis is one model call over the pack, and a second hop only when the model names what is missing, under a cap on hops, tokens and wall clock. The switch: which tier a session gets is its route, not an identity. A key or the gateway gives the server a model it may spend, so recall is server-executed, metered by the egress as a call of its own and checked against the destination's ceiling. A subscription login's credential is the harness's alone, so recall returns the pack with synthesis owed and the caller spends its one turn synthesizing; sampling through the harness is that same tier if a harness ever advertises it. Pull against push is observed, not asserted: two gets from one session inside a short window are a fan-out, and recall's default depth flips from sections to summaries once a session has shown one, since a serial caller pays a turn per section and a parallel one pays tokens only. The record serves every `get`; a per-session note of what packs pushed, by version, marks the gets that opened it, so pushed and then opened is counted in the funnel without a second serving path. Done: the pack, the deputy behind the egress with the destination's small default model, the fan-out rule, the cache, `recall` on the MCP surface and the CLI, `search` at section depth. The owed tier run here, 3 October, on a subscription credential with no deputy: Claude Code on Haiku, the server attached over stdio, a corpus of 57 entries seeded from the three docs. Asked to call `recall` once with a question about push against pull, it got a pack of eight at section depth, 5,190 tokens by the read log, followed the `next` field, answered in one turn citing the right entry, and its follow-up `get` was served from the session cache, which the log marks; the report's recall section shows both. A second session told to use only `search` and `get` chose `search` at section depth unprompted, wrote a keyword query that matched one entry, 1,987 tokens, and answered as well in the same number of turns. Two things follow. On a subscription login, owed recall and `search` with sections are nearly the same call, and recall's margin is the deputy, so the server-executed run with a key is the one that still matters. And the raw question is a wider query than the keywords a model writes: the pack from it cost two and a half times the single body the model's own query found, so a query reduction before the first search, or the deputy's second hop doing that work, is the first thing to try on that run. A golden suite says the same with a number instead of a reading: `amem golden golden/docs.toml` (`make golden`), fourteen questions over the docs corpus, each asked as written, stripped of stopwords, and by a hand-written keyword query standing in for a model's, scored through the pack with no model behind it; `--provider` scores the deputy's brief beside them. On the seeded corpus, hits in the top eight, mean reciprocal rank, and tokens a pack: the question as written 11 of 14, 0.65, 5,339; stripped 12 of 14, 0.75, 3,382; keywords 12 of 14, 0.86, 2,897. Dropping stopwords before the first search is therefore worth doing and gets most of the way to a model's query at under two thirds of the tokens; what it does not reach is the keyword query's ranking. Not yet: the key run, scored by that suite, and the deputy's prompt, the stopword reduction and the budgets tuned on it | The paper's §5 flow rules hold in one process, and the two recall tiers share every piece but the synthesis | +| M1, recall and erasure first cuts done 3 October | `recall`; erasure with stub and receipt; ceilings checked on every outbound call; HTTP binding. The last two hold already: the egress refuses a session label above the destination's ceiling before anything leaves, on every call, and `/mcp` on the egress is the memory server over MCP's streamable HTTP transport. Erasure, `amem erase --reason `, is the paper's §4 in the small: the owner only, once per entry, and not on the MCP surface, since an irreversible action escalates to a person. The receipt is written first, naming the versions about to be removed and saying the erasure has not finished; a run that stops short, an index another process holds mid-query say, leaves that receipt and the next run finishes from it. The stub is written over the current version and over every archived one, so no version keeps the body; it keeps the frontmatter with `status: erased` and no summary, no title when the person asked or the material was sensitive, and for an erasure the person asked for, neither author nor sources. The index drops the row, re-indexes the stub, merges its full-text segments, vacuums and checkpoints its log, and says so when another connection kept the log from being truncated, so the body's bytes leave the file rather than wait to be overwritten; the record's status now wins over the index's row on every `get`, so a stub is never served because the index is behind. Entries that cite the erased one, in the body, the sources or as the entry superseded, are flagged with a review date of today for curation to re-derive or erase; the bundles served from the root for that domain are removed; the session's push note forgets the id. The receipt, `record//.erasures/.yaml`, is the erasure record and the coverage in one: who, the reason class, when, every version removed with the digest of the text it had, the ids flagged, and the copies out of reach named as unresolved: exports issued before, transcripts of sessions that read it, the queries kept in the read log, and the history and handoffs that are not scanned. The digests are consulted: `import` refuses an entry whose text a receipt in the target domain lists as removed, so a bundle exported before the erasure cannot bring the body back, and names it. Proven by tests that erase an entry a bundle and three citing entries hold and read every byte under the root with the read log on, re-import the older bundle, hold the index from another connection and finish on the second run, and refuse before the record step when the receipt cannot be written. An adversarial pass found the re-import, the unfinishable run, the vacuum that claimed what it had not done and the read log's queries; all four are fixed above. Not yet: entries written by sessions that read the erased one, which needs the read log to carry ids; erasure through a role policy on behalf of the person; a receipt that reaches an upstream host. Recall is three pieces. The pack: one function with no model behind it that turns a question, or a batch of queries, into the top hits with their sections and what they link to, inside a token budget, at one of two depths, summaries or sections; a message from the start, so the same object can arrive over a wire as well as from a local index. The deputy: a trait the server defines and the launcher implements over the egress, so the server never holds a credential; synthesis is one model call over the pack, and a second hop only when the model names what is missing, under a cap on hops, tokens and wall clock. The switch: which tier a session gets is its route, not an identity. A key or the gateway gives the server a model it may spend, so recall is server-executed, metered by the egress as a call of its own and checked against the destination's ceiling. A subscription login's credential is the harness's alone, so recall returns the pack with synthesis owed and the caller spends its one turn synthesizing; sampling through the harness is that same tier if a harness ever advertises it. Pull against push is observed, not asserted: two gets from one session inside a short window are a fan-out, and recall's default depth flips from sections to summaries once a session has shown one, since a serial caller pays a turn per section and a parallel one pays tokens only. The record serves every `get`; a per-session note of what packs pushed, by version, marks the gets that opened it, so pushed and then opened is counted in the funnel without a second serving path. Done: the pack, the deputy behind the egress with the destination's small default model, the fan-out rule, the cache, `recall` on the MCP surface and the CLI, `search` at section depth. The owed tier run here, 3 October, on a subscription credential with no deputy: Claude Code on Haiku, the server attached over stdio, a corpus of 57 entries seeded from the three docs. Asked to call `recall` once with a question about push against pull, it got a pack of eight at section depth, 5,190 tokens by the read log, followed the `next` field, answered in one turn citing the right entry, and its follow-up `get` was served from the session cache, which the log marks; the report's recall section shows both. A second session told to use only `search` and `get` chose `search` at section depth unprompted, wrote a keyword query that matched one entry, 1,987 tokens, and answered as well in the same number of turns. Two things follow. On a subscription login, owed recall and `search` with sections are nearly the same call, and recall's margin is the deputy, so the server-executed run with a key is the one that still matters. And the raw question is a wider query than the keywords a model writes: the pack from it cost two and a half times the single body the model's own query found, so a query reduction before the first search, or the deputy's second hop doing that work, is the first thing to try on that run. A golden suite says the same with a number instead of a reading: `amem golden golden/docs.toml` (`make golden`), fifteen questions over the docs corpus, each asked as written, stripped of stopwords, and by a hand-written keyword query standing in for a model's, scored through the pack with no model behind it; `--provider` scores the deputy's brief beside them. On the seeded corpus, hits in the top eight, mean reciprocal rank, and tokens a pack: the question as written 11 of 14, 0.65, 5,339; stripped 12 of 14, 0.75, 3,382; keywords 12 of 14, 0.86, 2,897; those are for the suite as it was then, before eight of its questions changed to point at the current headings of the docs and one of them became two, and on the corpus of then, three docs, where the demo now seeds nine files. Dropping stopwords before the first search is therefore worth doing and gets most of the way to a model's query at under two thirds of the tokens; what it does not reach is the keyword query's ranking. Not yet: the key run, scored by that suite, and the deputy's prompt, the stopword reduction and the budgets tuned on it | The paper's §5 flow rules hold in one process, and the two recall tiers share every piece but the synthesis | | M2, first rungs 10 October | Postgres ledger, Solr index, Kafka log as alternative backends behind the same crates; a compose file. First rung, done 10 October: the ledger behind one type with two backends, SQLite as before and PostgreSQL through `tokio-postgres`, chosen by `[ledger] url` in `grants.toml`, the same event log and projection, a claim as one `SELECT ... FOR UPDATE SKIP LOCKED`, proven by the ledger's own tests and the conformance suite running against both. Second rung, done 10 October: OpenTelemetry, `crates/otel`, a small facade the egress and the server emit spans through, `model.call` with destination, model, status and tokens from the request's start to its last byte, `memory.read` with tool, hits, tokens and the ids served, `policy.decision` with the perimeter's every allow and deny and the policy version, exported over OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set and nothing installed otherwise, the SQLite tables kept beside it; task transitions from the ledger follow the first rung. Third rung, done 10 October: one schema per domain on PostgreSQL, `public.ledger_domains` and `public.ledger_tasks` routing ids to schemas, a ledger from the first rung migrated on connect, and `task.` spans from the ledger on either backend | The same binary runs as the sidecar in a container stack | | M3, first cut done 3 October | Taps as WASI components. `crates/taps` hosts a component over wasmtime with the component model, WASI preview 2 and WASI HTTP; the tap world in `crates/taps/wit/tap.wit` imports two things from memory, `file-task` and `propose`, and exports one `run`. The grants file gains `[taps.]`: the domain the tap's proposals land in, the hosts it may reach, its ceiling, and the name of the environment variable holding the tracker's credential, which the host injects as a bearer token in the one outbound hook, so the tap never holds it and the file names the variable, never the secret. The store the tap runs in has no filesystem, no sockets and no environment, and a run is bounded: a wall clock over the whole run, waits included, with the epoch ticking so a busy guest can be interrupted, and a cap on linear memory. The outbound hook holds one rule: a host the grant names, on the port it names if it names one, and TLS unless the host is loopback, because the credential rides on the request and plaintext would put it on the wire; a refusal is kept with its reason and comes back in the run's outcome whether or not the tap went on, and a label above the ceiling is refused the same way. A task a tap files takes its domain's default classification, never public by default, since upstream content is as sensitive as the domain says. Everything a tap writes is a proposal under its own author, `tap:`: a task born proposed with its origin and upstream event, deduplicated by the ledger, or an entry at raw tier. The first tap is GitHub issues, inbound, in `taps/`, a workspace of its own built for `wasm32-wasip2` with the `wasi` crate for HTTP and `wit-bindgen` for the world: each open issue becomes a proposed task citing `github:#` with the issue's state and last update as the event; pull requests are skipped. Proven by the host's integration test, which builds the real component and runs it against a fake of the GitHub API on loopback: two pages followed by their `Link` header, two issues filed, one pull request skipped, one malformed item costing only itself, a replay filing nothing twice, the bearer token arriving at the API on both pages, plaintext to a non-loopback host and a port the grant did not name both refused before anything is sent, a label above the ceiling stopping the tap, and a run ending at its wall clock. `amem tap install ` copies a component under the root, a name checked before it becomes a path; `amem tap run --config [--every ]` runs it under its grant. The outbound half, 3 October: the tap world gains `completed`, the domain's completed tasks that came from upstream and whose handoff has not been posted back, each with its handoff's text, and `posted`, which records where it went; the host hands out only handoffs under the tap's ceiling, so the tap's label stays where its grant allows it to send, and the ledger keeps `posted_to` on the task, appended as an event like everything else, so a replay posts nothing twice. `taps/github-comments` posts each such handoff on its issue as a comment naming the task, leaves tasks done for another repository to their own tap, and a post that fails costs only itself and is tried again next run, on every run until it lands, since nothing counts attempts yet. A tap may record only what it was handed: `posted` refuses a task the run was not given, one of another domain, or one above the ceiling. Proven by the host's test against a fake API: one comment lands with the bearer token, a restricted handoff never leaves, a replay posts nothing twice; an adversarial pass found `posted` unguarded and the index's origin lookup unscoped by domain, both fixed. Not yet: the outbound half, a handoff posted back as a comment under the fleet's identity; close and reopen events crossing inward; running taps on a schedule from `serve` rather than the command line; federated tools through the gateway's MCP endpoint under the wrapper's own surface | Contributed connectors run sandboxed with the access the grant allows, enforced by the runtime rather than by trust: a tap with a ceiling and a component with a capability set are the same idea, and the test shows the boundary holding against the connector itself | | M3b | Upstream transport: the wrapper talks to hosts (the institution's memory, the gateway's federation) over gRPC where that pays, while the agent-facing surface stays MCP over stdio or HTTP | MCP has no gRPC transport and the harness side should stay standard; server-to-server is where the paper says a shared protocol belongs, and where efficiency matters. A stream per session is what opens the options a managed service has and a local root does not. The service pushes speculatively to the wrapper, the sections behind the top hits, a supersedes chain, what a recall cited, because bytes to the wrapper are cheap and tokens in the model's context are not; the wrapper caches them for the session and pushes into the model by the fan-out rule, so the two budgets have two dials. Wasted push is measured at the wrapper, which knows what it was sent and what was then asked for. The deputy splits along the stream: the pack is built where the index is, synthesis runs where the credential is, which is the tier table's line that the index is free and inference is priced. The stream also carries what an agent should never have to ask about: the compiled filter up once, label changes back, erasure receipts down so a cached entry is dropped, a superseded citation, a refreshed T0, a task invalidation the launcher acts on by stopping the harness. None of it reaches the model until the wrapper puts it in a response: the agent sees the prefix and tool results and nothing else, and the ceiling check stays at the wrapper, where every outbound call already passes | @@ -93,7 +93,7 @@ that run turns up. | M18, mount done 13 October, memory tool a candidate | Anthropic's memory tool served by the memory server. The Claude API's client-side memory tool (`memory_20250818`, Claude 4 and later) gives a model six file commands on a `/memories` directory, `view`, `create`, `str_replace`, `insert`, `delete` and `rename`, which the calling application executes against storage of its choice; with the tool present the API adds a fixed protocol to the system prompt, view the directory before anything else and record progress as you go, on which the models are trained. That protocol is this design's under other names, so the commands map onto the memory server with every guarantee kept: `view /memories` is T0, the summaries organised by kind; `view` of a file is `get`; `create` is a `write` at raw under the session's author, through the scan and the quarantine; `str_replace` and `insert` write a new version that supersedes the old, so an edit in place loses nothing; `delete` supersedes with a tombstone, erasure staying the owner's; `rename` is a new version under a new title; and the protocol's "assume interruption, record progress" is the handoff. The first cut is a backend for the SDKs' memory helpers, a Python `BetaAbstractMemoryTool` subclass and the TypeScript handler, over the memory server's HTTP binding, so a Claude API agent writes to the record with one line of setup; the tool has no search, so retrieval rests on the listing, and a virtual search path is not proposed. Not for the harnesses this launcher runs, which reach the memory over MCP, nor for the gateway's open models, which are not trained on the tool; built when a Claude API workload needs it, Gofannon's agents on a Claude model the likeliest. Beside it, the projection's second rung (M4b) on the same mapping: the record as a live FUSE mount in the sandbox, every open and read a `get` the server sees, filters and logs whatever tool made it (a shell `cat` included, which the hooks cannot see), and every file written a `write` through the gates, read-only or read-write per session as Managed Agents' memory stores are, for every harness alike on any model. Unprivileged FUSE needs `/dev/fuse` in the namespaces backend and a device passed through on WSL containers; macOS keeps the render and the hooks. Its read-write half amends the paper's "project, never mount", whose reason, that no path bypasses the filter or the telemetry, a mount through the server keeps: to be decided in the register before it is built. Decided 13 October: the read half is built, as M4b's rung three, used wherever the host can mount it and a render where not; a mount the server itself serves keeps the rule's reason, that no path bypasses the filter or the telemetry. The read-write half is not built: a write stays the `write` tool's, and whether a file written under `/memory` should become one is still the register's question, with the memory tool's `create` and `str_replace` the mapping it would follow | A model-trained memory interface and a harness-agnostic file view, both kept behind the same server, so that neither opens a path around the policy, the provenance or the telemetry | | M19, third cut done 13 October, held on the way 16 October | Tools hosted, not carried. On a laptop the tools are WebAssembly components the launcher runs itself (M3, the fetch and search tools, the GitHub and Gofannon taps), which is right for one machine and wrong for a managed deployment, where the launcher should bring no tool runtime of its own and call the tools as services the environment hosts. The seam is there: every tool goes through `dm_server::sources::Tools`, one `call(tool, config, args)`, and the WASI host, `TapTools`, is one implementation of it. Three parts. First, one tool, two hosts: `amem tool-server` serves the same components over MCP's streamable HTTP, one container per environment, registered in the gateway's MCP registry (llmao [#25](https://github.com/apache/tooling-llmao/issues/25), proposal 5 in docs/llm-apache-org.md) with its tool list pinned by digest, so the code a tool runs is the same on a laptop and in the Foundation's deployment and only its host changes. Second, `HostedTools`, a second implementation of the trait in the launcher: it calls a registered tool server over MCP through the egress, so each call is metered, traced and held to the destination's ceiling like a model call; the grants choose per tool, `[taps.] hosted = ""` for hosted, the component as today without it. A launcher in a managed environment then needs neither wasmtime nor any local tool runtime. Third, the guarantees, which stay where they are whichever host runs the tool: the memory server decides each call by policy at the session's label before it leaves, scans the answer, keeps it as a source cited by digest, and taint follows it. What moves is control inside the tool: the WASI host decides every request a component makes, each redirect included, and holds a session that has read internal entries to trusted hosts; a hosted server makes its own requests. So the call carries the hosts the session may reach at its label, the server enforces them and returns the URLs it actually reached with its answer, the memory server refuses an answer that names none or names a host outside them, and the registry's pinned digest is what makes the server's word worth taking. Order: the hosted backend and `tool-server` first, proven against a fake MCP server and against `tool-server` on loopback with the same tests the components pass now, the refused redirect and the label's narrowing included; registration and the Foundation's deployment once llmao's registry takes servers, which it does not yet (`/v1/mcp/server` answers an empty list). Not in this: tools that need a browser to render a page, which would be hosted services from the start and never components, and per-person credentials for a hosted tool acting as its caller, which is proposal 5's last open point. First cut, 12 October: the tool host's decision is a `Gate`, the session's policy in a launcher (`PolicyGate`) or a `HostGate` in a tool server, the policy's hard rules (TLS, the granted methods, nothing inward) and a host only when both the server's grant and the caller name it, and every request a gate allows is recorded; `amem tool-server [--listen] [--token-env]` serves a root's installed tools over MCP's streamable HTTP, refuses to listen off loopback without a bearer token, and answers each call with the tool's JSON and the URLs it reached; a grant's `hosted = ""` (https, or http on loopback for development) sends that tool's calls there, its `credential` then the bearer for the server, with the hosts the session may reach at its label (`Memory::fetch_hosts`, the grant's and the trusted ones within their ceilings, so an exception is not in it and the list errs narrow); and the launcher decides every reported URL by its policy (`Memory::check_reached`) and refuses an answer that names none or one the policy refuses, keeping what it accepts as a source observed by `tool-server`. Proven end to end in `crates/memory/tests/hosted.rs` with the real fetch component: an answer through the server with the token, none without it, nothing sent for a session whose hosts leave the page out, a redirect off the server's grant refused there, and a fake server that names a host the session may not reach, or none, not believed and its answer not kept. Not yet: the calls go from the launcher's process, outside the sandbox as the components run, not through the egress, so they are not metered or held to a destination's ceiling there; the reached URLs are checked but not kept on the source; and registration in llmao's registry waits on #25. Decided 12 October, for the second cut: WebAssembly is chosen per tool, not as a rule. Its case is isolation for code the Foundation does not fully trust, connectors contributed by projects, which run with no filesystem, sockets or environment, every request through the host's gate and a credential they never hold, and that case stands. A tool this project writes and trusts, fetch and search today, may instead be native in the tool server behind the same interface and the same gate: the allowed hosts, the pinned addresses, the credential put on after the decision, and the reached URLs reported are the tool server's, not the runtime's, so a caller sees no difference. Native trades the component's isolation, which trusted code does not need, for no second build target and no runtime in the server; fetch is the first candidate. A launcher carries components only where it hosts tools itself, on a laptop. Second cut, 13 October: a tool server is a destination of the launcher's egress, one per hosted tool by the tool's name (`Egress::with_tool_servers`, `Egress::post`), so a hosted call is held to the tool's ceiling before it leaves, carries the server's bearer from there, and is metered and traced per request under the model `tool`, its latency with it; the harness cannot reach a tool server by path, since a tool is reached only through the memory server, and a command with no egress of its own makes one over the root's tool servers on the first hosted call. Every tool answer, from the tap host or a tool server, carries the URLs it reached (`sources::Answered`), kept on the source as `reached` and served with the answer, so a citation can be followed to the page. Fetch is native in the tool server: `dm_taps::fetch` makes the same requests behind the same `Gate`, pinned to the addresses the gate was shown, the credential put on after the decision, and shapes the same answer, from `crates/fetch`, which the component shares, so the two hosts cannot drift apart; a tool server runs the component when one is installed under the name and the native fetch otherwise, and says which it serves. Still open: registration in llmao's registry (#25), and whether a launcher on a laptop should also run fetch natively, which would end `amem tap install fetch` there. Third cut, 13 October, the browser as the third host: a laptop's owner is signed in to the pages a session needs, the wiki, the tracker, the list archive behind a login, and copying those logins into grants as credentials is the wrong way to let a session read them. So a tool whose grant says `browser = true` is fetched by the owner's own browser: the companion, a browser extension beside the web launcher's page (`crates/web/companion`, Manifest V3, loaded unpacked), holds one websocket to the launcher, paired with the key the page hands it over its own window once the page itself is paired; a fetch asks the launcher at `/api/companion/fetch` with the token, which no page holds, so a page's key cannot drive the browser's cookies anywhere; the extension fetches with the browser's credentials, to the hosts the ask names and nowhere else, and answers with the status, the content type, the URL that answered and the body as text, cut at the cap. The guarantees stay where they are: the memory server decides the URL by the session's policy before the browser is asked (`crate::browser` in the launcher, through `Memory::check_reached`, as a request is decided before it leaves), the ask carries `Memory::fetch_hosts`, the URL that answered is decided again before anything is kept, the answer is shaped by `dm_fetch::answer` as the other two hosts' are, scanned, and kept as a source observed by `browser`; a tool server never serves a browser tool; the page shows the companion beside its pairing. Proven end to end in `crates/memory/tests/browser.rs` with a companion standing in for the extension (a URL off the grant refused before the browser hears of it, an answer from a host the session may not reach not believed and not kept, the second call answered from the source) and in `crates/web/tests/companion_browser.rs` with the real extension in headless Chromium: the browser signs in to a site, and the session's fetch of it is signed in. What the browser does not give, said plainly: the ask goes to the web launcher on loopback, not through the egress, so it is not metered or pinned there and the ceiling is the grant's alone; and the browser's whole cookie jar is reachable in principle, held to the grant's hosts on both sides, the memory server's and the extension's. Only while `amem` runs with a companion connected; Chrome and Chromium today, not in a store. Held on the way, 16 October: the third cut's redirect limit, a page on a granted host sending the browser to any host with that host's cookies and the request made before the companion saw where it went, is closed with the browser's own request rules. While an ask is in flight the extension keeps session rules of `declarativeNetRequest` that block every request of its own to a host the ask does not name, one block rule on the extension as initiator and an allow rule above it per host, as a regular expression over the URL since `requestDomains` can say neither a port nor keep the apex out of `*.example.org`; a redirect elsewhere is stopped by the browser before it is made, the site never hears it, and the refusal says where it was going, read from `webRequest`. Probed first in headless Chromium, which settled three things: the rules do match the extension's own fetches, an IP is a domain to them, and `initiatorDomains` set to the extension's id scopes them so no tab of the owner's and no other extension is touched; the rules are rebuilt from the asks in flight, so none outlives its ask and a worker that starts clears what one that died left behind. The same turn gave the browser the `tabs` permission and the `fetch` tool a `render` argument: the page is opened in a tab of the owner's browser, not shown, held to the hosts by the same rules on the tab's frames, read as its scripts built it once two reads a half second apart agree (a tab the owner is not looking at runs timers on whole seconds, so the first read waits one out) and closed; what the page itself loads, scripts and images from wherever, is the page's own, as when the owner opens it. Only the browser renders: the launcher and a tool server refuse the argument rather than fetch the bytes and call them rendered, and a rendered page is a source of its own. The real-Chromium test now shows the redirect stopped with the site's own count of requests under the other name at zero, a page that is a shell until its script runs read as built, and a page whose script leaves the hosts stopped at the navigation; the launcher test shows the argument carried to the ask and refused off the browser. What the egress would add, pinning and a decision per hop, was weighed and not taken: a browser's proxy is per profile, so routing the companion's fetches through the egress would route the owner's own clicks on those hosts with them, under the session's label | The tool is the contract and its host a deployment choice, as the record is behind OpenDAL and the index behind its crate: a laptop carries everything, a managed environment carries nothing but the launcher, and the policy, provenance and telemetry are the memory server's in both | | M10, first rung done 9 October | The librarian. `amem librarian` surveys a domain and the read log and proposes curation as JSON, writing nothing: summaries the index should not serve, duplicate titles, raw entries past review or older than a window, oversized bodies, and questions searched for more than once that found nothing; `--file` makes each a low-priority task with `origin: librarian:` and a hash for dedup, for `amem work` on a cheap model to do with the memory server's own tools, every write an entry that `supersedes` another, a verify proposing and never promoting. Second rung, 9 October: the checker, `amem check`, on a destination and model of its own, samples a domain and asks whether each summary entails its body, writing nothing, a rejected summary becoming a task with the checker's model and reason; canaries, `amem canary plant` and `check`, planted entries with recorded digests, a check that exits 3 and files an owner's task when one is altered, superseded, demoted or erased; and the source-change trigger, an entry citing `file:` proposed for a verify when the file changed after it was written. Third rung, 10 October: the read log names the entries each read served, and the survey takes the paper's retrieval signals from it, entries searches return together three times as a merge candidate and a body opened three times after its summary was served as a re-summarise; `amem check --contradictions` puts each sampled entry beside the two a search for its title returns and files a reconcile task when the model says they cannot both hold; `amem librarian --telemetry` is the watch on the watchers, computed from the record and the ledger rather than written by any librarian, per author the curations, summary-only against body-changing rewrites and how far the body moved, demotions and tasks completed, with `body-drift`, `body-heavy` and `demotion-rate` alarms filed as owner's tasks; and `amem curate` is the schedule and the budget, one cycle of survey, checker, hunt, canaries and telemetry under `--budget-tasks` open tasks and `--budget-tokens` as the egress meters them, logged to `state/curation.jsonl`, with `--every` for the schedule. Curation tasks are admitted where the domain admits its own. Proven on a seeded record: a cycle on the gateway's gemma filed eight tasks for 2.4k tokens, Codex on the gateway's Qwen worked them through the memory server's tools, and the telemetry counted the result | The paper's eighth principle: curation is permanent work, and a librarian proposes within structure rather than vigilance | -| M11, first cut done 9 October | Evaluation. `scripts/eval.py` runs a TOML set of prompts, each with the regular expression its answer must match, under two conditions, the harness through `amem launch` with the record and the same harness bare, and across harnesses and models; it grades the answers and reads the tokens each harness counted from its own transcript, so both conditions are measured alike, and writes JSON and a table. `evals/demo.toml` asks four things only the demo record knows. Four conditions: `memory`; `empty`, the same launch over an empty record, so the only difference is what the record holds; `bare`, the harness as installed, web and shell included; `isolated`, bare with web, shell, subagents and files off. Measured 9 October, four questions each: Claude Code on a Max login, with memory 4 of 4 in 34 seconds on 294k cached tokens, over an empty record 0 of 4 in 55 seconds on 357k, bare 3 of 4 in 391 seconds on 978k rediscovered from GitHub, isolated 0 of 4; on the Foundation gateway with Qwen, Codex with memory 4 of 4 in 74 seconds on 18k input and 157k cached against 0 of 4 in 140 seconds on 21k and 296k over an empty record, Claude Code 4 of 4 in 97 seconds on 229k against 0 of 4 in 214 seconds on 741k; Codex on its own login with the set's preamble, with memory 3 of 4 by the grader and 4 of 4 on reading, over an empty record 0 of 4, and without the preamble it never asked the memory, which is why task sets carry one; bare Codex on the gateway, given a home without its login and a provider pointing at the gateway, 3 of 4 in 25 minutes by rediscovering the answers from this machine's disk, at hundreds of millions of cached tokens by its own count; bare Claude Code does not reach that gateway without the launcher's egress. The empty condition is the clean comparison: everything the same but the record's contents. Next: a judge model for answers a regex cannot grade, task sets per domain, and the savings report joined to the eval's sessions | The paper's claim is that memory changes outcomes and cost; a claim is a measurement | +| M11, first cut done 9 October | Evaluation. `scripts/eval.py` runs a TOML set of prompts, each with the regular expression its answer must match, under two conditions, the harness through `amem launch` with the record and the same harness bare, and across harnesses and models; it grades the answers and reads the tokens each harness counted from its own transcript, so both conditions are measured alike, and writes JSON and a table. `evals/demo.toml` asks four things only the demo record knows. Four conditions: `memory`; `empty`, the same launch over an empty record, so the only difference is what the record holds; `bare`, the harness as installed, web and shell included; `isolated`, bare with web, shell, subagents and files off. Measured 9 October, four questions each, two of which changed with the case study since: Claude Code on a Max login, with memory 4 of 4 in 34 seconds on 294k cached tokens, over an empty record 0 of 4 in 55 seconds on 357k, bare 3 of 4 in 391 seconds on 978k rediscovered from GitHub, isolated 0 of 4; on the Foundation gateway with Qwen, Codex with memory 4 of 4 in 74 seconds on 18k input and 157k cached against 0 of 4 in 140 seconds on 21k and 296k over an empty record, Claude Code 4 of 4 in 97 seconds on 229k against 0 of 4 in 214 seconds on 741k; Codex on its own login with the set's preamble, with memory 3 of 4 by the grader and 4 of 4 on reading, over an empty record 0 of 4, and without the preamble it never asked the memory, which is why task sets carry one; bare Codex on the gateway, given a home without its login and a provider pointing at the gateway, 3 of 4 in 25 minutes by rediscovering the answers from this machine's disk, at hundreds of millions of cached tokens by its own count; bare Claude Code does not reach that gateway without the launcher's egress. The empty condition is the clean comparison: everything the same but the record's contents. Next: a judge model for answers a regex cannot grade, task sets per domain, and the savings report joined to the eval's sessions | The paper's claim is that memory changes outcomes and cost; a claim is a measurement | | M5e, first cut done 5 October | Subagents on the board. A harness that would spawn or orchestrate subagents, Claude Code's `Agent` and `Workflow` tools and Codex's `spawn_agent`, is caught by the pre-tool hook when the launcher runs with `--subagents board`, the default in `work`: each prompt is filed on the ledger as a task of its own, born proposed, and the call is refused with the task ids, so the harness carries on with its own part and the board runs the rest. A `Workflow` script is read for its `agent()` calls, one task per literal prompt titled by its `label`, and a script the hook cannot read is one task holding the script. The orchestration the script would have done, the fan-out, the waits and the merges, is the board's: a task's result comes back as a handoff, which is M5d's question again, since the filing session is the one that wanted the answer. Codex, 10 October: its hooks name the spawn `collaborationspawn_agent` and hand over the message encrypted, so the hook refuses the call with an instruction to call `fan_out` itself, which Codex does, prompt in the clear; proven on its login, and the chain then crossed harnesses: Claude Code worked the child Codex filed, and Codex resumed with Claude's handoff in its prompt. Open: Muse's and Gemini CLI's subagent tools, whose names a run has to show | A multi-agent run inside one harness is orchestration the task board already does, with leases, handoffs and a record; doing it twice would hide half the work from the ledger | | M8, dry-coded 11 October | macOS. CI builds and tests on macOS, and the unconfined launcher works there. Confinement is Seatbelt (`sandbox-exec`) with a profile generated from the plan, written without a Mac and not yet run on one. Done: where the plan puts things inside is a `Layout`, a property of the backend, so the namespaces backend keeps its fixed paths (`/home/user`, `/opt/memory/memory`, `/run/memory/egress.sock`, `/memory`) and Seatbelt's layout has the private home, the socket, the memory binary and the projection at their host paths, with the harness's configuration and hook commands written against them and `TMPDIR` under the private home; the relay listens on a loopback port picked per session, since the host's loopback is shared. The profile is a pure function of the plan, unit-tested on every platform: deny by default; fork, exec and a few named system services; the system read-only (`/usr`, `/bin`, `/sbin`, `/System`, `/Library`, `/private/etc`, the old dyld cache and zoneinfo); metadata only for the directories on the way to a grant; the harness's install, the memory binary and the projection read-only; the private home and the working tree read-write; no network but the egress socket and the relay's port; paths escaped as SBPL strings, a relative path refused, and a grant of the host's home or anything above it refused. `auto` picks it on macOS, and `--backend seatbelt` names it. Waits for a Mac: a first confined run of each harness, which will show the system paths, devices and services the profile still lacks; the two tests that run `sandbox-exec`, a confined read and write inside and outside a grant, which compile only there; whether the socket's `path-literal` matches the path as named or as resolved; and the relay's port, which another process on the host could take between the pick and the listen, and which any local process can reach while the session runs | A Mac is what most of the room will carry; unconfined is honest for a demo, confined is the product | | M4, first cut done 3 October | The browser reader. `crates/reader` is the core crate for a browser: parsing entries and manifests and checking digests with the same code the server runs, bound to JavaScript with wasm-bindgen, which compiles under the workspace's forbid on unsafe code; `make reader` builds it into `crates/web/static/reader/`, which is committed so the web crate embeds it like the vendored libraries. The page, `/reader` under `amem web` or the same files served over HTTP anywhere, loads a bundle by URL or from a folder picked in the browser, refuses an entry whose digest does not match the manifest before indexing it, as import does before writing, renders an entry's Markdown through DOMPurify so somebody else's bundle cannot script the page, and searches with the server's own index in the browser: SQLite with FTS5 compiled to WebAssembly, vendored from the npm registry, the same schema, tokenizer and bm25 weights as `crates/index`, phrases ANDed with any-word as the fallback, summaries first, a handling picker that filters by label, and entries rendered with the same Markdown as the reading pane. Under `amem web` the page also offers the record's own domains: `/bundle//