mnemon memory-agent aml-serve --admin-listen ADDR serves the operator's side of one engine
instance on a listener of its own. The public listener (--listen, the Add/Search contract and
/health) serves none of the routes below. The alert watcher (aml-watch), Prometheus and the
memory cloud console read this listener; the deployment is in the
AML runbook.
The admin listener is plain HTTP. Its routes read, but for the two pairs that set and revert
the account's own provider keys (/admin/v1/provider-keys) and the budget caps
(/admin/v1/budgets), below: the one other thing a request makes is /ready's probe of the
data root, a file it creates there and removes; none touches a user's records.
--admin-listen is empty by default: there is no admin listener. When it is set it takes an IP
address literal and a port, such as 127.0.0.1:8091. A host name is not accepted: what it
resolves to is not the command's to check.
| Address | Admin token |
|---|---|
Loopback: 127.0.0.0/8, ::1 |
optional |
Private: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 (netip.Addr.IsPrivate) |
required |
| Anything else | refused, token or not |
"Anything else" includes the unspecified addresses (0.0.0.0, ::, or no host), link-local
ones (169.254.0.0/16, fe80::/10), shared address space (100.64.0.0/10) and every public
address. So is an address with a zone ([fe80::1%eth0]:8091) or without a port. An IPv4 address
spelled as an IPv6 one ([::ffff:10.0.0.5]:8091) is judged as the IPv4 address. A refusal is a
startup error that names the flag and the rule; the command has not locked the data root or
opened anything by then. An address that passes the rules but cannot be bound (the overlay
address before wg0 is up) is not a startup error: the data plane never depends on the admin
listener, so the process logs the failure once, serves its public listener, and binds this one
again every 5 seconds until it can (runbook, "Private network").
--admin-token-env NAME names the environment variable that holds the admin token. It is empty
by default: no token. When it is set:
- The variable must hold at least 32 bytes (not characters: a token of multi-byte characters
counts each byte). If it is unset, empty or shorter, startup fails with an error that names
--admin-token-envand the variable, never its value. This holds whatever the address, and with no--admin-listenat all. - It must be another variable than the one
--auth-key-envnames (AML_API_KEYby default): the admin token and the API key are two secrets. Startup is refused otherwise. - Every request to the admin listener must carry the token (below).
Make a token from a source of randomness, for example openssl rand -hex 32, and keep it in the
instance's environment file (root-owned, mode 0600) and nowhere else a flag, a URL or a log could
show it. Bind a private address only on a network whose firewall lets just the hosts that read
the listener reach it: the token is the second lock, not the first, and it crosses that network
in clear text.
Every engine instance has its admin listener on its host's WireGuard address, with a token (runbook, "Instances" and "Private network"):
aml-serve@.servicepasses--admin-listen ${AML_ADMIN_LISTEN} --admin-token-env MNEMON_ADMIN_TOKEN.mnemon-instance newsetsAML_ADMIN_LISTENto the host's overlay address (--admin-ip) and the instance's admin port: 8091 on a dedicated host, 8101 + 10 × the slot on a shared one.- It makes the token,
mnm_and 32 random bytes in base64url without padding (47 characters), and writes it asMNEMON_ADMIN_TOKENinto the instance's two environment files,envandwatch.env, and into the one-time registration JSON for the console. Nowhere else holds it but the console's registry. - The instance's watcher (
aml-watch@<n>,--admin-token-env MNEMON_ADMIN_TOKEN), the console and, through the console, Prometheus read the listener. The instance hosts' firewalls admit the admin ports (8091 to 8600) only from the overlay's hub,10.77.0.1, onwg0. - Rotating the token is a manual step: write the new one into both environment files, restart
aml-serve@<n>, and set it in the console.
With an admin token set, every request to the admin listener must carry
Authorization: Bearer <token>
The scheme is read in any case (Bearer, bearer). Nothing else stands in for it: not an
X-Api-Key header, not the Token scheme the public listener also accepts, and never a
token in the URL's query. The rule covers all the routes below, the profiler and a path that no
route serves, which is answered 401 before it can be answered 404. A request is checked before
anything else is done for it.
The token and the configured one are compared as SHA-256 digests with crypto/subtle's
constant-time comparison, so neither the token nor its length leaks through timing.
A request without the token, or with a wrong one, is answered:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mnemon-admin"
Content-Type: application/json
{"error":"missing or invalid admin token"}
Each such answer is counted in mnemon_aml_admin_auth_failures_total (a counter with no labels)
and no line is logged: the counter is the signal. The token, what a client sent in its
place, and anything made from either never reach a log, an error, a metric or an answer.
Without an admin token (a loopback listener only) the admin listener asks nothing of a request
and works as it always did; mnemon_aml_admin_auth_failures_total stays 0.
| Route | Answer |
|---|---|
GET /metrics |
the Prometheus text format |
GET /ready |
readiness: 200, or 503 with the reasons |
GET /admin/status |
the status the watcher reads, as JSON |
GET /admin/v1/stores |
the store overview, as JSON |
GET /admin/v1/events |
the latest WARN and ERROR lines, as JSON |
GET /admin/v1/logs |
the latest lines of every level, filtered and paged, as JSON |
GET /admin/v1/logs/export |
every line the log files hold for a range of hours, as one gzip file |
PUT /admin/v1/provider-keys |
the account's own provider keys: checked, written, swapped in |
DELETE /admin/v1/provider-keys?kind= |
back to the host's keys |
PUT /admin/v1/budgets |
budget caps at run time, a cap or unlimited per kind |
DELETE /admin/v1/budgets?kind= |
back to the configuration's caps |
PUT /admin/v1/read |
the role reading consolidation reads by, at run time |
DELETE /admin/v1/read |
back to the configuration's role reading |
/debug/pprof/ and below |
Go's profiler |
| anything else | 404 {"error":"not found"} |
/metrics, /ready, /admin/status, /admin/v1/stores, /admin/v1/events, /admin/v1/logs and
/admin/v1/logs/export are for GET, and
/admin/v1/provider-keys, /admin/v1/budgets and /admin/v1/read for PUT and DELETE only (the
keys are write-only: no route answers them; the caps and the role reading are in
/admin/status). Any other method on them is answered by the 404 route. The answers of /ready,
/admin/status, /admin/v1/events, /admin/v1/logs, /admin/v1/logs/export,
/admin/v1/provider-keys, /admin/v1/budgets, /admin/v1/read and (when it succeeds)
/admin/v1/stores are Cache-Control: no-store.
The server's own registry, with the Go and process collectors, in Prometheus's text format.
Every series of the server is named mnemon_aml_* and has a HELP line; the Go and process
collectors' (go_*, process_*) come beside them. No user, request or path ever labels a
series. Beside the series of the Add and Search paths, the models, the budgets and the
consolidation queue, it carries these, which the memory cloud console reads:
| Series | Meaning |
|---|---|
mnemon_aml_admin_auth_failures_total |
admin requests refused with 401 (no labels) |
mnemon_aml_model_attempts_total{kind,class,outcome} |
attempts of model calls. outcome is ok, throttled (refused with HTTP 429), invalid (a 200 reply that did not parse or failed validation), rejected (refused with HTTP 401, 402 or 403: the key or the balance), retryable, failed, breaker-open, bulkhead-full or budget; the console counts every outcome but ok as a model failure |
mnemon_aml_open_stores |
user stores the server holds open now |
mnemon_aml_stores |
user stores in the data root, from the latest scan (at most 60 s old) |
mnemon_aml_store_bytes |
bytes of the regular files of those stores, from the same scan |
mnemon_aml_consolidation_lag_bound_characters |
the backlog, in characters, above which an Add waits for consolidation; 0 unless --consolidate background. mnemon_aml_consolidation_max_backlog_characters is measured against it |
mnemon_aml_add_messages_total{role} |
messages Adds stored, by role: user, assistant, system, tool, a role the role reading names, or other |
mnemon_aml_consolidation_read_units_total{role,outcome} |
UTF-16 units of conversation lines consolidation read or skipped by its role reading, by role (same names) |
mnemon_aml_consolidation_batches_total{outcome} |
batches folded, deferred (linking failed) and dropped |
mnemon_aml_model_spend_total{kind,class} |
spend of model calls as the budget charges it (every input token at the cache-miss price), in the account's currency (deepseek CNY, jev USD), by call class: interactive (a Search's read), flush (the tail a Search folds first) and background (consolidation jobs) |
mnemon_aml_model_tokens_total{kind,class,type} |
tokens of model calls by type: input, cached (input the provider served from its cache, billed lower: DeepSeek's prompt_cache_hit_tokens; the bill is below the spend by about their share) and output |
mnemon_aml_search_items, mnemon_aml_search_characters |
histograms of what a Search answered: its items, and the characters of their content |
mnemon_aml_search_flush_seconds |
histogram of the time a Search spent folding what was pending before it read, when it folded anything (each user's first Search after Adds) |
mnemon_aml_stores and mnemon_aml_store_bytes come from the scan GET /admin/v1/stores
reads (below): a scrape makes a new scan only when the latest is older than 60 seconds, and
shares it with a request that asks at the same time. A scan that fails leaves the two series out
of that scrape rather than report 0, and the failure is logged once per streak of failures. The
scrape waits for a scan for 5 seconds at most, also when the scan it joined was started by a
GET /admin/v1/stores request (which has no such deadline): it leaves at its own, so a data root
whose scan takes longer is not measured here. They carry no store or user label.
200 with {"ready":true}. Otherwise 503 with {"ready":false,"reasons":["…"]}, every reason
given: the data root is not writable, its disk has less than 1 GiB free, a provider's circuit
breaker is open, or a paid account has spent and held more than 95% of its cap (an unlimited
cap has no share, and is never a reason).
The server's counters and state, as JSON. The watcher reads it, so its field names are a contract:
fields may be added, never renamed or repurposed. Counters count from startedAt. Users appear as
counts only.
| Field | Meaning |
|---|---|
startedAt |
when the server was made (RFC 3339); differs after every restart |
adds, searches |
Adds and Searches answered 200 |
degraded |
Searches answered by the raw ranking because the memory read failed |
partial |
Searches answered by a read in which a stage fell back |
dropped |
drops of records from the consolidation layer |
linkFallbacks |
batches linked without JEV because JEV failed |
addOutran |
Adds that waited out their window for consolidation and answered anyway |
partialLast10m, searchesLast10m |
partial Searches, and Searches, in the last ten one-minute buckets |
queue |
the consolidation scheduler: queued, running, backingOff, held, oldestQueued (nanoseconds) and maxBacklog (characters) |
lagBound |
the backlog, in characters, above which an Add waits (0 unless --consolidate background) |
breakers, breakerTrips |
by provider kind, the breaker's state, and how many times it has opened |
rejectedByKind |
by provider kind, the requests the provider rejected with 401, 402 or 403 (a wrong or revoked key, an empty balance); it only grows, from 0 for every kind the models have. The watcher's provider-rejected:<kind> alert reads it |
budgets |
each paid account that has been used: kind, cap, spent, held, source (config, or settings for a cap set at run time) and unlimited (cap is then null); always an array |
openStores |
user stores open now |
version |
the product version string of the build (dev for a build made without one) |
providerKeys |
by key kind the configuration's models use (deepseek, jev): {"source","fingerprint"}. source is account (the key file's key), host (the environment's) or none (neither: the variable is empty, and the kind's calls fail); fingerprint is the key's (below), "" with none. Never a key. An empty object when the configuration has neither kind |
messages |
by role, the messages Adds stored (the names of mnemon_aml_add_messages_total); {} before the first |
read |
the role reading: policy (in force), source (config or settings), config (the configuration's) and units, by role, the read and skipped UTF-16 units of consolidation; absent without timelines or layered consolidation |
A read-only overview of the user stores in the data root, for the console. Query:
limit: 1 to 500; 100 when absent.after: a cursor, exclusive: thenextof the previous page. Exactly 32 lowercase hex digits.
A limit or after that is malformed, an empty one included, is answered
400 {"error":"…"}, and so is a query string that cannot be read: one whose percent-encoding is
malformed (after=%zz), or whose pairs are separated by ; (only & separates them). It is not
read as the query without that pair, and the error says which:
the query string is malformed: its percent-encoding is invalid or
the query string is malformed: pairs are separated by '&', not ';'.
{
"total": 12,
"bytes": 734003200,
"scannedAt": "2026-10-25T08:00:00Z",
"stores": [
{"store": "58e645e0", "bytes": 61440000, "modified": "2026-10-25T07:59:12Z",
"open": true, "backlog": 12345, "consolidation": "running", "failures": 0}
],
"next": "58e645e06cad8b5e2d3b842ebef7c1c5"
}total,bytesandscannedAtdescribe the whole data root: the number of stores, the sum of their bytes, and when the scan ended (RFC 3339, UTC).storesis a page, in the order of the stores' keys. Each is named bystore, the first 8 hex digits of its key, as the logs name a user. Two stores may share it. The key is the first 32 hex digits ofsha256(user_id), and the store is the directory<data-root>/users/<key>. The user id is not stored anywhere, so no id can be shown, and neither is any record's content.bytesis the sum of the sizes of the regular files directly in the store's directory (records.db, its-waland-shmfiles,memory-index.dband the like). A subdirectory or a link is not a file of the store.modifiedis the newest modification time among those files (the directory's own when it holds none), in RFC 3339, UTC, to the second.opensays the server holds the store open now: a read of the registry of open stores, with no I/O. A store that is not open costs nothing until a request needs it.backlogis the scheduler's estimate of the characters (UTF-16 units, as consolidation counts them) of the store's records not yet consolidated, the unit ofqueue.maxBacklogandlagBound. It is known from the user's first Add, or first consolidation job, since the process started: that Add measures the backlog once, after its write and before it lets go of the user's write lock (reading only: the user's other Adds wait for that one read, other users' Adds do not), and the estimate then counts what the Adds after it write. It isnullwhen the scheduler has no estimate: outside--consolidate background; before that first Add or job; and for a store with nothing pending, which the scheduler stops tracking.consolidationis the scheduler's state of the store's user:queued,running,backing-off(after failed jobs;failurescounts them in a row),held(idle, held by a Search's flush), or""when idle or outside--consolidate background. Read for each page.nextis the last store of the page, as a cursor, to pass back asafter. It isnullon the last page. A cursor that names no store is where the order resumes, so a store removed between two pages does not end the walk.
The scan that makes bytes and modified lists the directories under <data-root>/users/ whose
names are user keys, reads each store's directory and nothing else, and honors the request's
context between stores. It is kept for 60 seconds, the first request after that makes the next,
and the requests that come meanwhile wait for it and share it (each leaves when its own
deadline or its client does), so a console that polls cannot make a storm of directory reads.
open and backlog are read for each page when it is asked for, not kept. If the scan fails the
answer is 500 {"error":"the store scan failed"} (503 when it was interrupted), nothing is kept,
and the server logs the failure once, naming a store by its 8-digit prefix, until a scan goes well
again.
The latest WARN and ERROR lines the server logged, from memory (the last 500 since the start):
?level=warn (the default: WARN and ERROR) or error, and ?limit=1..500 (100 by default).
Anything else is a 400. GET /admin/v1/logs?level=warn reads the same lines with filters and
pages; this route stays for consoles that read it.
{"total": 37, "events": [{"seq": 9120, "time": "2026-10-27T02:15:04Z", "level": "WARN",
"message": "degraded search 3 (user 58e645e0): …", "user": "58e645e0", "requestId": "9d44…"}]}events are the newest first, as GET /admin/v1/logs gives its lines; total counts every one
since the start, those the memory no longer holds included.
The latest lines the server logged, of every level, from memory, for the console's 日志 view: it
pages back through them and tails the new ones. The memory holds the last 10000 lines, and the
last 500 WARN and ERROR lines besides, which a burst of request lines cannot push out; journald and
the log files (GET /admin/v1/logs/export) hold every line. Its query, every parameter optional:
| Parameter | Meaning |
|---|---|
level |
info (the default: every line), warn (WARN and ERROR, from the 500) or error |
q |
at most 200 characters, at most 8 terms split at white space; a line matches when each term is in its message, user, request id or roles, case aside |
minMs |
only the request lines that took this many milliseconds or more |
before |
only lines numbered below this seq: the next page back |
after |
only lines numbered above this seq: what came since (not with before) |
limit |
1 to 200, 100 by default |
Anything else is a 400 {"error": "…"}.
{"lines": [{"seq": 9123, "time": "2026-10-27T03:07:12.345Z", "level": "INFO",
"message": "POST /add 200 532ms", "user": "ab12cd34", "requestId": "9d44…",
"roles": "assistant=1,user=3", "status": 200, "ms": 532}],
"more": true, "skipped": 0, "since": "2026-10-27T01:12:40Z", "kept": 10000, "total": 123456,
"archive": {"since": "2026-10-20T00:00:00Z", "bytes": 12345678, "dropped": 0}}linesare the newest that match, the newest first;seqnumbers every line from 1 since the start, in the order they were logged. A line carries the user's 8-hex store prefix, the request id and an Add's roles when it had them; a request line (METHOD /path STATUS NNNms) carries itsstatusandmstoo (msleft out at 0). No line holds a key or a record's content; a message is cut at 4096 bytes (journald and the files keep it whole).more(withoutafter): older lines that match are still in memory; ask again withbeforethe last line'sseq.skipped(withafter): lines newer thanafterthat match and were left out, because more thanlimitcame since; the page holds the newest of them.since: the time of the oldest line the memory read holds; left out while it holds none.kept: the most it holds, 10000 forinfo, 500 forwarnanderror.total: the lines it has seen since the start.archive: what the log files hold, left out while there are none: the first hour kept, their bytes on disk, and the lines dropped since the start (the writer never holds up a line: one that finds its queue of 8192 full, or that the disk refuses, is dropped and counted).
Every line the log files hold for the whole hours a range touches, the oldest first, as one gzip
file of JSON lines, for an offline look (zcat, jq, DuckDB). The server writes every line it
logs, as journald gets it in JSON, to a file per hour under <data-root>/logs/
(2026-10-27T03.jsonl, gzipped to 2026-10-27T03.jsonl.gz once the hour has passed), and keeps
30 days and 1 GiB at most, dropping the oldest first. ?from= and ?to= are RFC 3339 times,
either left out; an hour is in when it ends after from and starts before to. A bad time, or
from not before to, is a 400.
The answer is 200, Content-Type: application/gzip,
Content-Disposition: attachment; filename="mnemon-logs-<first hour>-<last hour>.jsonl.gz",
streamed with no Content-Length: a gzip member per hour, which gzip, zcat and Go's and Python's
readers read as one file (an empty gzip file when no hour is in). One export runs at a time;
another is a 429 {"error": "a log export is running; retry"} with Retry-After: 30. Each hour's
file has a minute to be sent, past the listener's write timeout, so a long export is not cut and a
stalled client is.
The account's own DeepSeek and JEV keys (BYOK, ruling R98): the console's 设置 → 模型服务 API key
sends them here (through its PUT /api/instances/:id/provider-keys). For each kind, the account's
key, when it has one, is sent in place of the host's.
{"deepseek": "sk-…", "jev": "…"}- The body is a JSON object of at most 4 KiB (else
413 {"error":"too_large"}); not an object, empty, or followed by more than white space:400 {"error":"invalid","field":"body"}. - At least one of
deepseekandjev, each a string of 8 to 512 printable ASCII characters without spaces; one that is not:400 {"error":"invalid","field":"<kind>"}. - Another field is
400 {"error":"invalid","field":"<name>"}. A field whose name is not a plain name (1 to 32 letters, digits and_, starting with a letter) is namedbodyinstead: what stands there may be a key in the wrong place. - A kind the configuration has no model of:
409 {"error":"unknown_kind","kind":"<kind>"}. - Without
--provider-keys-file:409 {"error":"no_key_file"}. The keys cannot change at run time without a file to keep them in.
What it does, in order:
-
Checks every key given before any is applied, DeepSeek's first, with one request to its provider:
GET {base}/modelswithAuthorization: Bearer <key>, where{base}is the model's endpoint without its route (https://api.deepseek.com,https://api.typesafe.ai/v1; a model'stestEndpointlikewise), through the provider's own transport (HTTP/1.1). It is not an attempt of the resilience layer: no retry, breaker, bulkhead, metric or rejection count sees it, and it spends no budget. When that route answers 404 or 405, one minimal call of the model of that kind takes its place: a chat completion ofmax_tokens1 (for JEV, whose API decides rather than completes, a decision of one question), likewise outside every budget; its few tokens are the provider's to bill, andcheckedsayscompletion. A kind's check, its fallback included, has 10 seconds.- a 2xx: the key is good;
- 401, 402 (an empty balance) or 403:
400 {"error":"key_rejected","kind":"<kind>","status":<status>}; - anything else (a timeout, a broken connection, a 5xx, a 429, a redirect):
502 {"error":"key_check_unavailable","kind":"<kind>"}.
The first kind that fails answers; a kind after it is not checked.
-
Writes and swaps, only when every key given passed: the keys given are merged into the key file, over the other kind's account key if it has one (which stays); the file is written atomically; the keys in memory are swapped. A key replaced applies to the model calls that start once the reply is sent; calls in flight finish, retries included, with the key they started with. No restart. A write that fails changes nothing and answers
500 {"error":"internal"}(the server logs why). -
Logs one INFO line, naming the kinds given:
provider keys replaced: deepseek 0123456789ab, jev ba9876543210. A key's fingerprint is the first 12 hex digits of its SHA-256. A refusal by the provider is a WARN line,provider keys not replaced: the deepseek key was rejected (HTTP 401), or…: the jev key: the key could not be checked: HTTP 503.
The reply is 200, every configured kind's status, and how each key given was checked:
{"providerKeys": {"deepseek": {"source": "account", "fingerprint": "0123456789ab"},
"jev": {"source": "host", "fingerprint": "ba9876543210"}},
"checked": {"deepseek": "models", "jev": "completion"}}No error changes anything: neither the file nor the keys in memory. The key itself never appears in a log, an error, a metric or a reply, of this route or any other; only its fingerprint does.
Reverts the account's keys of a kind, or of every kind (all), to the host's: they leave the key
file, which is removed when no key is left, and the kinds' calls send the host's keys at once
(those in flight keep theirs). Reverting a kind that has no account key changes nothing, and
answers the same. The reply is 200 {"providerKeys": {…}}, the shape above, and one INFO line is
logged: provider keys reverted to the host's: deepseek, jev.
- A missing, empty, unknown or repeated
kind, or a query string that does not parse:400 {"error":"invalid","field":"kind"}. - A kind the configuration has no model of:
409 {"error":"unknown_kind","kind":"<kind>"}. - Without
--provider-keys-file:409 {"error":"no_key_file"}.
--provider-keys-file PATH names it: in the memory cloud
/var/lib/mnemon-aml/<n>/provider-keys.json, in aml-serve's StateDirectory, beside the data root
(…/data) and never in it, so that a snapshot of the data root never holds a key. aml-serve
refuses a path inside its data root. Its format:
{"version":1,"keys":{"deepseek":"<key>","jev":"<key>"},"updatedAt":"<RFC 3339>"}Either key may be absent. aml-serve writes it atomically (a temporary file in the same directory, fsync, rename, then the directory's fsync), mode 0600, as the service user. A missing file means the host's keys only. A start is refused, with an error that names the file and never its content, when the file:
- is not a regular file (a directory, a symbolic link);
- may be read or written by its group or others (POSIX; not checked on Windows, R89);
- is not owned by the process's user (POSIX);
- is larger than 64 KiB, or malformed: not JSON, another
version, another field, nokeys, a key that is not 8 to 512 printable ASCII characters without spaces, anupdatedAtthat is not an RFC 3339 time; - names a kind other than
deepseekandjev, or one the configuration has no model of.
A write that a crash cut short leaves a temporary file beside it (.provider-keys.json.tmp-…),
never a partial key file; the next start of aml-serve, the file's one writer, removes it and logs
removed what an interrupted write of the provider key file left: ….
aml-inspect --provider-keys-file PATH reads the same file, never writes it: at its start, by
the same rules, and again before each full search, so a key the console replaces reaches the
console's full searches without a restart. A read that fails then keeps the keys it has, and is
logged once (a WARN line naming the file) until a read succeeds again. Lexical searches need no
key.
The instance's budget caps at run time (rulings R99 and R100): the console's 设置 → 预算上限
sends them here (through its PUT /api/instances/:id/budgets). A cap set here replaces the
configuration's (budgets of memory-config.json) for its kind, at once and across restarts.
{"deepseek": 800, "jev": null}- The body is a JSON object of at most 4 KiB (else
413 {"error":"too_large"}); not an object, empty, or followed by more than white space:400 {"error":"invalid","field":"body"}. - At least one of
deepseek(CNY) andjev(USD), each a number above 0 and at most 100,000, ornullfor unlimited; any other value:400 {"error":"invalid","field":"<kind>"}. - Another field:
400 {"error":"invalid","field":"<name>"}(a name that is not a plain one isbody, as for the provider keys). - A kind the configuration has no model of:
409 {"error":"unknown_kind","kind":"<kind>"}. - Without
--settings-file:409 {"error":"no_settings_file"}.
The caps given are merged into the settings file (the other kind keeps its own), the file is
written atomically, and the budget enforces them for every reservation made from then on. A
reservation already held keeps its own amount and settles as it would have. A cap below what is
already spent is taken: the kind's paid calls then stop before any request, as at a cap reached
(model spending cap reached), and the reply says "over": true for it. An unlimited cap
refuses no reservation for a cap, a Background one included; its spend is still metered: every
call still reserves and settles, and spent and held are counted in the budget file. One INFO
line is logged, naming each kind given with its old and new caps: budget caps set: deepseek 600→800 CNY, jev 60→unlimited USD.
The reply is 200, every kind whose cap can change (each kind the configuration has a model of),
with spent and held as the budget file had them just before:
{"budgets": {"deepseek": {"cap": 800, "currency": "CNY", "spent": 0.0031, "held": 0, "source": "settings", "over": false, "unlimited": false},
"jev": {"cap": null, "currency": "USD", "spent": 0.0001, "held": 0, "source": "settings", "over": false, "unlimited": true}}}source is settings for a cap of the settings file, else config. over is spent > cap,
always false while the cap is unlimited. A budget file that does not answer, or a write that
fails, changes nothing and answers 500 {"error":"internal"}.
Puts the kind, or every kind (all), back on the configuration's cap: it leaves the settings
file, which is removed once it holds no cap. The reply is the one above. One INFO line:
budget caps reverted to the configuration's: deepseek 800→600 CNY. A missing, empty, unknown
or repeated kind, or a query string that does not parse, is
400 {"error":"invalid","field":"kind"}; a kind the configuration has no model of 409 unknown_kind; without --settings-file, 409 no_settings_file.
Sets the role reading: how much of each role's messages consolidation reads, written to the settings file and read by the consolidation jobs that start from then on. The records they read from then on are read by it; records already pending keep how they were read, and nothing consolidated is read again. The body is a complete role reading, at most 4 KiB:
{"default":"full","excerpt":240,"roles":{"user":"full","assistant":"excerpt","tool":"ignore"}}defaultisfull(the whole message),excerpt(the firstexcerptUTF-16 units of its text, therole:prefix not counted) orignore(not read), for every rolerolesdoes not name.excerptis 40 to 2,000, 240 when absent.rolesmaps a role to its mode. Roles compare without case and are trimmed. A role is 1–64 bytes of single-line UTF-8, and two that differ only in case are refused.
The reply, like /admin/status's read, is the reading read by (policy, complete), its
source (settings) and the configuration's (config), which DELETE goes back to. One INFO
line: role reading set: {…}. A reading that is not one is 400 {"error":"invalid","field":…},
the field default, excerpt, roles (a role name that is not one), roles.<role> (its mode, or
a second spelling of it) or body; a larger body 413 too_large; without --settings-file, 409 no_settings_file; without timelines or layered consolidation, 409 no_consolidation. A
refusal changes nothing. Reading only changes what the consolidating model reads: every message
is stored and searched whole.
Goes back to the configuration's role reading (consolidation.read, or the default: users whole,
the first 240 units of each assistant message, other roles whole). The reading leaves the settings
file, which is removed once it holds no cap either. The reply is the one above, source config.
One INFO line: role reading reverted to the configuration's: {…}.
--settings-file PATH names it: in the memory cloud /var/lib/mnemon-aml/<n>/settings.json,
beside the key file and outside the data root (aml-serve refuses a path inside it). Its format:
{"version":1,"budgets":{"deepseek":800,"jev":null},"updatedAt":"<RFC 3339>"}and, while it holds a role reading, version 2 with the reading in read:
{"version":2,"budgets":{"deepseek":800},"read":{"default":"full","excerpt":240,"roles":{}},"updatedAt":"<RFC 3339>"}A file without a role reading stays version 1, so a binary that predates the role reading still
starts once the reading is reverted. Either kind may be absent, and null is unlimited. A missing file means the configuration's
caps. It is written as the key file is (atomically, mode 0600, the service user's), and a start
is refused by the key file's rules: a file that is not a regular one, that its group or others
may read or write, that another user owns, that is larger than 64 KiB or malformed (another
version or field, no budgets, a cap that is neither above 0 and at most 100,000 nor null, an
updatedAt that is not an RFC 3339 time), or that names a kind other than deepseek and jev, or
one the configuration has no model of. The error names the file. Caps are no secret: they are
answered and logged in full. The amounts spent and held stay in the budget file whatever the cap;
mnemon_aml_budget_cap{account} is +Inf for an unlimited one.
Go's net/http/pprof: the index and the named profiles under it (heap, goroutine, …),
/debug/pprof/cmdline, /debug/pprof/profile, /debug/pprof/symbol and
/debug/pprof/trace. A profile reads the process's memory and stacks, which is why this listener
is not public. Its write timeout (5 minutes) outlasts a profile of up to 4 minutes.
mnemon memory-agent aml-watch reads /ready and /admin/status on this listener, and the
public /health, once per run (see the runbook for its rules).
--admin-token-env NAMEnames the variable that holds the admin token (the same token as aml-serve's). The watcher reads it once, at the start, and the run is refused, naming the variable, if it is unset or empty. It sendsAuthorization: Bearer <token>on every admin read, and only there: the public/healthprobe carries no token, and an admin read is not redirected, since theLocationof an answer is not the operator's. Without the flag the watcher sends none. The token is never printed, logged or put in an error, a message or the state; a server that refuses it is an alert about readiness (/ready answered 401).--instance NAMEnames the instance the watcher serves, so that instances on one host can be told apart. The name is 2 to 31 characters: lowercase letters, digits and hyphens, starting with a letter (^[a-z][a-z0-9-]{1,30}$); the run is refused otherwise. With it, the header of every message reads[mnemon-aml] ALERT <alert> on <host>/<name>, andRESOLVEDlikewise, where it readson <host>without it.