Version: 0.4.2
Last Updated: August 2026
Developer: Kartik (NullVoider)
✨ What's new in 0.4.2 — record fidelity. The recorder no longer trusts an agent known to misreport what it typed, and says so when a stored command looks cut short. No wire, storage or CLI change; a session recorded by 0.4.1 is read identically.
- The Control-Center agent version is now gated, not just the server's. Below 1.2.2 the agent rebuilt its report of a typed command by scanning for a closing quote, so anything typed with a quote in it was stored truncated there. The keystrokes actuated correctly and the artifact on disk was byte-exact — only the record was damaged, which is exactly what let it survive: every self-reporting signal said success. One corpus session carried the damage and it was found a month later while annotating, not at capture. The existing gate could not have caught it — it reads
GetServerIdentityat connect and the defect is in the agent, so a current server fronting an old agent passed, and the server/agent comparison only warned when the two differed. A matched 1.0.0 pair went through in silence. A second floor,RECORD_FIDELITY_MIN(1.2.2), is checked against the agent version and refuses the session;control_center_allow_unsupportedoverrides it. It is deliberately not a raise of the connect floor: 1.0.0 connects and actuates fine, and collapsing "can we drive it" into "can we trust what it says" is what let this through. The check reads the first event carrying a version — including the heartbeat an idle agent sends every five seconds — so it normally refuses before a single step has been written.- A recorded command that looks cut short is flagged in
metadata.jsonand shown while annotating. The version gate closes a known defect in a known release; this notices an unknown one. Arecord_suspectobject on the step names which check fired and what to do about it, and the annotation pane shows a banner. It is advisory throughout: it never alters the recorded command, never fails a step, and never reachesreasoning.jsonlor the compiledmemory.md— repairing a damaged record is a person's job, done from the frames and stamped with its provenance, because a guess written by the recorder would be indistinguishable from a reading. The signal is the shape the known truncations actually left, and the discriminator against a Windows path ending in\is what precedes it: whitespace means a cut, a word character means a path separator. Across all 164 typed payloads in the recorded corpus it flags none of them, and it flags all three known truncations.- The compatibility matrix now drives a real agent. Every case before this ran server-only, and
agent_versiondoes not exist on the wire until an agent registers — the server stamps an empty string on its heartbeats until then. So the matrix could not reach the fidelity gate at all, which is the gap that let the truncation ship. The staging script now lays downcontrol-center-agentfrom the same archive it already downloads, and three cases cover the refusal, the accepted path and the override. Two harness bugs surfaced immediately: the pinned 5s silence timeout exactly equalled the server's heartbeat interval, so a test that needed to receive a heartbeat raced it and lost; and the accepted path had no log line of its own, so asserting on the absence of a refusal would have passed even when no version ever reached the gate. Earlier in 0.4.1 — editor fixes found while annotating:- Pasting over selected text replaces it.
Ctrl+AthenCtrl+Vin the reasoning editor left the selection in place and inserted the clipboard immediately after it, with no separator — the opposite of what select-all-and-paste means everywhere else, and the exact operation an annotator performs to replace a draft. The override inserted at the cursor rather than replacing the selection range, and after select-all the cursor sits at the end of the document. A right-to-left drag selection is handled, an empty selection still inserts at the cursor, and the cursor now follows the pasted text.- The compile editor reaches the system clipboard. It had no paste binding of its own, so
Ctrl+Vfell through to Textual's, which reads the in-app clipboard and nothing else: text copied anywhere outside the TUI had nowhere to land inmemory.md, the document the compile step exists to produce. Both editors now share one paste path, so the source and the selection-replacement semantics cannot drift apart again.Ctrl+Aalso means select-all in both —TextAreabinds it to cursor-to-line-start, which only the reasoning editor had overridden.- Cut, undo and redo work when the reasoning editor is reached by
Tab. The outer editor widget is focusable in its own right, and only keys bound on it reach the inner text area in that state.Ctrl+A,Ctrl+CandCtrl+Vwere bound there;Ctrl+X,Ctrl+ZandCtrl+Ywere not — while the widget's own documentation listed all of them as working regardless of which half held focus.- An annotation ending in a newline no longer leaves its step permanently unsaved. Saving persists
text.strip()and stores that as the baseline, but the dirty check compared it against the raw buffer, so oneEnterat the end of a paragraph never matched: autosave rewrotereasoning.jsonland fired an IPC progress notification every 2.5 seconds for the rest of the session, and quitting always claimed unsaved changes. Nothing was written wrongly — the repeated writes were idempotent — but a confirmation prompt that fires every time teaches an annotator to dismiss it unread.Earlier in 0.4.0 — Control-Center 1.3.0 support, and the record fidelity work that goes with it:
- Control-Center 1.3.0 is supported, verified by the compatibility matrix against real 1.0.0 through 1.3.0 server binaries. 1.3.0 adds a modifier grammar for mouse actions (
770 310 ^+left), drag waypoints,middleworking on macOS for the first time, and ahere dragthat reports its destination. The.protois untouched, so the wire shape is identical.- A gesture reaches the record as the gesture that was performed. A waypoint drag records every point in visit order and replays as a runnable command; modifier prefixes survive into both the record and the replay command;
middle,triple,scroll_leftandscroll_rightare named rather than falling through to a generic label. Modifier names now follow the reporting OS — the same#key isCmdon macOS,Winon Windows andSuperon Linux — where previously only the macOS name was recognised and a modified click on the other two recorded as a plain one. Previously ahere dragrecorded its origin instead of its destination, theclickalias recorded asPerformed action at at, and a drag's replay command was the agent's English sentence, which Control-Center cannot execute.- The annotation TUI shows the record, not a derived view of it.
metadata.jsonis a session's record;commands/raw_input.md,converted_input.mdandactuation_commands.jsonare projections of it written for reading, and they diverge from it two ways — they go stale when a value is corrected in metadata after the fact, and a markdown table delimited by|cannot hold a piped shell command without escaping. The loader read metadata and then overwrote it from those files, andstep_list.pycarried a second parser that read them again to build the visible row titles. So a drag whose origin was recovered by hand still displayedHold at (1393, 810)for what metadata recorded asDrag from (344, 221) to (1393, 810), andGet-ChildItem -Filter *invoice* | Copy-Item -Destination found\displayed cut at the pipe: still a valid command, no longer the one that ran, and no error anywhere. Metadata is now authoritative, the second parser is gone rather than repaired, ma-core escapes|and folds newlines when writing a cell, and the reader splits on unescaped delimiters only — so rows written before the escape existed read back whole.[FAILED]is stripped where it was leaking into a step's command, and positional mapping fromactuation_commands.jsonnow refuses on a length mismatch instead of attaching one step's command to another. Nothing already written was wrong — the save path always usedStepState— but an annotator reasons from what is displayed, and a wrong label produces a correct-looking annotation of an action that never happened.- Windows drag positions were wrong, and said they were verified. Control-Center's Windows actuation is asynchronous, and the agent's readback was checked against the first coordinate in the command — so a coordinate drag reported its origin as though it were the endpoint, a
here dragreported the pre-drag position, and both carriedposition_captured: true. Fixed in 1.3.0. Sessions recorded before it are unaffected in storage but their drag positions mean the origin;actuation_agent_versionis what tells them apart.mouse_x/mouse_yhas always meant the destination on macOS and Linux.- The compatibility matrix could go green having stopped testing the newest release. It parameterises over what is staged on disk while the staging script treats a failed download as a warning, so a failed download dropped a test row and the suite still exited 0 — and because the provenance test targets the newest staged release, losing that one silently retargeted it at the previous version. Staging now records what it discovered and
MA_INTEGRATION_STRICT=1fails when anything discovered did not stage.action_subtypeis bounded. It is client-controlled free text and was rendered straight into a record label with no length or character limit; it is now capped and character-restricted, and a drag path is bounded at eight points with an over-limit marker that carries no wire data at all.Earlier in 0.3.4 — six defects found during an annotation and compile pass, plus copy-on-select:
Ctrl+Ffinalizes the compiled memory.Ctrl+Dnever reached the finalize action: Textual resolves a key against the focused widget's bindings before the screen's, and the compile editor is aTextArea, which bindsdelete,ctrl+dto delete-right. So the key meant to lock the draft was silently editing it — one character per press — and the confirm dialog never opened.Ctrl+Fis unclaimed byTextArea,Input,Button,AppandScreen, and is boundpriority=Trueso a focused editor cannot shadow it. It sits on the screen rather than the App because priority bindings resolve against the top screen's chain, and an App copy would re-fire with the confirm overlay open and stack a second dialog.Ctrl+Qstill only quits — it saves the draft and leaves the session resumable atpending_compilation.- Selecting text with the mouse copies it, in every region of the TUI. No region could be copied before. A terminal application normally leaves the mouse to the terminal, whose own drag-to-select does the copying; Textual asks the terminal for mouse reporting instead, which takes selection away from it and makes it the application's problem — and Textual then implements selection twice. The screen-level model covers static content but records nothing inside
TextAreaandInput, which capture the mouse to run their own: the compile editor, the reasoning editor and the jump box, the panes where copying matters most. Both models are now read, screen first, origin widget second.Ctrl+Cinside an editable region copies too, becauseApp.copy_to_clipboardis overridden rather than called and every built-in copy funnels through it. The selection goes to the system clipboard as well as OSC 52, so it pastes into other applications, not just back into the TUI. A plain click selects nothing and copies nothing, so clicking never clears a clipboard filled elsewhere. The helper runs on one coalescing background thread: spawning it takes ~64 ms and the handler runs on the event loop, so an inline call would stall the UI on every drag, while a thread per selection let concurrent helpers finish out of order and leave the clipboard holding an earlier selection than the one just made.memory-archive updatecan replace a runningma-core. The POSIX path copied the new binary over the destination in place, which Linux refuses withETXTBSYwhen the target is executing — andma-corenormally is, so updating meant stopping the daemon first. The binary is now written to a temp file and renamed over the target;rename(2)is permitted and the running process keeps its old inode. The temp file is created withmkstemp(O_EXCL, unguessable name, mode 0600) so a pre-planted symlink at a predictable path cannot redirect the write.ma-coreremoves its Unix socket on shutdown. A leftoverma.sockreads as a running daemon and has repeatedly misdirected debugging. The socket and PID file are removed only if the PID file still names the exiting process, so a slow shutdown cannot delete the files of an instance that has already taken over.- Ctrl+N reaches a step it has passed. Step advancement scanned forward only, so a step left pending behind the cursor — what happens whenever annotation starts part-way through a session — was unreachable by keyboard. At the last step it did nothing at all: no navigation, no completion prompt, no message, and no way to finish the session from the keyboard. The scan now wraps to the start and announces the jump.
- Confirm dialogs are navigable and honest. Left/Right move between buttons in every dialog (they were never wired; Textual moves focus with Tab). The focused button is now the one that looks selected — Quit was rendered with a colour fill while Cancel held focus, so the loudest button was not the one Enter would activate. And the key hints are visible again: labels are parsed as content markup, so
Quit [q]had[q]eaten as an unknown tag and rendered asQuit, hiding the only key that closed the dialog.Earlier in 0.3.3 — the annotation TUI no longer stacks a new image viewer on every open:
- Opening a step's image replaces the previous viewer instead of stacking another one.
ImageReview._open_imagespawned the viewer withsubprocess.Popenand never kept the handle, so nothing tracked or closed it and every Enter or click launched another fullscreenfeh. It surfaced as the image opening "in a new tab", and as Escape appearing not to close it — feh binds Escape to quit but quits only the focused instance, so closing the top of a stack of identical fullscreen windows leaves an identical image on screen. The extra window was never useful: all three frames (before,at,after) are already passed to one instance with--start-at. The viewer is also closed when the TUI unmounts, so it can no longer outlive the session, and the tracked handle reaps the child instead of leaving a zombie. The macOS and Windows launchers stay untracked on purpose —openandos.startfilehand off to a separate application and exit, so their handle is not the window.Earlier in 0.3.2 — session registry records no longer expire, and deletion can no longer remove a recording it does not own:
- Session registry records no longer expire. Records carried a Redis TTL — 7 days while pending or annotating, 30 days when incomplete, 90 days once complete. Expiry could never reclaim a session's bytes, because those are the frames on disk and not the 1.8 KB record, so it only dropped the pointer and stranded the payload:
annotate,compileandstatusall resolve a session through that record, and nothing can rebuild one from disk. A 101-session corpus lost 21 records this way — every session from its first fortnight, capture data intact, all of it unreachable.ttl_seconds()now returnsNonefor every status,FinalizeMemoryno longer applies its own hardcoded 90 days, andupdate_statusclears a stale TTL rather than only ever setting one, which is what makes the fix retroactive for records written by earlier versions.- Deletion no longer purges a directory it does not own.
memory_pathdoes not uniquely identify a session: when a scrapped take is re-recorded under the samememory_name, the replacement occupies the same path while the abandoned record keeps pointing at it — so purging by path took the successful recording. Two live examples were found in the affected corpus, each aimed at a complete recording.purge_memory_dirnow requires the directory'smetadata.jsonto name the session being deleted, and leaves anything it cannot prove it owns in place.- Interrupted captures are bounded by a retention sweep, not a TTL.
Incompleteis the one status with a retention limit, at one year. Key expiry runs no application code, so a TTL could only ever drop the record and orphan the frames; the startup sweep removes the record, the stored objects and the directory in one operation, and skips any record it cannot date.Earlier in 0.3.1 — Control-Center 1.2.2 support, and a converter panic that voided a capture session:
- A malformed key string no longer panics the converter and destroys the session.
humanize_keyfound the opening brace withfind('{')but the closing one withfind('}')searched from index 0, so a string whose}precedes its{sliced backwards and panicked. The panic killed the watch loop mid-session: Control-Center reported every step as successful and the session finalised with zero steps recorded. One corpus session was lost before the cause was found. The scan now starts at the opening brace, restoring what the function's doc comment already promised — a malformed key costs a poor label, not a capture.- Control-Center 1.2.2 is supported, verified by the compatibility matrix against real 1.0.0 through 1.2.2 server binaries. 1.2.2 is the first release since the gate landed that touches
crates/server/, so the review was not a formality: the.protois untouched,CommandEventconstruction is not in the diff, and the server changes are command-lifecycle correctness affecting responses for commands that were never delivered — not the content of events for commands that ran.position_capturedkeeps its 1.2.0 meaning.- Windows chord steps are recorded as the chord. Control-Center's Windows controller reported the AutoHotkey transport form, so
press ^sreached the record as{Ctrl down}s{Ctrl up}; 1.2.2 reports the command as issued. Memory Archive needs no change — but Windows sessions recorded either side of that upgrade label chords differently, andactuation_agent_versionis what tells them apart.Earlier in 0.3.0 — Control-Center 1.2.1 support, and a version gate so an unverified Control-Center can never record silently:
- Memory Archive now refuses to record against a Control-Center version it has not been verified against. The capture stream reads
GetServerIdentitybefore subscribing; an unsupported version marks the sessionincompleteand exits non-zero instead of recording under semantics this build does not know. It refuses rather than adapts on purpose: protobuf already absorbs additive wire changes, but a field can change meaning with no change on the wire —position_captureddid exactly that in 1.2.0 — and nothing observable at runtime can distinguish the two. Guessing would turn a loud failure into a silent one, and the artefact is a session that records confidently and wrongly. Two explicit escapes:control_center_max_versionraises the ceiling without a rebuild (it only ever raises), andcontrol_center_allow_unsupportedbypasses the gate while logging the refusal on every connect. A server that reports no version is not refused — Control-Center 1.0.0 predates the check.- A pinned Control-Center CA now excludes the platform trust store. With
control_center_tls_caset, the TLS config previously enabled the system roots and added the configured CA — both inputs are additive — so every publicly trusted CA stayed acceptable for the Control-Center name while the connection looked pinned. Platform roots are now used only when no CA is configured.- Sessions record the Control-Center server version (
actuation_server_version) alongside the agent version, and a mismatch is logged. The two halves install independently, and Control-Center 1.2.1 warns that a split pair fails actuation closed while still reporting success.- Staged Control-Center archives are checksum-verified before extraction, against the
SHA256SUMSControl-Center publishes from 1.2.1 onward. Verification fails closed; releases predating it are reported as unverified rather than blocked.- The compatibility matrix is discovered, not written down — releases are enumerated from the GitHub API and the tests parameterise over whatever is staged, so a new Control-Center release joins the matrix without a code edit. 12 integration tests across real 1.0.0, 1.1.0, 1.2.0 and 1.2.1 servers.
Earlier in 0.2.0 — Control-Center 1.1.0+ compatibility, and a silent capture failure closed:
- Capture works against every Control-Center release (1.0.0, 1.1.0, 1.2.0) from one configuration. Control-Center 1.1.0 made TLS mandatory and put a
monitorscope onWatchCommands; Memory Archive connected in plaintext with no credentials, so every session against 1.1.0+ recorded zero steps. The capture stream now presents a bearer token and negotiates the transport — TLS first, falling back to plaintext only when the transport itself fails, and never silently. Setcontrol_center_token(andcontrol_center_tls_cafor a private CA); see Control-Center compatibility.- A watch that cannot subscribe now fails loudly instead of looking healthy.
memory-archive startpreviously returned success the moment the loop was spawned, before the gRPC subscription was attempted. A failure left the session sitting atactive, recording nothing, with the error visible nowhere — the operator only found out by noticing an empty trace.startnow waits for the event source to come up, exits non-zero with the cause, and marks the sessionincompleterather than stranding itactive.- Frames are no longer marked at a position the pointer never occupied.
mouse_x/mouse_yare non-optional, so an uncaptured position arrives as(0, 0)— a real coordinate, the top-left corner. Control-Center 1.2.0 reportsposition_captured=falsewhenever it cannot verify the readback, which makes that common rather than rare. The click marker is now drawn only when the position was verified; the frame is still captured either way.- Sessions record which Control-Center produced them (
actuation_agent_version,actuation_transportinmetadata.json).position_capturedmeans "best-effort readback" before 1.2.0 and "verified, or false" from 1.2.0 on, so a consumer cannot interpret recorded coordinates correctly without knowing which wrote them.- Installer PATH persistence fixed on both
install.shandinstall.ps1: the entry is now written based on the shell rc file (or the Windows registry), not the current process$PATH, so a shell that already had it exported no longer causes every fresh terminal to miss it.Earlier in 0.13.2: a critical updater hotfix —
memory-archive updateusedpip install --prefixand destroyed the install it was updating. If your install is still broken from that bug,updatealone cannot repair it (the broken copy decides how the next version installs); reinstall once viainstall.sh/install.ps1. Earlier in 0.13.1: a startup-sweep fix so a fully completed recording is never demoted to incomplete after a Redis rollback, and the Unix IPC socket locked to0600. Earlier in 0.13.0: frames for every mouse interaction, interrupted annotations surviving a restart, and an explicit finalize at the compile stage (bound toCtrl+Dthen; it isCtrl+Ffrom 0.3.4, becauseTextAreaclaimsCtrl+Dfor delete-right).
📖 Documentation in progress — Extended documentation covering in-depth deployment guides, architecture deep-dives, and operational runbooks for research teams, AI labs, and enterprise users is currently being written and will be published separately. This README serves as the primary reference in the meantime.
Additionally, a research document covering the memory-grounded CUA training paradigm enabled by Memory Archive — including pre-training, SFT, post-training RL, and inference-time retrieval — has been published. Read it here: Memory Archive: A Memory-Grounded Training Paradigm for Computer Use Agents.
Memory Archive is a production-grade, open-source Rust + Python monorepo that serves as the observational backbone for Computer Use Agent (CUA) training data collection. It monitors a running duo or trio of CUA tools — The-Eyes (screen capture) and Control-Center (actuation agent) — records every action with its full visual context (three screenshots per captured step), routes each step to either a human annotator or a Vision Language Model (VLM) for natural-language reasoning, and compiles the result into a structured memory.md file: a replayable, step-by-step document that a CUA can follow to autonomously repeat the task. Memory Archive is read-only by design: it never sends commands to the OS environment, never modifies the tools it monitors, and treats data integrity as its first priority — every write is atomic, every session is either complete or explicitly marked incomplete, and no state is ever batched or deferred.
- Overview
- Key and Critical Features
- Capabilities Summary
- Technical Specification
- Quick Start Guide
- Security Features
- Usage Modes
- CLI Command Reference
- TUI Usage and Features
- Configuration Reference
- Session Management
- Metrics, Monitoring, and Observability
- Advanced Features
Memory Archive is a training data collection engine purpose-built for the CUA (Computer Use Agent) development and inference lifecycle. It sits alongside running CUA tools — specifically The-Eyes and Control-Center — and silently records every action event the actuation agent performs, paired with the precise visual state of the screen at the moment of that action. After capture, each recorded step is annotated with natural-language reasoning, either by a human reviewer using the built-in TUI or by a VLM in fully automated mode. The final output is memory.md: a structured, step-annotated document that a CUA can ingest and replay to autonomously repeat the captured task.
| Audience | Use Case |
|---|---|
| Individual developers | Capture personal workflow memories on a single machine using local storage; annotate manually; build a private library of CUA-replayable task recordings |
| Research teams | Shared ma-core with cloud-primary storage; multiple annotators claiming sessions from a queue via the remote TUI; consistent provenance tracking across all recordings |
| AI labs | Fully automated mode with multi-provider VLM routing; per-session circuit breakers; signed pricing registry; Prometheus observability; Kafka-backed crash recovery; 100,000+ concurrent sessions per ma-core instance |
| Enterprises | Multi-tenant storage routing, per-annotator access control, audit trails, Ed25519-signed cost manifests, zero-credential annotator machines, TLS 1.3 IPC with fingerprint pinning |
| CUA platforms | Programmatic session registration via IPC; streaming StepReadyForReasoning push events; fully automated pipeline from capture to finalized memory.md without human intervention |
| It is NOT… | Why this matters |
|---|---|
| An actuation tool | It never sends commands to the OS environment; it only reads from Control-Center's stream |
| An orchestration layer | It does not schedule, plan, or coordinate CUA actions |
| A reasoning engine | It calls external VLM APIs; it does not perform its own inference |
| A storage backend | It writes to local disk or delegates to AWS S3, Azure, or GCP — it does not implement its own storage |
| An agent that lives inside the OS | It runs alongside the environment, not inside it |
| A file server | It proxies files to authorized remote annotators; it is not a general-purpose file server |
| A secrets manager | Cloud credentials and API keys are resolved from environment variables, never stored in config.json or transmitted over IPC |
| A certificate authority | It generates a self-signed CA and server cert for its own TLS IPC transport, not for general PKI use |
| A multi-cloud replication layer | It routes each session to one backend at registration time; it does not synchronize across providers |
| A pricing authority | Token cost rates come from an external, cryptographically signed manifest; Memory Archive does not determine pricing |
| Principle | Description |
|---|---|
| Read-only by design | Memory Archive never issues commands to the OS environment, never modifies the tools it monitors, and has no write path into Control-Center or The-Eyes |
| Data integrity above all | Every write to disk or cloud is atomic (temp-file-then-rename); a crash at any point produces an explicitly flagged incomplete session, never a silently corrupted one |
| Incremental persistence | Every event, image, and annotation is persisted immediately and individually; nothing is batched, buffered, or flushed only at session end |
| Capture-first, reasoning-async | In automated mode, VLM API calls are dispatched asynchronously and never pause or delay the capture loop |
| Scale-first | A single ma-core instance is designed to handle 100,000+ concurrent active sessions without architectural changes |
| Secrets never cross the wire | Cloud credentials and VLM API keys never appear in IPC messages, config files, or log output; they are resolved from environment variables at runtime |
| Zero-credential remote annotation | Annotator machines hold no cloud credentials; all file reads and writes are proxied through ma-core over an authenticated, TLS-encrypted channel |
| Per-session circuit breaker isolation | Circuit breaker state lives in per-session objects; one session's VLM failure cannot degrade, open, or affect the circuit breaker of any other session |
| Pricing truth is external and signed | Token cost rates are sourced from a cryptographically signed manifest whose public key and URL are hardcoded in the binary; no user-entered or config-file rate is authoritative |
| Storage backend is pinned at registration | The storage backend for a session is selected and written to Redis when the session is registered; it is never re-evaluated mid-capture |
| Single-instance enforcement | ma-core writes a PID file on startup and refuses to start if a live process already holds that PID, preventing silent data corruption from duplicate writers |
Memory Archive operates in two distinct capture modes. In manual mode, a user performs a task live in the OS environment while Memory Archive records every non-position command event in real time. Annotation is done afterward using the built-in TUI. In automated mode, an orchestration layer registers sessions programmatically, and a VLM pipeline handles all reasoning asynchronously without pausing capture. Both modes produce identical output structures; the source field in reasoning.jsonl distinguishes human from model annotations.
ma-core is a Rust async daemon built on Tokio. All session state — status, metadata, step counters, Kafka offsets — is stored in Redis, not in process memory. This means session count is bounded by Redis capacity, not by ma-core RAM. The capture loop, IPC server, cloud upload queue, VLM pipeline, and Prometheus endpoint all run concurrently without blocking each other. The architecture is validated for 100,000+ concurrent sessions per process.
Every file write — whether to local disk or cloud storage — goes through an atomic path: data is written to a uniquely named temporary file, then renamed into place. On any crash, the temp file is either absent (write never started) or present (renamed to an incomplete marker). No partial-write corruption of finalized files is possible. Sessions that disconnect without a done command are renamed to {memory_name} (incomplete)/ and flagged in Redis with status incomplete.
For each captured step, Memory Archive fetches three frames from The-Eyes: a before frame (1000ms before the event), an at frame (exact event timestamp), and an after frame (1000ms after the event). For mouse events (click, double-click, right-click), the at frame is programmatically annotated in Rust by marker: a filled red circle (radius 6px, #dc2626) is drawn at the exact click coordinates, a directional arrow points from the circle edge toward the quadrant with the most available space, and a coordinate box displays the numeric X: and Y: values. The annotated frame is re-encoded to PNG. For keyboard events, the at frame is not annotated and is passed to the VLM or human annotator for contextual reasoning.
Remote annotators connect to ma-core over TLS 1.3 TCP using only an annotator_id and a 256-bit random key. They never hold cloud credentials. All file reads (FetchFile) and writes (UploadFile) are proxied through ma-core, which verifies the annotator's identity, checks write authority, and performs the actual cloud operation. Annotators receive only the files they are authorized to access for the sessions they have claimed.
Each annotator is registered with a unique identity (annotator_id), a 256-bit random key (only the SHA-256 hash is stored in Redis), a list of allowed tenant ID prefixes (allowed_tenant_ids), and a maximum concurrent claim limit (max_concurrent_claims). Auth failure attempts are rate-limited per annotator. The annotation queue visible to an annotator is filtered by their allowed_tenant_ids. Deactivation preserves full audit history. Key rotation takes effect immediately, invalidating the old key.
A single ma-core instance can route different sessions to different storage backends — AWS S3, Azure Blob/ADLS/Files, GCP Cloud Storage, or local disk — based on configurable routing rules (tenant prefix, region tag, storage mode). The selected backend is pinned to the session at registration time and stored in Redis. Multiple named backends (e.g., aws-us-east, azure-eu-west, gcp-asia) can coexist in a single deployment. All backends support MD5 integrity verification on upload.
ma-app's ModelRouter applies one of three stateless routing policies — pinned, fallback, or load_balance — to select a VLM provider per request. Each session has its own ReasoningPipeline with an independent circuit breaker. A circuit opens after 5 consecutive non-retryable failures and enters a half-open trial after 60 seconds. When both primary and fallback circuits open, the session transitions to reasoning_degraded status, capture continues, and steps are marked source: model_degraded. On session completion, the session auto-enters the human annotation queue. At most two providers per session are permitted — one primary and one fallback — to preserve dataset clarity.
Token cost rates are sourced from an MA-hosted Ed25519-signed JSON manifest. The public key and manifest URL are both hardcoded in the binary and cannot be overridden by config or environment variables. The manifest is cached locally for 24 hours; signature verification is performed on every cache load. A failed signature check deletes the cache and triggers a fresh fetch, and fires an ERROR alert. Fallback tiers include the AWS Bedrock Pricing API and user-configured cost rates in ProviderConfig. Model aliases (e.g., gpt-4o-2024-11-20 → gpt-4o) are resolved via the manifest's aliases[] array.
Each session registration can specify its own capture_server_addr (The-Eyes) and actuation_server_addr (Control-Center), overriding the global config fallback. These per-session addresses are stored in Redis and used throughout the capture lifecycle. This enables a single ma-core instance to serve sessions spread across many different capture and actuation servers — a requirement at scale where The-Eyes and Control-Center may run on different hosts per OS environment.
ma-core exposes a Prometheus metrics endpoint (default port 9091, bound to 127.0.0.1 by default; exposing it on a non-loopback address requires a Bearer token) with 25+ counters and gauges covering: active session count, steps captured per second, cloud upload queue depth and permanent failures, IPC connection counts and push queue depth, Kafka consumer lag, VLM request latency histograms (p50/p95/p99), per-provider error counts by type, circuit breaker state per session, pricing registry fetch status and manifest age, per-annotator active claims and auth failures, and storage routing decisions. Alert conditions are delivered via configurable webhook.
The built-in TUI (built on Textual) provides a two-pane interface: a virtual-scrolling step list on the left and an image preview on the right, with a multi-line reasoning editor, word counter, undo/redo, clipboard support, and autosave every 2.5 seconds. Steps are resumable — previously saved reasoning is loaded from reasoning.jsonl on open. The TUI is followed automatically by CompilerApp, a full-screen terminal text editor for producing and finalizing memory.md. In remote annotator mode, the TUI sends claim heartbeats every 5 minutes and prefetches the next step's images in the background.
Memory Archive ships with a one-line installer for Linux/macOS (install.sh) and Windows (install.ps1), covering both the Rust binaries and the Python wheel. Every download is verified against a published SHA256SUMS manifest before extraction, and archives are unpacked with path-traversal guards. The memory-archive update command downloads and checksum-verifies the latest platform archive, then swaps each binary into place by renaming a temp file over it — so a running ma-core does not block the update, and a partially written binary is never visible at the final path. The temp file is created with mkstemp (O_EXCL, unguessable name, mode 0600), so a symlink planted at a predictable path cannot redirect the write. The memory-archive uninstall command removes the binaries and CLI launcher and optionally purges all local state (--purge). All three paths handle version transitions cleanly without leaving orphaned files.
Session lifecycle:
- Register a new session (manual or automated mode) via IPC
- Start watching a session's command stream
- Signal session completion (
done) - Query full session status from Redis
- Automatic transition from
reasoning_degraded→pending_human_annotationon session completion - Startup sweep: mark sessions with stale heartbeats as
incompleteonma-corerestart - Reconcile sweep: re-queue orphaned
annotatingsessions onma-corerestart
Capture:
- Stream CommandEvent messages from Control-Center WatchCommands gRPC endpoint (TLS +
monitor-scoped token; compatible with Control-Center 1.0.0 through 1.3.0) - Consume CommandEvent messages from Kafka (cloud_primary mode)
- Drop position-only events silently (no file write, no step counter increment)
- Write
raw_input.md,converted_input.md,actuation_commands.json,cc_commands.jsonatomically per step - Mark failed commands with
[FAILED]prefix inraw_input.md - Detect tool silence beyond configurable timeout and trigger graceful disconnect
- Filter Control-Center heartbeat-only messages from the command stream (after reading the agent version they carry)
- Refuse to record against a Control-Center agent below the record-fidelity floor (1.2.2), which stores typed commands truncated at the first quote
- Flag a recorded command that carries a truncation signature into
record_suspecton the step, without altering it or failing the step - Track Kafka partition and offset per session for crash recovery
Image capture:
- Fetch
before,at, andafterframes from The-Eyes per step - Route fetch using
FetchDecisionbased on action type and configurable timing offsets - Skip image fetches for position events and failed commands
- Fetch
closing_state.webpon sessiondone - Log skipped image fetches in
metadata.jsonwithout blocking step record - Annotate mouse
atframes in Rust (marker.rs): filled circle, directional arrow, coordinate box - Re-encode annotated frames to PNG
- Check The-Eyes liveness via configurable poll interval
Storage:
- Write to local disk with atomic temp-file-then-rename
- Upload to AWS S3 (multipart >5MB, MD5 + ETag integrity verification)
- Upload to Azure Blob Storage, ADLS Gen2, and Azure Files (auto-detection, MD5 integrity)
- Upload to GCP Cloud Storage (MD5 integrity)
- Route sessions to named storage backends via routing rules
- Pin storage backend per session in Redis at registration
- Retry uploads with exponential backoff (SyncWorker)
- Permanently fail uploads after max retries and fire an ERROR alert
- Track per-session upload state in
sync_log.json - Flush
metadata.jsonto cloud every N steps (metadata_flush_interval) - Proxy file reads and writes for remote annotators via IPC (zero cloud credentials on annotator machine)
- Download session files for annotation in local mode (
fetch_session_if_missing) - Download session files to temp dir for cloud_primary annotation (
RemoteFetcher)
Conversion:
- Convert raw command strings to human-readable descriptions
- Normalize key names per OS (
humanize_key) - Produce OS-specific human-readable output for keyboard and mouse events
Reasoning — manual/human:
- Open TUI for step-by-step human annotation
- Load existing reasoning from
reasoning.jsonlon TUI open (resumable) - Write reasoning atomically to
reasoning.jsonlviaReasoningWriter - Send
UpdateAnnotationProgressIPC notification per save - Support step skipping (skipped steps logged in
reasoning.jsonl) - Send claim heartbeat every 5 minutes in remote annotator mode
Reasoning — automated/VLM:
- Receive
StepReadyForReasoningpush fromma-coreper step - Route to primary or fallback VLM per session routing policy
- Call any OpenAI-compatible API endpoint (
GenericApiModelBackend) - Call Proprietary VLM API (
InternalModelBackend) - Apply sliding-window rate limiting (requests/min + token budget/hour)
- Track per-session circuit breaker state (threshold, reset interval)
- Transition session to
reasoning_degradedwhen circuit opens - Return
ReasoningResulttoma-corevia IPC - Send
automatedcommand to start VLM daemon per session
Compilation:
- Generate
memory.mdscaffold fromreasoning.jsonl(run_compile) - Open
CompilerAppfor full-screen terminal editing - Finalize session (status →
complete,FinalizeMemoryIPC) on explicitCtrl+F
Remote annotation:
- Register annotators via admin CLI or REST API
- Deactivate annotators (audit history preserved)
- Rotate annotator keys (immediate effect)
- List all annotators with status and claim counts
- Generate base64 connection profile strings for annotator onboarding
- Set up annotator config from profile string on annotator machine
- List annotation queue (filtered by
allowed_tenant_ids) - Claim specific or oldest available session
- Release session claim
- Auto-heartbeat claim TTL renewal
- Detect and handle claim expiry mid-annotation
Observability:
- Expose Prometheus metrics on configurable port with optional Bearer token auth
- Emit structured JSON logs (capture, IPC, storage, VLM subsystems)
- Deliver webhook alerts for all defined alert conditions
- Track pricing registry fetch status, manifest age, and signature verification result
- Track per-annotator active claims and auth failure rates
- Track storage routing decisions and backend errors
Server management:
- Start
ma-corein foreground or daemon mode - Stop
ma-corevia SIGTERM / taskkill - Stream or tail
ma-corelogs - Start
ma-kafka-producerKafka bridge for a session - Generate and display TLS cert fingerprint
- Enforce single-instance via PID file
Install/update/uninstall:
- One-line install on Linux/macOS and Windows
- Check for updates without downloading
- In-place binary update
- Uninstall with optional full state purge
Session Lifecycle Management. Every Memory Archive recording begins with a session registration IPC call that creates a Redis hash for the session and initializes the memory directory structure on disk or cloud. The session moves through a well-defined state machine — active → pending_annotation → annotating → pending_compilation → complete — with defined exception paths for incomplete disconnects and VLM degradation. Redis Set indexes (sessions:active, sessions:pending, etc.) allow O(1) membership checks and fast queue operations. On ma-core startup, a startup sweep inspects all sessions with active or annotating status and marks those whose associated processes are no longer running as incomplete or re-queues them. A reconcile sweep re-queues any orphaned annotating sessions that have no live heartbeat. Session records do not expire — the record is the only index from a session id to its capture on disk, and nothing can rebuild one, so a TTL would strand the recording rather than reclaim anything. Records are removed explicitly, through delete_session, which drops the Hash, every index set, the stored objects and the directory together. incomplete is the one status with a retention limit, at one year, enforced by a startup sweep rather than a TTL so that the record and the frames are removed in the same operation.
Capture and Command Processing. The capture loop (run_watch_loop) supports two event sources — direct gRPC streaming from Control-Center or Kafka consumption (cloud_primary mode). In gRPC mode, the WatchStream connects directly to the Control-Center WatchCommands endpoint, presenting a monitor-scoped bearer token and negotiating the transport (see Control-Center compatibility); the stream must be established before start reports success, so a session can never come up active while recording nothing. In Kafka mode, StreamConsumer subscribes to the control-center-events topic, consuming from the partition keyed to the session's session_id. Position-only events are silently dropped; heartbeat-only messages from Control-Center are filtered before any processing. For each non-position event, the capture loop writes four files atomically: raw_input.md (raw command string), converted_input.md (human-readable), actuation_commands.json (full CommandEvent JSON), and cc_commands.json (Control-Center replay format). The convert module normalizes key names per OS (humanize_key) and generates grammatically correct human-readable descriptions. Silence detection monitors the time since the last non-position event; if the configurable silence_timeout_seconds elapses without activity, the session is gracefully disconnected.
Three-Frame Image Capture and Annotation. For each step, VisionPipeline evaluates a FetchDecision — based on action type, event timestamp, and per-type timing offsets — and issues three HTTP requests to The-Eyes' /frames/closest endpoint. The before frame is fetched at timestamp - 1000ms, the at frame at the exact event timestamp (plus a press or type delay for keyboard events), and the after frame at timestamp + 1000ms. For mouse events, marker.rs annotates the at frame in Rust: a filled red circle is drawn at the click coordinates, a directional arrow is drawn from the circle edge toward the least-crowded screen quadrant, and a coordinate box is rendered with the numeric X/Y values. The annotated frame is re-encoded to PNG. Before/after frame fetch failures are logged in metadata.json under skipped_image_fetches[] but do not block the at frame or the step record. A closing_state.webp is fetched on the done command.
Multi-Cloud Storage with Routing. StorageRouter selects a named backend at session registration time by evaluating routing rules against session attributes (tenant prefix, region tag, mode). The backend name is stored in the Redis session hash and is never re-evaluated. Each named backend (LocalBackend, S3Backend, AzureBackend, GcpBackend) implements the StorageBackend trait and handles its own credential resolution, integrity verification, and retry logic. In local mode, SyncWorker runs as a background thread, dequeuing FileWritten events and uploading to cloud with exponential backoff. Permanent upload failures fire an ERROR webhook alert. Cloud session read-back for annotation (fetching a session from cloud to a temp dir) is handled by RemoteFetcher for remote annotators and fetch_session_if_missing for local annotation.
VLM Reasoning Pipeline. In automated mode, ma-core emits a StepReadyForReasoning IPC push event per step. ma-app's ReasoningPipeline receives the push, applies the session's routing policy via ModelRouter, and dispatches the request to the selected ModelBackend — either GenericApiModelBackend (any OpenAI-compatible endpoint) or InternalModelBackend (Proprietary VLM). Rate limiting is applied via a sliding-window RateLimiter across both requests_per_minute and token_budget_per_hour. The per-session circuit breaker in ReasoningPipeline counts non-retryable failures; at the threshold, it opens, all subsequent requests for that session return model_degraded, and after circuit_breaker_reset_seconds, a single half-open trial request is sent. If the trial succeeds, the circuit closes. On ReasoningResult return, ma-core writes the reasoning to reasoning.jsonl, updates metadata.json token counts, and advances the session.
Record Fidelity Gate. Two independent defences against a trace that looks complete and is not. First, capture/compat.rs carries a second version floor, RECORD_FIDELITY_MIN (1.2.2), evaluated against the Control-Center agent version rather than the server's: below it the agent reverse-engineered its own report of a typed command by scanning for a closing quote, so a command containing one was stored cut at that point while actuating perfectly. The agent version is not available at connect — GetServerIdentity describes the server and ConnectionMetadata carries no agent field — so it is read from the first event bearing one, including the heartbeat an idle agent produces every five seconds; the refusal therefore normally lands before any step is written. control_center_allow_unsupported overrides it, and still logs. Second, capture/fidelity.rs inspects every typed payload for the shape a cut leaves — a trailing lone backslash, or an odd number of unescaped quotes — and attaches a record_suspect object to the step in metadata.json, surfaced as a banner in the annotation pane. It is advisory by construction: it never rewrites the recorded command, never fails a step or a session, and never propagates into reasoning.jsonl or the compiled memory.md, because repairing a damaged record is a human judgement made from the frames and a guess written by the recorder would be indistinguishable from a reading.
Human Annotation TUI. AnnotationApp is a Textual-based terminal application providing a two-pane interface: a virtual-scrolling StepList on the left (with step status icons and accordion expansion) and an image preview pane on the right. The ReasoningEditor widget supports multi-line input, word counting, undo/redo with Ctrl+Z/Y, clipboard, and autosave every 2.5 seconds. SessionLoader reads all existing reasoning.jsonl entries and metadata.json on open, pre-populating the step list; metadata.json is authoritative for every step's raw and converted command, and the files under commands/ are consulted only to fill a value metadata does not carry. Nothing else parses them — the step list renders StepState and reads it at render time, so a row cannot display a value the loader has since resolved differently. ReasoningWriter performs atomic upserts to reasoning.jsonl. After all steps are annotated, AnnotationComplete overlay offers to launch CompilerApp immediately. CompilerApp is a full-screen terminal text editor with autosave, a CompilerStatusBar showing word count and save state, and a quit overlay. On save, it calls FinalizeMemory IPC, setting session status to complete.
| Tier | Description | ma-core RAM | Redis | Kafka | Storage | Network |
|---|---|---|---|---|---|---|
| Development | Single developer, local mode, no cloud | 512 MB | Single node, in-memory | Not required | Local disk | LAN |
| Team | Small team, cloud sync, remote annotation | 2 GB | Persistent (AOF + RDB snapshots) | Not required | One cloud provider | 1 Gbps |
| Production | AI lab / enterprise, 100k+ sessions, automated | 16–64 GB per ma-core |
Redis Cluster or Redis Enterprise | Kafka 3.0+ KRaft, 200-partition topics | Multi-region cloud | 10 Gbps+ to cloud storage |
| Component | Requirement |
|---|---|
ma-core |
Rust 1.85+ (build only); Redis 7.0+; Kafka 3.0+ (cloud_primary mode); cloud credentials in env (cloud_primary); MA_IPC_TOKEN env var (if ipc_port set); MA_ANNOTATOR_MGMT_TOKEN env var (if annotator_mgmt_port set) |
ma-app |
Python 3.13; pip; network access to ma-core (Unix socket locally, TLS TCP remotely) |
ma-kafka-producer |
Rust 1.85+ (build only); access to Control-Center gRPC endpoint; access to Kafka broker |
| Annotator machine | Python 3.13; pip; network access to ma-core TLS TCP port; no cloud credentials required |
| Platform | Architecture | Notes |
|---|---|---|
| Linux | x86_64 | Ubuntu 22.04+; glibc 2.35+ |
| Linux | arm64 | Ubuntu 22.04+; cross-compiled via cross crate |
| macOS | x86_64 | macOS 12 (Monterey)+ |
| macOS | arm64 (Apple Silicon) | macOS 12 (Monterey)+ |
| Windows | x86_64 | Windows 10 1809+; Windows 11 |
| Backend | Authentication | Multipart | Integrity | Min SDK/API |
|---|---|---|---|---|
| Local disk | OS filesystem permissions | N/A | Atomic rename | N/A |
| AWS S3 | Env vars / IAM role / ~/.aws credential chain |
Yes (>5 MB) | MD5 pre-upload + ETag verification | aws-sdk-s3 (Rust) |
| Azure Blob | Service principal / IMDS managed identity / Azure CLI | N/A | MD5 | REST API 2026-02-06 |
| Azure ADLS Gen2 | Service principal / IMDS managed identity / Azure CLI | N/A | MD5 | REST API 2026-02-06 |
| Azure Files | Service principal / IMDS managed identity / Azure CLI | N/A | MD5 | REST API 2026-02-06 |
| GCP Cloud Storage | Application Default Credentials | N/A | MD5 | google-cloud-storage (Rust) |
| Backend type | API compatibility |
|---|---|
GenericApiModelBackend |
Any OpenAI-compatible /v1/chat/completions endpoint |
InternalModelBackend |
Proprietary VLM API |
| AWS Bedrock (pricing only) | pricing:GetProducts IAM action |
| Dependency | Version | Component | Purpose |
|---|---|---|---|
| Redis | 7.0+ | ma-core |
Session state, annotator registry, claim TTLs |
| Kafka | 3.0+ KRaft | ma-core (cloud_primary) |
Event streaming, crash recovery |
tokio |
1.x | ma-core |
Async runtime |
tonic |
0.12+ | ma-core, ma-proto |
gRPC client |
rcgen |
0.13+ | ma-core |
Self-signed TLS cert generation |
reqwest |
0.12+ | ma-core |
HTTP client (The-Eyes, Azure REST) |
aws-sdk-s3 |
latest | ma-core |
S3 operations |
google-cloud-storage |
latest | ma-core |
GCS operations |
rdkafka |
0.36+ | ma-core, ma-kafka-producer |
Kafka client |
prometheus |
0.13+ | ma-core |
Metrics exposition |
tracing |
0.1+ | ma-core |
Structured logging |
Textual |
0.60+ | ma-app |
TUI framework |
Typer |
0.12+ | ma-app |
CLI framework |
boto3 |
1.34+ | ma-app |
Python S3 client (SyncWorker) |
azure-storage-blob |
12.19+ | ma-app |
Python Azure client (SyncWorker) |
google-cloud-storage |
2.16+ | ma-app |
Python GCS client (SyncWorker) |
protoc |
any stable | build | Protobuf compiler |
| Message direction | Transport | Auth tier | Purpose |
|---|---|---|---|
CLI → ma-core (local) |
Unix domain socket | None (file permission) | All local admin commands |
CLI → ma-core (remote admin) |
TLS 1.3 TCP | MA_IPC_TOKEN Bearer |
Remote admin operations |
Annotator machine → ma-core |
TLS 1.3 TCP | AnnotatorAuth JSON (first message) |
Annotation session operations |
ma-core → ma-app (push) |
Same transport as initiating connection | N/A (push on established conn) | StepReadyForReasoning, FileWritten, session events |
curl -fsSL https://raw.githubusercontent.com/nullvoider07/memory-archive/master/install/install.sh | bashThe installer downloads the platform-appropriate release archive from GitHub Releases, verifies it against the release SHA256SUMS before extracting (a missing or mismatched checksum aborts the install), extracts ma-core and ma-kafka-producer to ~/.local/bin/ (or /usr/local/bin/ with sudo), installs the Python wheel into your active Python environment, and verifies the installation with a memory-archive ping. If you hit a GitHub API rate limit, set GITHUB_TOKEN before running the installer.
irm https://raw.githubusercontent.com/nullvoider07/memory-archive/master/install/install.ps1 | iexThe installer downloads ma-windows-x64.zip, extracts binaries to %LOCALAPPDATA%\MemoryArchive\bin\, installs the Python wheel, and adds the bin directory to PATH for the current user.
# Prerequisites: Rust 1.85+, Python 3.13, protoc on PATH
git clone https://github.com/nullvoider07/memory-archive.git
cd memory-archive
# Build Rust binaries
cargo build --release
# Install Python package
cd ma-app
pip install .
# Verify
memory-archive version
memory-archive pingFor cross-compilation to Linux arm64:
cargo install cross
cross build --release --target aarch64-unknown-linux-gnuBefore using Memory Archive, run the config command to set your storage path and, if using cloud sync, your cloud provider:
# Local-only setup
memory-archive config --storage-path ~/memories --storage-mode local
# With cloud sync (AWS S3 example)
memory-archive config \
--storage-path ~/memories \
--storage-mode local \
--cloud aws \
--aws-bucket my-memory-archive-bucket \
--aws-region us-east-1
# Set Control-Center and The-Eyes addresses
memory-archive config \
--control-center-addr localhost:50051 \
--the-eyes-addr http://localhost:8080
# Verify config
memory-archive config --showCloud credentials are not set in config — they are read from environment variables at runtime:
# AWS
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
# Azure (service principal)
export AZURE_TENANT_ID=...
export AZURE_CLIENT_ID=...
export AZURE_CLIENT_SECRET=...
# GCP
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa-key.jsonThis walkthrough covers the full pipeline from installation to a finalized memory.md.
Step 1 — Start ma-core
memory-archive server start --daemonma-core starts in the background, creates ~/.memory-archive/ma-core.pid, opens the Unix socket, and writes initial logs to ~/.memory-archive/ma-core.log.
Step 2 — Verify connectivity
memory-archive ping
# → ma-core v0.x.x — OKStep 3 — Register a session
SESSION_ID=$(memory-archive session register \
--mode manual \
--os-type LINUX \
--os-version "Ubuntu 24.04 LTS" \
--os-arch x86_64 \
--capture-server the-eyes-local \
--actuation-server cc-local \
--memory-name "open-browser-and-search")
echo "Session: $SESSION_ID"This creates the memory directory at ~/memories/open-browser-and-search/ and registers the session in Redis.
Step 4 — Start watching
memory-archive start --session "$SESSION_ID"This command blocks. Memory Archive is now recording every non-position command event from Control-Center and fetching frames from The-Eyes.
Step 5 — Perform the task
Perform the task you want to record in your OS environment. Control-Center streams each command event to Memory Archive. Each step is written atomically to disk.
Step 6 — Signal completion
When you have finished the task, in a new terminal:
memory-archive done --session "$SESSION_ID"The start command returns. Session status advances to pending_annotation.
Step 7 — Check status
memory-archive status --session "$SESSION_ID"Step 8 — Annotate
memory-archive annotate --session "$SESSION_ID"The TUI opens. For each step:
- Navigate with
j/kor arrow keys - Press
eto edit the reasoning for the selected step - Write your natural-language reasoning in the editor
- Press
Ctrl+Nto save and advance to the next step - Press
?for the full keyboard shortcut reference
When all steps are annotated, the AnnotationComplete overlay appears. Choose "compile now" to proceed directly.
Step 9 — Compile
If you did not compile from the AnnotationComplete overlay, run:
memory-archive compile --session "$SESSION_ID"CompilerApp opens with a scaffold of memory.md generated from your reasoning. Edit the document to your satisfaction, then press Ctrl+F and confirm. FinalizeMemory IPC is called: session status → complete. Ctrl+Q is the other exit — it saves the draft and leaves the session at pending_compilation, resumable with memory-archive compile. Only Ctrl+F finalizes.
Step 10 — View results
ls ~/memories/open-browser-and-search/
# memory.md metadata.json commands/ vision/ reasoning/
cat ~/memories/open-browser-and-search/memory.mdStep 11 — Check cost (automated mode only)
memory-archive cost --session "$SESSION_ID" --detailedThis walkthrough sets up a shared ma-core with remote annotators.
On the server (operator):
# 1. Configure cloud storage and Redis
memory-archive config \
--storage-mode cloud_primary \
--cloud aws \
--aws-bucket team-memories \
--aws-region eu-west-1 \
--redis-url redis://redis-host:6379 \
--ipc-port 9001
# 2. Set the admin IPC token
export MA_IPC_TOKEN=<strong-random-token>
export MA_ANNOTATOR_MGMT_TOKEN=<strong-random-token>
# 3. Start ma-core
memory-archive server start --daemon
# 4. Get the TLS fingerprint
memory-archive tls fingerprint
# → AA:BB:CC:DD:...
# 5. Register an annotator
memory-archive annotator-admin register \
--annotator-id alice \
--allowed-tenants acme-corp \
--max-claims 3
# → Key: <plaintext key shown once — distribute securely>
# 6. Generate a connection profile for Alice
memory-archive annotator-admin generate-profile --annotator-id alice
# → base64 profile stringOn the annotator machine (Alice):
# 1. Install Memory Archive
curl -fsSL https://raw.githubusercontent.com/nullvoider07/memory-archive/master/install/install.sh | bash
# 2. Configure from profile
memory-archive annotator setup <base64-profile-from-operator>
# 3. View annotation queue
memory-archive annotator queue
# 4. Claim and annotate a session
memory-archive annotator claim
# TUI opens for the oldest available session
# Or claim a specific session:
memory-archive annotator claim --session "$SESSION_ID"Automated mode requires cloud_primary storage, Kafka, Redis, and a running VLM endpoint.
# 1. Configure
memory-archive config \
--storage-mode cloud_primary \
--cloud aws \
--aws-bucket prod-memories \
--aws-region us-east-1 \
--redis-url redis://redis-cluster:6379 \
--kafka-broker kafka-host:9092
# 2. Add VLM provider config to config.json (see Section 10)
# 3. Start ma-core
export MA_IPC_TOKEN=<token>
memory-archive server start --daemon
# 4. From orchestration layer: register session with --mode automated
SESSION_ID=$(memory-archive session register \
--mode automated \
--os-type LINUX \
--memory-name "automated-task-001" \
--tenant-id my-org)
# 5. Start VLM reasoning daemon
memory-archive automated --session "$SESSION_ID" &
# 6. Start watching
memory-archive start --session "$SESSION_ID"
# 7. Signal done when orchestration layer completes task
memory-archive done --session "$SESSION_ID"
# 8. Compile (or compile programmatically via FinalizeMemory IPC)
memory-archive compile --session "$SESSION_ID"All local IPC communication uses a Unix domain socket at ~/.memory-archive/ma.sock. The socket file has permissions 600, and its parent directory (~/.memory-archive/) has permissions 700. Only the owner process and processes running as the same user can connect. config.json — which may hold an annotator key in remote-annotator setups — is written with permissions 600 so it is not readable by other local users.
All remote IPC communication (annotators, remote admin) uses TLS 1.3 exclusively. TLS 1.2 and below are rejected at the handshake level. A self-signed CA is generated by rcgen on the first ma-core start and stored at ~/.memory-archive/ca/ca-cert.pem with a 10-year validity. The server certificate is generated from this CA, stored at ~/.memory-archive/ipc-cert.pem with a 1-year validity, and has file permissions 600. Clients do not use standard CA trust chains — instead, the SHA-256 fingerprint of the server certificate is pinned on the client side. No Trust On First Use (TOFU): the fingerprint must be explicitly configured before any connection is accepted. The fingerprint is distributed via a base64 connection profile string.
The admin IPC token is read exclusively from the MA_IPC_TOKEN environment variable. It is never written to config.json, never logged, and never transmitted in IPC messages (it is verified at connection establishment and then discarded). If ipc_port is configured in config.json but MA_IPC_TOKEN is not set in the environment, ma-core refuses to start and exits with a CRITICAL error.
Each annotator is provisioned with a 256-bit cryptographically random key generated server-side. Only the SHA-256 hash of the key is stored in Redis (key_hash field on the annotator:{annotator_id} hash). The plaintext key is returned once in the AnnotatorRegistered IPC response and never stored or logged afterward; the operator is responsible for distributing it to the annotator securely. Hash comparison at authentication time uses constant-time comparison to prevent timing oracle attacks. Authentication failures are tracked per annotator with a 60-second rolling window; more than 10 failures in 60 seconds fires a WARNING alert for brute-force detection. The last_auth_at timestamp is updated on every successful authentication for audit purposes.
Annotator deactivation sets the status field in the Redis hash to "deactivated". The record and its full audit history are preserved; the annotator simply can no longer authenticate. Key rotation generates a new 256-bit key, updates key_hash in Redis, and immediately invalidates any session using the old key.
The UploadFile IPC message from an annotator connection is subject to server-side path validation and write authority checks in the Rust handler — not on the client. Only two paths are permitted: reasoning/reasoning.jsonl and metadata.json. Any other path returns a WRITE_FORBIDDEN error. For reasoning.jsonl uploads, the Rust handler forcibly overwrites the source field in every entry to "human" regardless of the value supplied by the client. This prevents training data poisoning where a rogue annotator client could submit reasoning entries falsely attributed to a VLM. For metadata.json uploads, only counter fields (annotated_steps, skipped_steps) are permitted; status changes are rejected server-side. The validate_relative_path() function normalizes all submitted paths and rejects any path containing .. sequences, preventing directory traversal attacks. The payload size limit for UploadFile is 50 MB.
Cloud credentials (AWS access keys, Azure service principal credentials, GCP Application Default Credentials) exist only on the ma-core server. They are never transmitted over any IPC channel, never written to config files, and never logged. Remote annotator machines hold zero cloud credentials. All file access for remote annotators — both reads (FetchFile) and writes (UploadFile) — is proxied through ma-core, which performs the actual cloud storage operation after verifying annotator identity and write authority.
The Ed25519 public key used to verify the pricing manifest and the manifest URL are both hardcoded in the ma-core binary at compile time. They cannot be overridden by environment variables, config files, or IPC messages. This prevents DNS spoofing, BGP hijack, and config manipulation attacks from substituting a fraudulent manifest. If signature verification fails for any reason — including a corrupt cache or a compromised network response — the manifest is discarded, the local cache is deleted, an ERROR alert is fired, and the system falls back to the next pricing tier (Bedrock Pricing API or configured cost rates).
The AWS Bedrock Pricing API (pricing:GetProducts) is accessed under a separate IAM policy from the inference policy. This prevents a confused deputy scenario where an attacker could use ma-core's inference credentials to query pricing data and infer usage patterns or billing information.
Circuit breaker state is stored in per-session _SessionState objects held by ReasoningPipeline. It is never held in ModelRouter, which is stateless. One session's VLM failures cannot affect any other session's circuit breaker. Storage backends are pinned per session in Redis at registration and are never re-evaluated mid-capture; a routing rule change after session start does not affect in-flight sessions. Kafka partition key is session_id, ensuring that all events for a session are consumed in order and that events for different sessions never mix. The allowed_tenant_ids list on each annotator record is evaluated server-side when building the annotation queue; annotators cannot access sessions outside their permitted tenant scope.
ma-core writes its PID to ~/.memory-archive/ma-core.pid on startup. On every subsequent startup, it reads this file and checks whether the recorded PID corresponds to a live process. If a live process is found, ma-core exits with an error. This prevents two concurrent ma-core instances from writing to the same session directories, which could produce corrupted state even with atomic individual writes.
| Aspect | Manual (duo) | Automated (trio) | Remote Human Annotation | Degraded → Human Fallback |
|---|---|---|---|---|
| Who annotates | Local human user via TUI | VLM API (async) | Remote human annotator via TUI | Human annotator (after VLM failure) |
| Requires Kafka | No | Yes | No (local capture); yes if cloud_primary | No |
| Requires cloud storage | Optional | Yes (cloud_primary required) | Yes (cloud_primary required) | No |
| Requires Redis | Yes | Yes | Yes | Yes |
| Session registration | Manual CLI | Programmatic (orchestration layer) | Any | Any |
| Annotation timing | After capture | During capture (async) | After capture | After capture |
| Circuit breaker | N/A | Per-session | N/A | N/A |
| VLM providers | None | 1 primary + 1 optional fallback | None | None |
| Annotator credentials | Local only | N/A | TLS + annotator key | Local or remote |
In manual mode, the operator works directly in the OS environment while Memory Archive watches the Control-Center gRPC stream. The system captures every non-position, non-failed command event, fetches three frames per step from The-Eyes, and annotates mouse frames programmatically. No VLM is involved during capture. After the task is complete, the operator runs memory-archive done, then memory-archive annotate to open the TUI and write natural-language reasoning for each step, then memory-archive compile to produce and finalize memory.md. This mode works entirely without Kafka and optionally without cloud storage.
Mode-specific requirements: Redis 7.0+; Control-Center running locally; The-Eyes running locally; MA_IPC_TOKEN not required unless ipc_port is configured.
In automated mode, an orchestration layer registers sessions programmatically via IPC, the OS environment runs the task while Memory Archive captures, and a VLM pipeline handles reasoning in real time without pausing capture. ma-app's automated command starts a reasoning daemon per session that receives StepReadyForReasoning push events, calls the configured VLM API, and returns ReasoningResult IPC messages. When all steps are captured and the orchestration layer sends done, the session proceeds directly to compilation (via compile or FinalizeMemory IPC). No human annotation is required in the happy path.
Mode-specific requirements: Redis 7.0+; Kafka 3.0+ KRaft; cloud_primary storage; VLM API endpoint and credentials; MA_IPC_TOKEN env var.
In remote human annotation mode, annotators connect to ma-core over TLS 1.3 TCP from machines that hold no cloud credentials. An operator registers annotators with annotator-admin register, distributes the plaintext key and connection profile, and annotators use memory-archive annotator claim to pick sessions from the queue and annotate them via the TUI. The TUI sends a HeartbeatClaim IPC every 5 minutes to refresh the claim TTL. File reads (images) and writes (reasoning.jsonl) are proxied through ma-core. This mode is compatible with both manually captured and automated-then-degraded sessions.
Mode-specific requirements: Redis 7.0+; ipc_port configured; MA_IPC_TOKEN and MA_ANNOTATOR_MGMT_TOKEN env vars; TLS cert fingerprint distributed to annotators; annotator records registered in Redis.
When both the primary and fallback VLM circuits open for a session, ma-core transitions the session to reasoning_degraded status. Capture continues normally; steps after the degradation point are marked source: model_degraded in reasoning.jsonl. When the session receives a done command, it automatically transitions to pending_human_annotation and appears in the annotation queue. In the TUI, steps annotated before degradation appear as complete with their VLM reasoning pre-populated; degraded steps appear as empty and require human annotation. The final memory.md reflects the mix of VLM and human reasoning, with each step's source field distinguishing the two.
Mode-specific requirements: Same as automated mode for the capture phase; same as remote human annotation for the annotation phase.
All commands are subcommands of the memory-archive entry point.
Register a new capture session in Redis and initialize the memory directory.
memory-archive session register [OPTIONS]
Options:
--mode TEXT Capture mode: manual | automated [required]
--os-type TEXT OS type: LINUX | WINDOWS | MACOS [required]
--os-version TEXT OS version string (e.g., "Ubuntu 24.04 LTS")
--os-arch TEXT OS architecture (e.g., x86_64)
--os-env-id TEXT Opaque OS environment identifier
--capture-server TEXT The-Eyes server ID
--actuation-server TEXT Control-Center server ID
--memory-name TEXT Directory name for this memory [required]
--tenant-id TEXT Tenant identifier (optional, overrides config)
Returns: session ID (UUID string) printed to stdout.
# Example
SESSION_ID=$(memory-archive session register \
--mode manual \
--os-type LINUX \
--os-version "Ubuntu 24.04 LTS" \
--os-arch x86_64 \
--capture-server eyes-01 \
--actuation-server cc-01 \
--memory-name "fill-web-form")Permanently purge a session from everywhere. Removes the Redis record and every index/claim entry (session:{id}, all status sets, sessions:by_os:*, sessions:by_mode:*, claim:{id}), all stored files (cloud objects under sessions/{id}/… and the local memory directory plus any (incomplete) sibling), and the client-side temp/scratch directory. This cannot be undone.
If the Redis record is already gone, the command still sweeps orphaned index/claim entries and any leftover storage, so it doubles as a cleaner for stale sessions. Active or annotating sessions are refused unless --force is given.
A memory directory is removed only when its metadata.json names the session being deleted. memory_path does not uniquely identify a session — a scrapped take re-recorded under the same memory_name occupies the same directory while the abandoned record still points at it — so a directory whose metadata is missing, unreadable, or names a different session is left in place and reported. Deletion never removes a recording it cannot prove it owns.
memory-archive session delete [OPTIONS]
Options:
-s, --session TEXT Session ID to delete [required]
-y, --yes Skip the confirmation prompt
--force Delete even if the session is active or being annotated
# Delete a session, with confirmation
memory-archive session delete --session "$SESSION_ID"
# Force-delete an in-flight session without prompting
memory-archive session delete --session "$SESSION_ID" --force --yesStart watching a registered session's command stream. Blocks until done is received or the tool disconnects.
memory-archive start [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
In local mode, also starts SyncWorker for cloud uploads and handles FileWritten push events from ma-core.
memory-archive start --session "$SESSION_ID"Signal that the capture is complete. Flushes all in-memory state, fetches the closing state image, transitions Redis status to pending_annotation (manual) or the appropriate automated state.
memory-archive done [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
memory-archive done --session "$SESSION_ID"Start the VLM reasoning daemon for an automated session. Receives StepReadyForReasoning push events from ma-core, calls the configured VLM, and returns ReasoningResult via IPC. Runs until the session is complete or disconnected.
memory-archive automated [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
Requires storage_mode = cloud_primary and VLM provider config in config.json.
memory-archive automated --session "$SESSION_ID"Open the TUI annotation interface for a session. In local mode, also starts SyncWorker. Resumes from the last annotated step if partially complete. After annotation, launches CompilerApp automatically.
memory-archive annotate [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
memory-archive annotate --session "$SESSION_ID"Standalone command to regenerate the memory.md scaffold from reasoning.jsonl and open CompilerApp. Calls FinalizeMemory IPC when the editor is finalized with Ctrl+F.
memory-archive compile [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
memory-archive compile --session "$SESSION_ID"Print all Redis session hash fields for a session.
memory-archive status [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
memory-archive status --session "$SESSION_ID"Print token usage and estimated cost for a session.
memory-archive cost [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
--detailed Scan reasoning.jsonl for per-step breakdown
(default: reads metadata.json summary only)
Output includes: total input tokens, total output tokens, total tokens, estimated cost in USD, per-provider breakdown, and rate source (manifest / Bedrock API / configured).
memory-archive cost --session "$SESSION_ID"
memory-archive cost --session "$SESSION_ID" --detailedCheck ma-core connectivity. Sends a Ping IPC message and prints the ma-core version string from the Pong response.
memory-archive ping
# → ma-core v0.4.2 — OKCheck for and apply updates. Downloads the latest release archive for the current platform from GitHub Releases and replaces binaries and wheel in place.
ma-core does not need to be stopped first: each binary is written to a temp file and renamed over the target, and rename(2) succeeds over a running executable — the running process keeps its old inode until it exits. Restart ma-core afterwards to pick up the new version.
memory-archive update [OPTIONS]
Options:
--check-only Only check for a newer version; do not download
memory-archive update
memory-archive update --check-onlyRemove Memory Archive binaries and optionally all local state.
memory-archive uninstall [OPTIONS]
Options:
--purge Also remove ~/.memory-archive/ (config, TLS certs, session data)
-y, --yes Skip confirmation prompt
memory-archive uninstall
memory-archive uninstall --purge --yesList all sessions in pending_human_annotation that are visible to the current annotator (filtered by allowed_tenant_ids). Sorted oldest-first.
memory-archive annotator queueClaim a session from the annotation queue and open the TUI. Sends a HeartbeatClaim every 5 minutes while the TUI is open. Releases the claim on TUI exit.
memory-archive annotator claim [OPTIONS]
Options:
-s, --session TEXT Specific session ID to claim (omit to auto-claim oldest)
memory-archive annotator claim
memory-archive annotator claim --session "$SESSION_ID"Configure this machine as a remote annotator from a base64 connection profile string provided by the operator.
memory-archive annotator setup <PROFILE>
Arguments:
PROFILE Base64 connection profile string from operator [required]
Decodes the profile and writes ma_core_addr, ipc_server_fingerprint, annotator_id, and annotator_key to config.json.
memory-archive annotator setup eyJtYV9jb3JlX2FkZHIiOiAi...Register a new annotator. Generates a 256-bit random key, stores its SHA-256 hash in Redis, and returns the plaintext key once.
memory-archive annotator-admin register [OPTIONS]
Options:
--annotator-id TEXT Unique annotator identifier [required]
--allowed-tenants TEXT Comma-separated tenant ID prefixes (empty = all tenants)
--max-claims INT Maximum concurrent claims (0 = unlimited) [default: 0]
memory-archive annotator-admin register \
--annotator-id alice \
--allowed-tenants acme-corp,beta-labs \
--max-claims 5Deactivate an annotator. Sets status = deactivated in Redis; audit history preserved.
memory-archive annotator-admin deactivate [OPTIONS]
Options:
--annotator-id TEXT Annotator ID to deactivate [required]
memory-archive annotator-admin deactivate --annotator-id aliceRotate an annotator's key. Generates a new 256-bit key, invalidates the old key immediately, and returns the new plaintext key once.
memory-archive annotator-admin rotate-key [OPTIONS]
Options:
--annotator-id TEXT Annotator whose key to rotate [required]
memory-archive annotator-admin rotate-key --annotator-id aliceList all registered annotators with their current status, claim counts, allowed tenants, and last authentication timestamp.
memory-archive annotator-admin listGenerate a base64 connection profile string for an annotator. Looks up the annotator in Redis (must be active), prompts for the plaintext key, and produces the encoded profile.
memory-archive annotator-admin generate-profile [OPTIONS]
Options:
--annotator-id TEXT Annotator to generate profile for [required]
memory-archive annotator-admin generate-profile --annotator-id aliceSet or view configuration. All options are optional; only specified flags are updated.
memory-archive config [OPTIONS]
Storage:
--storage-path PATH Local memory directory [default: ~/memories]
--storage-mode TEXT local | cloud_primary [default: local]
Infrastructure:
--redis-url TEXT Redis connection URL [default: redis://localhost:6379]
--kafka-broker TEXT Kafka broker address
--ipc-port INT TCP IPC listener port (enables remote mode)
--ipc-bind-addr TEXT TCP IPC bind address [default: 0.0.0.0]
Tool addresses:
--control-center-addr TEXT CC gRPC address (global fallback)
--the-eyes-addr TEXT The-Eyes HTTP address (global fallback)
--the-eyes-poll-interval INT Liveness poll interval in seconds [default: 10]
--silence-timeout INT CC silence timeout in seconds [default: 30]
Control-Center authentication (required by CC 1.1.0+):
--control-center-token TEXT JWT for CC; needs the 'monitor' scope
--control-center-tls-ca TEXT PEM CA that signed the CC certificate
--control-center-security TEXT auto | strict | legacy [default: auto]
Cloud — general:
--cloud TEXT aws | azure | gcp
Cloud — AWS:
--aws-bucket TEXT S3 bucket name
--aws-region TEXT S3 region (e.g., us-east-1)
Cloud — Azure:
--azure-container TEXT Azure container or file share name
--azure-account TEXT Azure storage account name
--azure-storage-type TEXT auto | blob | adls | files [default: auto]
Cloud — GCP:
--gcp-bucket TEXT GCP bucket name
--gcp-project TEXT GCP project ID (optional)
Remote / annotator:
--ma-core-addr TEXT Remote ma-core address for annotator machines (host:port)
--ipc-server-fingerprint TEXT SHA-256 TLS cert fingerprint (AA:BB:CC:...)
--annotator-id TEXT Annotator identity for this machine
--annotator-key TEXT Annotator key for this machine
--tenant-id TEXT Tenant identifier for cost attribution
Advanced:
--metadata-flush-interval INT Steps between cloud metadata flushes [default: 10]
--temp-session-dir PATH Temp dir for cloud_primary read-back [default: system temp]
View:
--show Print current configuration and exit
memory-archive config --show
memory-archive config --storage-mode cloud_primary --cloud aws --aws-bucket my-bucket --aws-region us-east-1Start ma-core.
memory-archive server start [OPTIONS]
Options:
--daemon Run ma-core in the background
--log-file PATH Log file path for daemon mode [default: ~/.memory-archive/ma-core.log]
--release Use release binary tier [default]
--debug Use debug binary tier
Binary resolution order: MA_CORE_BIN env var → ma-core on PATH → target/release/ma-core in workspace root → cargo run.
memory-archive server start --daemon
memory-archive server start --daemon --log-file /var/log/ma-core.logStop a running ma-core daemon. Reads the PID from ~/.memory-archive/ma-core.pid and sends SIGTERM (Linux/macOS) or calls taskkill (Windows).
On a clean shutdown ma-core flags any active sessions as interrupted, then removes both ma-core.pid and ma.sock. Both are removed only if the PID file still names the exiting process, so a shutdown that outlasts a successor's startup cannot delete the newer instance's files. A socket left behind after an unclean kill is harmless — startup unlinks a stale one before binding.
memory-archive server stopView or stream ma-core logs.
memory-archive server logs [OPTIONS]
Options:
-n, --lines INT Number of recent lines to show [default: 50]
-f, --follow Stream new log output (like tail -f)
--log-file PATH Log file path [default: ~/.memory-archive/ma-core.log]
memory-archive server logs
memory-archive server logs -n 200 -f
memory-archive server logs --log-file /var/log/ma-core.log -fStart ma-kafka-producer for a session. Connects to Control-Center's WatchCommands gRPC stream and publishes events to the control-center-events Kafka topic. Development tool — not for production use.
memory-archive server kafka-bridge [OPTIONS]
Options:
-s, --session TEXT Session ID [required]
--cc-addr TEXT CC gRPC address (overrides config)
--kafka-broker TEXT Kafka broker address (overrides config)
--release Use release binary [default]
--debug Use debug binary
memory-archive server kafka-bridge \
--session "$SESSION_ID" \
--cc-addr localhost:50051 \
--kafka-broker localhost:9092Read the ma-core TLS server certificate from ~/.memory-archive/ipc-cert.pem and print the SHA-256 fingerprint in colon-separated uppercase hex (e.g., AA:BB:CC:DD:...).
memory-archive tls fingerprintThe image pane (top-left) shows the at-frame for the selected step along with its filename, pixel dimensions, and action type. An "Open fullscreen" button launches the frame in an external viewer (feh on Linux, open on macOS). The steps list (top-right) is a virtual-scrolling list of all captured steps with their checkbox status and converted command. The reasoning editor (bottom-left) contains a multi-line text input with a live word and character counter. The stats pane (bottom-right) shows the session name, truncated session ID, annotated and skipped counts, and a PillProgressBar with completion percentage.
| Icon | Status | Meaning |
|---|---|---|
⬜ |
Pending | Not yet visited in this session |
🔵 |
In Progress | Currently open for editing |
✅ |
Complete | Reasoning saved and step confirmed |
[-] |
Skipped | User explicitly chose to skip this step |
| Key | Action |
|---|---|
j / ↓ |
Move to next step |
k / ↑ |
Move to previous step |
PgDn |
Fast scroll down (5 steps) |
PgUp |
Fast scroll up (5 steps) |
e / Enter |
Open selected step for editing |
Space |
Toggle step accordion (expand/collapse) |
Ctrl+N |
Save current reasoning, mark step complete, advance to the next pending step |
Ctrl+S |
Save current reasoning without advancing |
Ctrl+A |
Select all text in the reasoning editor |
Ctrl+C |
Copy the selection to the system clipboard |
Ctrl+V |
Paste the system clipboard, replacing the selection |
Ctrl+X |
Cut the selection to the system clipboard |
Ctrl+Z |
Undo last edit in reasoning editor |
Ctrl+Y |
Redo last undone edit |
u |
Revert reasoning editor to last saved content |
Tab |
Cycle focus between StepList and ReasoningEditor |
+ |
Zoom in on image preview |
- |
Zoom out on image preview |
f |
Fit image to pane dimensions |
? |
Open HelpOverlay (full keyboard shortcut reference) |
Ctrl+Q |
Quit (QuitConfirm overlay if unsaved changes exist) |
← / → |
Move between buttons in any confirmation dialog |
| drag | Selecting text with the mouse copies it to the system clipboard |
Ctrl+N looks forward first. When no step ahead is pending it wraps to the
start and opens the earliest pending step, announcing the jump in the status bar
— an annotator who begins part-way through a session leaves earlier steps
pending, and those would otherwise be unreachable from the keyboard. When the
current step is the only one left pending it says so rather than doing nothing.
Selecting text with the mouse copies it — no copy keystroke — anywhere in the
TUI: static regions such as the step list, stats pane and help overlay, and
editable ones such as the compile editor, the reasoning editor and the
jump-to-step box. Ctrl+C inside an editable region copies the selection too,
and Ctrl+X cuts it.
The selection goes to the system clipboard (via wl-copy, xclip, xsel,
pbcopy or clip, whichever is present) as well as to the terminal via OSC 52,
so it can be pasted into other applications and not only back into the reasoning
editor. A plain click selects nothing and copies nothing, so clicking never
overwrites a clipboard filled elsewhere.
Ctrl+V reads the system clipboard in both editors (via wl-paste,
xclip, xsel, pbpaste or powershell -Command Get-Clipboard), so text
copied elsewhere pastes in; with no helper installed it falls back to whatever
was copied inside the TUI. A paste replaces the current selection rather than
inserting beside it, so Ctrl+A then Ctrl+V swaps the whole document for the
clipboard — a right-to-left drag selection is handled, and an empty selection
inserts at the cursor. Both editors share one paste path so the two cannot drift.
Ctrl+A selects all in both editors. Textual's TextArea binds that key to
cursor-to-line-start by default; the override is deliberate, and f6 / f7
still select the line and the document as TextArea defines them.
The reasoning editor binds its clipboard and undo keys on the outer widget, not
only on the inner text area, because Tab focuses the outer widget — a key
bound only on the text area does nothing in that state.
Editable regions need the second path because Textual implements selection
twice. TextArea and Input capture the mouse to run their own drag-selection,
and the screen-level model Textual exposes through Screen.get_selected_text()
only records a selection while nothing has captured the mouse — so in exactly
the panes where copying matters most, the screen reports nothing and the text
lives in the widget's own selected_text. Both are read, screen first.
The ReasoningEditor triggers an autosave to reasoning.jsonl via ReasoningWriter every 2.5 seconds whenever the content has changed since the last save. Autosave fires the UpdateAnnotationProgress IPC notification, which updates annotated_steps in the Redis session hash. Autosave does not advance step status to complete; only Ctrl+N marks a step complete.
When the TUI opens for a session that has already been partially annotated, SessionLoader reads all entries from reasoning.jsonl and pre-populates each step's status:
- Steps with non-empty
reasoningand noskippedflag →✅ Complete - Steps with
skipped: true→[-] Skipped - Steps with an empty or missing entry →
⬜ Pending
The TUI automatically positions the cursor on the first ⬜ Pending step. This makes the TUI fully resumable after any interruption.
| Overlay | Trigger | Description |
|---|---|---|
JumpToStep |
Internal navigation | Numeric input to jump directly to any step number |
QuitConfirm |
Ctrl+Q with unsaved changes |
Prompts to confirm quit; offers discard, save, or cancel |
CrashRecovery |
TUI open with an in_progress step detected |
Prompts to resume or reset the in-progress step |
AnnotationComplete |
All steps have status Complete or Skipped | Offers "compile now" (launches CompilerApp immediately) or "compile later" |
HelpOverlay |
? |
Full keyboard shortcut reference panel |
Every confirmation dialog is navigable with ← / → as well as Tab, and each
button shows its own shortcut key in brackets. Focus starts on the
non-destructive choice — Enter activates the focused button, so a dialog whose
other option discards work never defaults to it.
The right pane displays the at frame for the selected step inline in the terminal. For mouse steps, the annotated PNG (with the red circle, arrow, and coordinate box) is shown. Controls:
+/-: zoom in/outf: fit image to pane
An external image viewer can be opened: feh on Linux, open on macOS, and os.startfile() on Windows (opens in the system default image viewer). If none of these are available, the "Open fullscreen" button is disabled and labelled "no image viewer available". In remote annotator mode, images are fetched from ma-core via RemoteFetcher over the TLS channel and cached in a local temp directory. While the annotator is writing reasoning for step N, the next step's images are prefetched in a background thread to eliminate visible fetch latency on step advance.
After annotation is complete (either via AnnotationComplete overlay or memory-archive compile), CompilerApp opens. It is a full-screen terminal text editor built as a CompilerScreen Textual widget. On open, run_compile generates a memory.md scaffold from reasoning.jsonl, inserting the step reasoning in document order with headers for each step. The editor supports all standard text editing operations.
CompilerStatusBar is displayed at the bottom, showing the current word count and save state (Saved / Unsaved). Autosave fires every 2.5 seconds when content has changed.
The two exits are distinct, and only one of them finalizes:
| Key | Overlay | Result |
|---|---|---|
Ctrl+S |
— | Save the draft, stay in the editor |
Ctrl+F |
CompilerFinalizeOverlay |
Save, confirm, then FinalizeMemory IPC → status complete |
Ctrl+Q |
CompilerQuitOverlay |
Save, confirm, exit; session stays pending_compilation and is resumable |
Editing keys match the reasoning editor: Ctrl+A selects all, Ctrl+V pastes
the system clipboard over the selection, and Ctrl+C / Ctrl+X copy and cut to
it.
Ctrl+F is bound with priority=True because the editor is a TextArea, whose own bindings are resolved before the screen's — the previous Ctrl+D was consumed by TextArea's delete,ctrl+d and deleted a character instead of finalizing. Ctrl+A and Ctrl+V are priority for the same reason: TextArea claims both, for cursor-to-line-start and for a paste that reads only the in-app clipboard. All three resolve against the top screen's binding chain, so none of them fires while a confirm overlay is open. The finalized memory.md and all session files remain accessible indefinitely — a completed session is never expired, and is removed only by an explicit memory-archive session delete.
When memory-archive annotator claim is used (remote mode), the TUI starts a daemon thread that fires HeartbeatClaim IPC every 5 minutes while the TUI is open. This refreshes the claim:{session_id} Redis key TTL (30-minute rolling window). If the HeartbeatClaim response is CLAIM_LOST (e.g., the claim expired due to network interruption), the TUI logs a warning but does not immediately close — work in progress is not discarded. The claim can be reclaimed via memory-archive annotator claim --session "$SESSION_ID" if the session is still available in the queue.
| Config key | CLI flag | Description | Mode | Default |
|---|---|---|---|---|
storage_path |
--storage-path |
Local memory storage directory | both | ~/memories |
storage_mode |
--storage-mode |
local or cloud_primary |
both | local |
redis_url |
--redis-url |
Redis connection URL | both | redis://localhost:6379 |
kafka_broker |
--kafka-broker |
Kafka broker address | cloud_primary | — |
ipc_port |
--ipc-port |
TCP IPC listener port (enables remote mode) | remote mode | — |
ipc_bind_addr |
--ipc-bind-addr |
TCP IPC bind address | remote mode | 0.0.0.0 |
ma_core_addr |
--ma-core-addr |
Remote ma-core address (host:port) for annotator machines | remote | — |
ipc_server_fingerprint |
--ipc-server-fingerprint |
SHA-256 TLS cert fingerprint (AA:BB:CC:...) | remote | — |
control_center_addr |
--control-center-addr |
CC gRPC global fallback address | both | — |
control_center_token |
--control-center-token |
JWT presented to CC; the monitor scope is required by CC 1.1.0+ and ignored by 1.0.0 |
both | — |
control_center_tls_ca |
--control-center-tls-ca |
PEM CA that signed the CC server certificate; empty uses the system trust store | both | — |
control_center_security |
--control-center-security |
Transport policy: auto (TLS, fall back to plaintext with a warning) / strict (TLS only) / legacy (plaintext only) |
both | auto |
control_center_max_version |
--control-center-max-version |
Accept CC up to this x.y.z, above the built-in ceiling. Only ever raises — it cannot narrow the supported range |
both | — |
control_center_allow_unsupported |
--control-center-allow-unsupported |
Record against a CC version outside the supported range, or against an agent below the record-fidelity floor. Output is unverified; the refusal is logged on every connect | both | false |
the_eyes_addr |
--the-eyes-addr |
The-Eyes HTTP global fallback address | both | — |
the_eyes_poll_interval_seconds |
--the-eyes-poll-interval |
The-Eyes liveness poll interval (seconds) | both | 10 |
silence_timeout_seconds |
--silence-timeout |
CC silence before graceful disconnect (seconds) | both | 30 |
metadata_flush_interval |
--metadata-flush-interval |
Steps between cloud metadata.json flushes | cloud_primary | 10 |
temp_session_dir |
--temp-session-dir |
Temp dir for cloud_primary annotation read-back | cloud_primary | system temp |
cloud.provider |
--cloud |
Cloud provider: aws / azure / gcp |
both | — |
cloud.aws.bucket |
--aws-bucket |
S3 bucket name | both | — |
cloud.aws.region |
--aws-region |
S3 region (e.g., us-east-1) |
both | — |
cloud.azure.account |
--azure-account |
Azure storage account name | both | — |
cloud.azure.container |
--azure-container |
Azure container or file share name | both | — |
cloud.azure.storage_type |
--azure-storage-type |
auto / blob / adls / files |
both | auto |
cloud.gcp.bucket |
--gcp-bucket |
GCP bucket name | both | — |
cloud.gcp.project |
--gcp-project |
GCP project ID (optional) | both | — |
annotator_id |
--annotator-id |
Annotator identity for this machine | annotator | — |
annotator_key |
--annotator-key |
Annotator key for this machine | annotator | — |
tenant_id |
--tenant-id |
Tenant ID for cost attribution | automated | — |
model.routing_policy |
config.json only | VLM routing policy: pinned / fallback / load_balance |
automated | pinned |
model.providers[] |
config.json only | Named provider configs with endpoint, key ref, priority, cost rates | automated | — |
model.requests_per_minute |
config.json only | VLM API rate limit (requests per minute) | automated | 60 |
model.token_budget_per_hour |
config.json only | Token budget per hour across all VLM calls | automated | 1000000 |
model.circuit_breaker_threshold |
config.json only | Non-retryable failures before circuit opens | automated | 5 |
model.circuit_breaker_reset_seconds |
config.json only | Seconds before half-open trial | automated | 60 |
model.max_retries |
config.json only | Retries on retryable errors | automated | 3 |
observability.metrics_port |
config.json only | Prometheus metrics endpoint port | both | 9091 |
observability.metrics_bind_addr |
config.json only | Metrics listener bind address. A non-loopback value requires metrics_token, else it falls back to loopback |
both | 127.0.0.1 |
observability.metrics_token |
config.json only | Bearer token for the metrics endpoint. Required to bind a non-loopback metrics_bind_addr |
both | — |
observability.log_level |
config.json only | Log level per component | both | info |
observability.alert_webhook_url |
config.json only | Webhook URL for alert delivery | both | — |
observability.memory_warn_mb |
config.json only | ma-core RSS memory warning threshold (MB) | both | — |
observability.kafka_lag_warn |
config.json only | Kafka consumer lag alert threshold | cloud_primary | — |
observability.upload_queue_warn |
config.json only | Cloud upload queue depth alert threshold | both | — |
observability.ipc_push_queue_warn |
config.json only | IPC push queue depth alert threshold | both | — |
annotator_mgmt_port |
config.json only | Annotator management REST API port | both | 9002 |
Memory Archive supports Control-Center 1.0.0 through 1.3.0 from a single configuration. Two regimes exist, and the boundary is 1.1.0:
| 1.0.0 | 1.1.0 | 1.2.0 | 1.2.1 | 1.2.2 | 1.3.0 | |
|---|---|---|---|---|---|---|
| TLS on the gRPC listener | not supported | required | required | required | required | required |
monitor scope on WatchCommands |
not enforced | required | required | required | required | required |
CommandEvent wire format |
identical | identical | identical | identical | identical | identical |
position_captured meaning |
best-effort readback | best-effort readback | verified, or false |
verified, or false |
verified, or false |
verified, or false |
| Windows drag position reports | origin | origin | origin | origin | origin | destination |
| Typed command recorded faithfully | truncated at the first quote | truncated | truncated | truncated | faithful | faithful |
Every row above is verified against real release binaries by the compatibility suite in integration-tests/, which discovers releases from the GitHub API rather than a written-down list.
The last row is a refusal, not a caveat. Below 1.2.2 the Control-Center
agent rebuilt its own report of a typed command by scanning for a closing
quote, so a command containing one was stored cut at that quote — while the
keystrokes actuated correctly and the artifact on disk was byte-exact. Memory
Archive refuses to record against such an agent rather than write a trace that
looks complete and is not; control_center_allow_unsupported overrides it.
Note that this floor is checked against the agent version, which is a
different number from the server's and arrives later. Nothing at connect carries
it — GetServerIdentity reports the server and ConnectionMetadata has no agent
version field — so it is read from the first event, including the heartbeat an
idle agent produces every five seconds. In practice the refusal lands before a
single step is written.
Configure once, works everywhere:
# The monitor-scoped token. Ignored by 1.0.0, required by 1.1.0+, so always set it.
memory-archive config --control-center-token "$(CC_JWT_SECRET=<cc-jwt-secret> \
control-center token generate --user ma-core --scopes monitor --expires 8760 | head -1)"
# Only needed when the Control-Center certificate is signed by a private CA —
# which is what `control-center gen-certs` produces. Omit for a public certificate.
memory-archive config --control-center-tls-ca ~/.config/control-center/tls/ca.crtTransport negotiation. The version cannot be discovered before connecting — Ping carries no version and GetServerIdentity is itself monitor-scoped — so the transport is negotiated rather than derived from a version number. (Once the channel is up and the token has been accepted, the version is readable, which is what the version gate below uses.) Under the default auto policy the stream attempts TLS first and falls back to plaintext only when the transport itself fails, logging a warning that names the downgrade. A rejected token never triggers a fallback: the server answered, so a weaker transport would not help.
The URL scheme in control_center_addr does not pin the transport under auto — an address written http:// is still tried over TLS first, so pointing an existing configuration at an upgraded server needs no edit. Use strict to refuse any downgrade, or legacy to force plaintext.
The token travels only over an encrypted channel, or over plaintext you asked for by name. On an automatic downgrade the credential is withheld and a warning is logged. An automatic downgrade is not consent: an attacker able to disrupt the TLS handshake would otherwise force the fallback and collect a monitor-scoped token in the clear — and that token subscribes to WatchCommands, which carries every keystroke a session records. Withholding costs nothing against a genuine 1.0.0 server, which does not check the token. If you are running 1.1.0+ with CC_ALLOW_INSECURE=true and therefore need the token over plaintext, set control_center_security = "legacy" to say so deliberately.
| Policy | Behaviour | Use when |
|---|---|---|
auto (default) |
TLS, then plaintext with a warning; token withheld on the fallback | Mixed or unknown Control-Center versions |
strict |
TLS only; a transport failure is fatal | Untrusted networks; downgrade must be impossible |
legacy |
Plaintext only; token sent in the clear by explicit choice | Control-Center 1.0.0, or 1.1.0+ started with CC_ALLOW_INSECURE=true |
Diagnosing a failure. memory-archive start exits non-zero and names the setting to change:
| Symptom | Cause | Fix |
|---|---|---|
rejected the credentials for WatchCommands |
CC 1.1.0+ with no/invalid token | Set control_center_token with the monitor scope |
the server refused a plaintext connection |
CC 1.1.0+ reached without TLS | Use auto or strict; check the address |
the server certificate was not trusted |
Private CA not configured | Point control_center_tls_ca at ca.crt |
The version gate. Before subscribing, the capture stream reads GetServerIdentity and compares the reported version against the range this build was verified against. A version above that range is refused: the session is marked incomplete and memory-archive start exits non-zero, rather than recording under semantics this build does not know.
It refuses rather than adapts, deliberately. Protobuf already absorbs additive wire changes — an unknown field is ignored, so a new field in CommandEvent needs no code. What cannot be absorbed is a change of meaning in a field that already exists: position_captured was a best-effort readback before 1.2.0 and a verified value from 1.2.0, byte-identical on the wire. Nothing observable at runtime separates the two, so nothing at runtime can adapt to it — the knowledge lives in a changelog a person read. Adapting by guess would trade a loud failure for a silent one, and the artefact of guessing wrong is a corpus session that records confidently and wrongly, discovered long after the environment that produced it is gone.
| Situation | Behaviour |
|---|---|
| Version inside the supported range | Records; version stored as actuation_server_version |
| Version above the range | Refused. Message names the highest supported version |
| Version below the range | Refused. Upgrade Control-Center |
| No version reported, or unparseable | Allowed, with a warning — Control-Center 1.0.0 predates this check |
Two explicit escapes. control_center_max_version = "1.2.2" raises the ceiling without waiting for a Memory Archive build — use it once the release notes confirm the command stream is unchanged. It only ever raises: setting it lower cannot make the gate reject a version this build genuinely supports. control_center_allow_unsupported = true bypasses the gate entirely and logs the refusal as a warning on every connect; treat anything it records as unverified.
Upgrade both halves. The Control-Center controller and agent install independently, and 1.2.1 warns that a mismatched pair fails actuation closed while still reporting success. Sessions now record actuation_server_version and actuation_agent_version separately, and a mismatch is logged once when the first command event arrives.
Interpreting older sessions. position_captured changed meaning in 1.2.0: before it, the agent reported whatever the cursor readback returned; from 1.2.0 it reports false rather than publishing a coordinate it could not verify. Because mouse_x/mouse_y are non-optional, an uncaptured position is carried as (0, 0) — a real screen coordinate. Always read position_captured before the coordinates. Sessions recorded by 0.2.0 onward store actuation_agent_version and actuation_transport in metadata.json so the regime is recoverable; 0.3.0 adds actuation_server_version. Sessions recorded earlier leave these empty.
These values are never stored in config.json and are read exclusively from the environment at runtime:
| Variable | Required when | Description |
|---|---|---|
MA_IPC_TOKEN |
ipc_port is configured |
Admin IPC authentication token |
MA_ANNOTATOR_MGMT_TOKEN |
annotator_mgmt_port is configured |
Annotator management REST API bearer token |
MEMORY_ARCHIVE_CONFIG |
Always (optional) | Override path to config.json |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
AWS storage, if not using IAM role | AWS credentials |
AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET |
Azure storage, if not using managed identity | Azure service principal credentials |
GOOGLE_APPLICATION_CREDENTIALS |
GCP storage, if not using ADC | Path to GCP service account JSON key |
{
"model": {
"routing_policy": "fallback",
"requests_per_minute": 120,
"token_budget_per_hour": 2000000,
"circuit_breaker_threshold": 5,
"circuit_breaker_reset_seconds": 60,
"max_retries": 3,
"providers": [
{
"id": "primary-gpt4o",
"backend": "generic",
"endpoint": "https://api.openai.com/v1/chat/completions",
"api_key_ref": "env:OPENAI_API_KEY",
"model_id": "gpt-4o",
"priority": 1,
"cost_per_million_input_tokens": 2.50,
"cost_per_million_output_tokens": 10.00
},
{
"id": "fallback-claude",
"backend": "generic",
"endpoint": "https://api.anthropic.com/v1/messages",
"api_key_ref": "env:ANTHROPIC_API_KEY",
"model_id": "claude-sonnet-4-6",
"priority": 2,
"cost_per_million_input_tokens": 3.00,
"cost_per_million_output_tokens": 15.00
}
]
}
}API key references use the env:VARIABLE_NAME syntax. The secrets.py module resolves these at runtime. Plaintext keys in config are not supported.
{
"storage_backends": [
{
"name": "aws-us-east",
"provider": "aws",
"bucket": "memories-us-east",
"region": "us-east-1"
},
{
"name": "azure-eu-west",
"provider": "azure",
"account": "memoriesstorage",
"container": "memories-eu",
"storage_type": "blob"
},
{
"name": "gcp-asia",
"provider": "gcp",
"bucket": "memories-asia",
"project": "my-gcp-project"
},
{
"name": "local-fallback",
"provider": "local",
"path": "~/memories"
}
],
"storage_routing_rules": [
{
"match": { "tenant_prefix": "eu-" },
"backend": "azure-eu-west"
},
{
"match": { "tenant_prefix": "asia-" },
"backend": "gcp-asia"
},
{
"match": { "mode": "automated" },
"backend": "aws-us-east"
},
{
"match": {},
"backend": "local-fallback"
}
]
}Rules are evaluated top-to-bottom; the first matching rule wins. An empty match object is a wildcard (default route).
| Key pattern | Type | Description | TTL |
|---|---|---|---|
session:{session_id} |
Hash | All session fields (see below) | None — never expires; removed explicitly by delete_session |
claim:{session_id} |
String | {annotator_id}:{claim_id} |
30 min (refreshed by heartbeat) |
annotator:{annotator_id} |
Hash | Credential hash, status, claim counts, allowed tenants | None |
sessions:active |
Set | Session IDs with status active |
None |
sessions:pending |
Set | Session IDs with status pending_annotation |
None |
sessions:pending_human_annotation |
Set | Session IDs with status pending_human_annotation |
None |
sessions:annotating |
Set | Session IDs with status annotating |
None |
sessions:pending_compilation |
Set | Session IDs with status pending_compilation |
None |
sessions:reasoning_degraded |
Set | Session IDs with status reasoning_degraded |
None |
sessions:by_os:{OS_TYPE} |
Set | Session IDs indexed by OS type | None |
sessions:by_mode:{mode} |
Set | Session IDs indexed by capture mode | None |
session_id UUID string
mode manual | automated
status active | pending_annotation | pending_human_annotation |
annotating | pending_compilation | complete |
incomplete | reasoning_degraded
ma_core_addr ma-core IPC address that owns this session
tenant_id tenant identifier (optional)
os_type LINUX | WINDOWS | MACOS
os_version string (e.g., "Ubuntu 24.04 LTS")
os_architecture e.g., x86_64
os_environment_id opaque environment identifier
capture_server_id The-Eyes server identifier
capture_server_addr The-Eyes HTTP address (per-session override or global config)
actuation_server_id Control-Center server identifier
actuation_agent_version Control-Center agent version that produced the steps
actuation_transport capture stream transport: tls | plaintext
the_eyes_addr The-Eyes HTTP address
reasoning_model_id VLM model ID used (automated mode)
memory_name directory name for this memory
memory_path full path to memory directory
created_at ISO 8601 timestamp
updated_at ISO 8601 timestamp
total_steps integer
annotated_steps integer
skipped_steps integer
kafka_partition integer (cloud_primary mode)
kafka_offset integer (cloud_primary mode, last committed)
storage_backend name of pinned storage backend
model_provider primary VLM provider ID
model_endpoint primary VLM endpoint
model_api_key_ref primary VLM API key reference (env:VAR_NAME)
fallback_model_provider fallback VLM provider ID (optional)
fallback_model_endpoint fallback VLM endpoint (optional)
fallback_api_key_ref fallback VLM API key reference (optional)
context_window_steps steps of context sent to VLM per request
No session status carries a TTL. The record is the only index from a session id
to its capture on disk, and nothing can rebuild one from disk — the reconcile sweep
seeds from the Redis index sets, so it repairs only records that still exist.
Expiring a key therefore reclaims nothing: a session's bytes are its frames, not the
1.8 KB Hash, so the payload survives and the pointer is lost. Records are removed
explicitly, via delete_session.
| Status | TTL | Notes |
|---|---|---|
active |
None | Removed from set on status transition |
pending_annotation |
None | Removed from set on annotating transition |
pending_human_annotation |
None | Removed from set when claimed |
annotating |
None (claim TTL: 30 min rolling) | Claim expires if heartbeat stops |
pending_compilation |
None | Removed on FinalizeMemory |
complete |
None | Set at FinalizeMemory |
incomplete |
None — retention sweep at 1 year | Set at disconnect without done |
reasoning_degraded |
None | Transitions to pending_human_annotation on completion |
incomplete is the one status with a retention bound, INCOMPLETE_RETENTION_SECONDS
(one year). It is enforced by a sweep at ma-core startup, not a TTL: Redis key
expiry runs no application code, so it could only ever drop the record and orphan the
frames. The sweep removes the record, the stored objects and the directory together,
retries on the next start if any step fails, and skips any record whose updated_at
cannot be read — retention never deletes a session it cannot age. Because it runs at
startup only, a process running longer than the retention period will not clean up
until it restarts.
Before 0.3.2 the table above was wrong in both directions: pending_annotation and
annotating carried 7 days, incomplete 30, and complete 90 — set in two
independent places, since FinalizeMemory applied its own hardcoded 90 days without
consulting the status table. Records written by those versions shed their expiry the
next time their status is written, because update_status now clears a TTL rather
than only ever setting one.
When a session is registered, per-session capture_server_addr and actuation_server_addr fields can override the global control_center_addr and the_eyes_addr from config.json. These per-session addresses are stored in the Redis hash at registration. All subsequent capture loop operations (gRPC stream connection, The-Eyes HTTP requests) use the per-session address. This allows a single ma-core to serve sessions on different hosts — for example, 10,000 sessions each on a different VM/container running its own The-Eyes and Control-Center. The ma_session_server_address_source Prometheus counter distinguishes per-session overrides from global config usage.
In cloud_primary mode, each session is assigned a Kafka partition based on hash(session_id) % num_partitions. The partition and the last-committed offset are stored in the Redis session hash as kafka_partition and kafka_offset. Offsets are committed only after the corresponding step has been successfully written to the storage backend — guaranteeing at-least-once processing. On crash recovery, replay_session_events() creates a dedicated BaseConsumer with a unique consumer group, manually assigns the stored partition, seeks to kafka_offset + 1, and replays from that point. Events already processed (before the crash) are re-received but are idempotent to write (same content, same path, atomic rename).
A claim is created by a ClaimSession IPC message. ma-core atomically sets claim:{session_id} in Redis with {annotator_id}:{claim_id} and a 30-minute TTL, and moves the session from sessions:pending_human_annotation to sessions:annotating. The claiming annotator must send HeartbeatClaim IPC every 5 minutes to refresh the TTL. If the claim expires (no heartbeat for 30 minutes), the session returns to sessions:pending_human_annotation and becomes available for other annotators. A ClaimConflict response is returned if another annotator claims the same session concurrently (Redis atomic SETNX). ReleaseSession IPC explicitly removes the claim and returns the session to the queue.
On ma-core startup, two sweeps run serially before the IPC server accepts connections:
-
Startup sweep: Iterates over all sessions in
sessions:active. For each, checks whether the PID that registered the session is still alive. If not, transitions the session toincompleteand renames the memory directory (local mode only). -
Reconcile sweep: Iterates over all sessions in
sessions:annotating. For each, checks whether the associatedclaim:{session_id}key still exists in Redis. If the claim has expired (TTL elapsed), returns the session tosessions:pending_human_annotation. This handles the case wherema-corewas restarted while an annotator had an active claim.
The Prometheus metrics endpoint is available at http://{host}:{metrics_port}/metrics (default port 9091). By default it binds 127.0.0.1 (observability.metrics_bind_addr), so metrics are reachable only from the local host. To scrape it remotely, set observability.metrics_bind_addr to a routable address and observability.metrics_token; if a non-loopback address is set without a token, ma-core refuses to expose unauthenticated metrics and falls back to loopback with a CRITICAL log. When a token is set, requests must include an Authorization: Bearer <token> header.
| Metric | Type | Labels | Description |
|---|---|---|---|
ma_active_sessions |
Gauge | — | Currently active sessions |
ma_steps_total |
Counter | — | Total steps captured (use rate() for steps/sec) |
ma_cloud_upload_queue_depth |
Gauge | — | Files pending upload in SyncWorker queue |
ma_cloud_upload_errors_total |
Counter | — | Permanent upload failures |
ma_ipc_push_queue_depth |
Gauge | — | Pending outbound IPC push messages |
ma_ipc_tcp_connections_active |
Gauge | — | Active remote TLS TCP IPC connections |
ma_kafka_consumer_lag |
Gauge | — | Kafka consumer lag (cloud_primary mode only) |
| Metric | Type | Labels | Description |
|---|---|---|---|
ma_vlm_requests_total |
Counter | provider |
VLM API requests dispatched |
ma_vlm_request_latency_ms |
Histogram | provider, quantile |
Latency at p50, p95, p99 |
ma_vlm_errors_total |
Counter | provider, error_type |
VLM API errors by provider and error type |
ma_vlm_circuit_breaker_open |
Gauge | — | 1 if any session circuit is open, 0 otherwise |
ma_vlm_tokens_consumed_total |
Counter | — | Total tokens consumed across all VLM calls |
ma_sessions_reasoning_degraded |
Gauge | — | Sessions currently in reasoning_degraded status |
| Metric | Type | Labels | Description |
|---|---|---|---|
ma_storage_backend_errors_total |
Counter | backend |
Storage backend operation errors |
ma_storage_routing_decisions_total |
Counter | backend |
Sessions routed to each named backend |
| Metric | Type | Labels | Description |
|---|---|---|---|
ma_pricing_registry_fetch_status |
Counter | status |
Fetch outcomes: success, signature_failure, network_failure, cache_hit |
ma_pricing_registry_age_seconds |
Gauge | — | Age of the current pricing manifest in seconds |
| Metric | Type | Labels | Description |
|---|---|---|---|
ma_annotator_active_claims |
Gauge | annotator_id |
Current concurrent claims per annotator |
ma_annotator_auth_failures_total |
Counter | annotator_id |
Cumulative authentication failures per annotator |
| Metric | Type | Labels | Description |
|---|---|---|---|
ma_session_server_address_source |
Counter | source |
Sessions using per_session vs. global_config addresses |
All alerts are delivered via the observability.alert_webhook_url webhook as JSON POST requests.
| Condition | Severity | Trigger |
|---|---|---|
| Cloud upload permanently failed | ERROR | Upload exhausts all retries |
| VLM circuit breaker opened | WARNING | Per-session circuit opens |
| All fallback chain providers degraded | ERROR | Primary + fallback both in open state |
| Redis connection lost | CRITICAL | Redis ping fails |
ma-core RSS memory above memory_warn_mb |
WARNING | RSS exceeds configured threshold |
Kafka consumer lag above kafka_lag_warn |
WARNING | ma_kafka_consumer_lag exceeds threshold |
Cloud upload queue above upload_queue_warn |
WARNING | ma_cloud_upload_queue_depth exceeds threshold |
IPC push queue above ipc_push_queue_warn |
WARNING | ma_ipc_push_queue_depth exceeds threshold |
| Storage backend unreachable at registration | ERROR | Health check at registration time fails |
| Pricing registry signature verification failed | ERROR | Ed25519 verification fails on manifest |
| Pricing registry manifest older than 7 days | WARNING | ma_pricing_registry_age_seconds > 604800 |
| Annotator auth failures > 10 in 60 seconds | WARNING | Possible brute-force attempt |
ma-core emits structured JSON logs via the tracing crate. Each log entry includes:
{
"timestamp": "2026-04-09T12:01:04.286Z",
"level": "INFO",
"target": "ma_core::capture::stream",
"session_id": "f83a1d2c-...",
"message": "Step 0042 captured",
"fields": {
"action_type": "mouse_click",
"step_id": 42,
"upload_queued": true
}
}Log destinations: stdout (foreground mode) or the configured --log-file path (daemon mode). Per-component log levels are set in config.json under observability.log_level:
{
"observability": {
"log_level": {
"ma_core::capture": "debug",
"ma_core::storage": "info",
"ma_core::ipc": "warn"
}
}
}Alerts are delivered as HTTP POST requests to observability.alert_webhook_url:
{
"severity": "ERROR",
"condition": "cloud_upload_permanent_failure",
"session_id": "f83a1d2c-...",
"detail": "File vision/frames/step_0042_at.png failed after 5 retries",
"timestamp": "2026-04-09T12:01:04.286Z"
}The webhook target can be any HTTP endpoint — Slack incoming webhook, PagerDuty Events API, custom alertmanager, etc.
StorageRouter (storage/router.rs) is evaluated once per session registration. It receives a SessionRegistrationRequest containing tenant_id, mode, os_type, and any routing hints from the registration call. It evaluates the routing rules array top-to-bottom and returns the name of the first matching backend. The backend name is written to storage_backend in the Redis session hash and is never re-evaluated for the lifetime of the session.
Routing rule DSL (config.json):
{
"match": {
"tenant_prefix": "eu-", // tenant_id must start with this string
"region_tag": "eu-west", // session-level region hint
"mode": "automated", // session mode
"os_type": "LINUX" // OS type
},
"backend": "azure-eu-west"
}All conditions in a match object are ANDed. An empty match object matches any session and acts as a default route. Multiple named backends (e.g., aws-us-east, azure-eu-west, gcp-asia, local-dev) can coexist per ma-core instance. Each backend has its own credential configuration, resolved from separate environment variables.
ma_storage_routing_decisions_total{backend} tracks which backend each session was routed to. ma_storage_backend_errors_total{backend} tracks errors per backend.
ModelRouter is stateless — it holds no per-session state. It applies one of three routing policies:
pinned: Always selects the provider with the lowestpriorityvalue (highest priority). Ignores all other providers.fallback: Selects the highest-priority provider. If the per-session circuit breaker for that provider is open (tracked inReasoningPipeline, notModelRouter), selects the next-highest-priority provider. Maximum two providers per session.load_balance: Distributes requests across providers using weighted round-robin based onpriorityvalues. All providers share a single per-session circuit breaker.
Per-session circuit breaker state lives in ReasoningPipeline._SessionState. State transitions:
- Closed: normal operation; failures counted.
- Open: circuit tripped (threshold reached); all requests immediately return
model_degraded; timer starts. - Half-open: after
circuit_breaker_reset_seconds, one trial request is sent. If it succeeds: → Closed, failure count reset. If it fails: → Open, timer resets.
When both the primary and fallback circuits open for a session, ReasoningPipeline sends a ReasoningDegraded IPC message to ma-core, which transitions the session to reasoning_degraded in Redis. Capture continues; steps written after degradation carry source: model_degraded in reasoning.jsonl.
The pricing registry (model/pricing.py) resolves token cost rates in three tiers:
Tier 1 — MA-hosted Ed25519-signed manifest:
{
"generated_at": "2026-04-01T00:00:00Z",
"models": [
{
"model_id": "gpt-4o",
"aliases": ["gpt-4o-2024-11-20", "gpt-4o-2025-01-01"],
"input_cost_per_million": 2.50,
"output_cost_per_million": 10.00
}
],
"signature": "<Ed25519 signature over the JSON body>"
}The manifest is fetched from a hardcoded URL at startup, cached in ~/.memory-archive/pricing-cache.json, and re-used for 24 hours. On cache load, the signature is verified against the hardcoded public key. A failed verification deletes the cache, fires an ERROR alert, and triggers a fresh fetch. If the fresh fetch also fails verification, the system falls through to Tier 2 or Tier 3. The manifest age is tracked by ma_pricing_registry_age_seconds.
Tier 2 — AWS Bedrock Pricing API:
For models hosted on AWS Bedrock, pricing:GetProducts is queried against the Bedrock service code. This IAM action must be in a separate policy from inference permissions. Bedrock pricing data is used when the model ID matches a known Bedrock model pattern and the manifest does not contain an entry for it.
Tier 3 — Configured cost rates:
cost_per_million_input_tokens and cost_per_million_output_tokens fields in ProviderConfig (config.json). Used as a final fallback for any model not found in the manifest or Bedrock pricing.
Model alias resolution: the pricing registry first checks model_id exactly, then checks aliases[] for each manifest entry. First match wins. This handles version suffixes (e.g., gpt-4o-2024-11-20 resolves to the gpt-4o entry).
In large deployments where each OS environment runs its own The-Eyes and Control-Center on a distinct host, every session registration call includes --capture-server-addr and --actuation-server-addr overrides. These values are stored in Redis at capture_server_addr and the_eyes_addr fields. When the capture loop starts for a session, it reads the per-session address from Redis rather than the global config. This requires no ma-core reconfiguration as new OS environments are added — each session simply declares its own endpoints.
ma_session_server_address_source{source="per_session"} vs. {source="global_config"} lets operators verify that all production sessions are using per-session addressing and that no session is accidentally falling back to a stale global config address.
ma-core exposes a REST API on annotator_mgmt_port (default 9002) for annotator lifecycle management. All endpoints require Authorization: Bearer <MA_ANNOTATOR_MGMT_TOKEN>.
| Method | Path | Description |
|---|---|---|
POST |
/v1/annotators |
Register a new annotator |
GET |
/v1/annotators |
List all annotators |
GET |
/v1/annotators/{annotator_id} |
Get a single annotator |
DELETE |
/v1/annotators/{annotator_id} |
Deactivate an annotator |
POST |
/v1/annotators/{annotator_id}/rotate-key |
Rotate annotator key |
POST /v1/annotators request:
{
"annotator_id": "alice",
"allowed_tenant_ids": ["acme-corp", "beta-labs"],
"max_concurrent_claims": 3
}POST /v1/annotators response:
{
"annotator_id": "alice",
"key": "<plaintext key — shown once>",
"status": "active"
}The REST API is an alternative to the annotator-admin CLI commands and enables programmatic annotator lifecycle management from CI/CD systems, orchestration layers, or admin dashboards.
ma-kafka-producer is a Rust binary in the workspace that bridges a single Control-Center WatchCommands gRPC stream to the control-center-events Kafka topic. It is intended for development and testing — specifically, for running automated mode tests against a live Control-Center without a full production Kafka-native CC deployment. In production, Control-Center publishes directly to Kafka.
memory-archive server kafka-bridge \
--session "$SESSION_ID" \
--cc-addr localhost:50051 \
--kafka-broker localhost:9092Each event published by ma-kafka-producer is wrapped in a KafkaEnvelope:
{
"schema_version": "1.0",
"session_id": "<session_id>",
"event_type": "command_event",
"timestamp": "2026-04-09T12:01:04.286Z",
"payload": { ... }
}The partition key is session_id, guaranteeing ordered delivery within a session on the consumer side.
In cloud_primary mode, ma-core tracks kafka_partition and kafka_offset in Redis per session. The offset is committed after each step is successfully written to the storage backend. If ma-core crashes mid-session, on restart it runs the startup sweep, identifies the session as active with a stale PID, and invokes replay_session_events():
- A dedicated
BaseConsumeris created with a unique consumer group ID (not the sharedmemory-archive-workersgroup) to avoid disrupting other sessions. - The consumer manually assigns the stored Kafka partition.
- It seeks to
kafka_offset + 1(the first unprocessed offset). - It replays events from that point forward, writing each step atomically to storage.
- Each successful write updates
kafka_offsetin Redis. - When the end of the partition is reached (or the
doneevent is encountered), the replay completes and the session resumes its normal lifecycle.
Because all storage writes use atomic temp-file-then-rename, re-processing an event that was already written (in the window between the write and the offset commit) is safe — the rename is idempotent for the same content.
{memory_name}/
├── memory.md # Compiled output — the final deliverable
├── metadata.json # The record — full session metadata, token counts, step index
├── commands/ # Derived views of metadata.json, written for reading
│ ├── raw_input.md # Raw command strings, [FAILED] prefix on failed
│ ├── converted_input.md # Human-readable (e.g., "Left-click at (960, 540)")
│ ├── actuation_commands.json # Full CommandEvent JSON array
│ └── cc_commands.json # Control-Center replay format
├── vision/
│ └── frames/
│ ├── step_0001_20260409_120104_before.webp
│ ├── step_0001_20260409_120104_at.png # Always PNG (re-encoded after marker.rs annotation)
│ ├── step_0001_20260409_120104_after.webp
│ └── closing_state.webp # Final screen state on session done
└── reasoning/
└── reasoning.jsonl # One JSON entry per step
metadata.json is the record; commands/ is a projection of it. The four command files are written per step for human reading and for replay, and they are never read back as a source of truth — a correction applied to metadata after the fact does not propagate into them, and they are not regenerated, because regenerating would overwrite a corrected value with the superseded one. Consumers should read metadata.json.
An incomplete session (disconnect without done) has its directory renamed to {memory_name} (incomplete)/ in local mode. In cloud_primary mode, the incomplete flag is stored in metadata.json and in the Redis session hash.
Last Updated: July 2026
Developer: Kartik (NullVoider)
Memory Archive is released under the Apache 2.0 License.
Memory Archive was built from scratch as the data collection and annotation layer for Computer Use Agent (CUA) workflows. Every file format, and every pipeline decision — three frames per step, atomic writes, per-session circuit breakers, signed pricing — was designed around the real constraints of producing reliable training data at scale: data that is structurally consistent, crash-recoverable, and usable from pre-training through inference without format translation.
The tool operates as one part of a three-component CUA stack alongside The-Eyes (vision capture) and Control-Center (desktop actuation), with each component designed to be independently deployable and observable. Memory Archive sits outside the OS environment entirely — read-only by design — recording what the other two components do and turning those recordings into memories a CUA can follow.
Memory Archive — Training data infrastructure for the AI age 🗄️

