Skip to content

Latest commit

 

History

History
644 lines (537 loc) · 44.2 KB

File metadata and controls

644 lines (537 loc) · 44.2 KB

Architecture

How this repository is put together, and which seams you may not cross.

44 operations are declared in src/spec/operations/, the official Bot API has its own half (§18), and the diagram below shows only the first commands; max store and max runs answer from this machine. Both runtimes pass. Everything here was verified against the real service unless it says otherwise. Round trip re-verified live 2026-09-20: max chats list --limit 3 --verbose --record made three requests, stdout was one JSON value, stderr only the event lines.

"Brief §N" is docs_ai/REQUIREMENTS.md; rulings (NEED-nn) are in docs_ai/DECISIONS.md. Long detail lives in companions under architecture/: session.md (§7), messages.md (§10), store.md (§15, §16).

CLI design references

MAX and Telegram share one CLI standard and adoption profile. Its external references are POSIX utility conventions, GNU command-line conventions and Command Line Interface Guidelines. They inform option syntax, help, composition and interface stability; the project's resource names and statistics hierarchy are our own decisions. This is an adoption profile, not a claim of complete external conformance.

For agents, the shared standard also assesses the MCP tools specification, the Agent Skills format and additional tool-design guidance. The shell and renderer own the machine contract, services own operations, and guards enforce permissions for CLI and MCP alike. Retrieved messages are data, never authority to act.

The public compliance audit and work queue distinguishes source checks, isolated observations, deliberate differences and remaining gaps. Shared fixes reach this CLI through an exact dependency adoption; documenting a convention does not change the installed binary or assert that every command already follows it.

Agent evaluation evidence

the public admin/statistics evaluation separates independent model contexts, synthetic CLI/MCP traces, deterministic fixture checks and live-provider claims. Its six contexts and 38 assessed outcomes are a bounded observation, not broad agent conformance; original model identity was not captured and MCP used a shell proxy. Read the report's subject versions and first-failure record before comparing results with another release. Reproduction tooling belongs to cli-messaging; the user CLI contract explains what that evidence means for a statistics answer.


1. One published package, four layers, one direction

     src/commands/     session · account · chats · contacts · messages · store · runs
            │          speaks the domain model, owns no protocol knowledge
            ▼
     src/client.ts     MaxClient — the only thing above here that knows MAX exists
            │          client.chats.list() · client.messages.send() · client.account.me()
    ┌───────┼────────────────┬──────────────────┐
    ▼       ▼                ▼                  ▼
 src/domain/  src/spec/          src/generated/    src/protocol/
 Chat         one operation,     opcode registry   frame codec
 Message      declared once      operation table   connection
 Contact      Valibot schemas    wire wrappers     seq correlation
 mapping      provenance         never hand-edited
                                         ▲
                        src/session/   handshake (INIT → LOGIN) · keyring · state
                        src/record.ts  account-scoped login data in the shared store
                        src/wire-events.ts  which ids and counts a MAX event carries
                        (the run directory and the event format: cli-messaging's, T6 item 3c)

the diagram above is the personal account's own half. Since #260 and T6 (#330–#382) max also plugs into @wirecat/cli-messaging: src/messenger.ts describes MAX once as a Messenger (maxMessenger — provider, paging, the guard, permissions), and src/adapter/max-adapter.ts (maxAdapter) wraps MaxClient behind cli-messaging's MessengerAdapter port, translating MAX's models into the shared domain types. src/program.ts registers cli-messaging's shared commands — store, conversations, polls, reactions, inbox, review, and chats mark-read among chats — beside max's own; they reach MAX only through that adapter, and read and write the shared store (~/.local/share/cli-messaging/messages.db), not a cache of max's own. So the path is now commands → cli-messaging services → maxAdapter → MaxClient → protocol; MaxClient stays the only code that knows the wire. src/adapter/contract.test.ts runs cli-messaging's contract cases over the adapter.

  • Commands are resource + action (NEED-48); the diagram matches max --help. Adding an operation: §12.
  • @wirecat/cli-core supplies output streams, renderer, error model and exit codes, keyring, config and clocks — the non-MAX half, shared with braze-cli.
  • One npm package (@wirecat/max-cli, command max), split by directory, not workspace (NEED-12). src/commands/ may not import from src/protocol/, src/spec/ or src/generated/: a Biome rule fails the build with that sentence. Verified by writing the forbidden import, both directions.
  • A second rule marks what any messenger CLI could share (CLI-30, NEED-147; was CLI-26, a number taken three times): src/domain/models.ts, src/rendering/ and src/resolve.ts may not import the protocol, the specification, the generated wrappers, the session, the client or the commands. The files stay where they are; a future cli-messenger package is a move of exactly these, not an untangling. src/domain/map.ts is outside it on purpose — it is where MAX becomes the model. Verified by a forbidden import in each of the four places.
  • The newer directories have rules too (2026-10-01): src/adapter/ keeps off the wire, the commands, MCP, max serve, the shared store and the bot; src/session/ off the commands, MCP, the shared store, the adapter and the bot; src/server/ off the commands, MCP, the adapter, program.ts and the bot; src/messenger.ts off the wire, MCP, max serve and the bot; src/bot/ off the personal account's protocol, session, spec, server, store and client. Test files are exempt in the first three, since they wire the real pieces together. Not ruled, because code already does it: messenger.ts → commands/context.js, session/adopt.ts → server/start.js and client.js. Verified by a forbidden import in each place.

2. What each layer may know

Layer Knows about Must never
commands/ Chat, Message, Contact, the renderer, exit codes opcodes, frames, ws, MAX field names
client.ts opcodes, the session, the domain model rendering, process.stdout, argv
domain/ MAX's wire shapes, and only to translate them out the network, the session
spec/ every operation, its shape and where it came from the socket, the session, output
generated/ what follows from spec/ being edited by hand
protocol/ frames, seq, the socket who is asking or why — including the handshake
session/ the keyring, the state file, INIT → LOGIN anything it can ask cli-core for
runs/ what a request cost, and where a record of it goes what a request said — §13

Why: if MAX's transport changes, only protocol/ and client.ts change. That is why we own the adapter instead of depending on @bruch/max-client (NEED-17).

3. The WebSocket is binary, as web.max.ru's is, and ids are the reason to be careful

this section described text frames {ver: 11, …} on wss://ws-api.oneme.ru, and said MessagePack and LZ4 were a TCP-only transport. The web client's live socket showed otherwise, and we now send what it sends. The capture is src/testing/fixtures/web-capture-2026-09-25.json — proof, not somebody's README.

wss://api.oneme.ru/websocket, Origin: https://web.max.ru. Every frame is a 10-byte big-endian header — ver (10), cmd (0 request, 1 response, 2 event, 3 error), seq u16, opcode u16, then flags << 24 | length — and a MessagePack body:

  • LZ4 block compression both ways from 32 bytes of body, flags = ⌈raw / compressed⌉, kept even when it comes out larger; zero-length body for "no payload". The web client does exactly this on every one of 73 recorded frames. zstd (flags = 0xFF) was never seen and is refused.
  • Extension type 1 wraps a 64-bit number — ids and times from MAX, ids from the web client. We wrap every bigint we send (toWireId makes one of every id) and unwrap what arrives.
  • seq is two bytes and starts at 0. It wraps; the max serve push filter compares modulo 65536, because a plain <= drops every push after the wrap without a word.

⚠ Integers. Message ids have 18 digits (measured), past Number.MAX_SAFE_INTEGER, and times are 13 — both arrive as 64-bit. The decoder returns a number whenever it is exact and a bigint only when it is not; a bigint timestamp would break every Date above this layer. Every id leaves the domain mapper as a string — an id is never arithmetic. Chat ids reach 14 digits and contact ids 9 (pnpm probe:ids, 2026-09-19).

⚠ The mock speaks our own codec, so the suite alone only proves the codec agrees with itself. What ties it to MAX: LZ4 checked against the reference C library both ways, the compression rule checked against every recorded frame, and src/protocol/parity.test.ts — our INIT, LOGIN and history requests field by field, in order, beside the web client's.

seq is the only link from answer to question: MAX interleaves pushed events with responses on one socket, so "the next frame is my answer" eventually reads somebody's incoming message.

4. One command, one connection, and it closes

  • connect → do the thing → print → close, the close in a finally. Connection owns the socket, per-request timers and listeners; close() clears all three. ⚠ An open WebSocket or live timer keeps Node alive, so max chats list --json | … would never return: print-then-hang is a defect.
  • INIT (6) then LOGIN (19) come first; MAX answers nothing before them. The handshake is handwritten in src/session/handshake.ts and stays so (brief §9); the spec supplies its two payloads, so field names have one home, not two. It gets the client's invoke, not the socket, so both requests are checked and reported like any other (§13).
  • LOGIN returns profile, chats, contacts, recent messages and presence, so account show and chats list need no further request. PROFILE (16) is a profile update and refuses an empty payload — not used for reading. it is sent now, by max account update only (MAX-33), with the current first name always included, as the web client does.
  • interactive: false on login and history (the web client sends true for a watching person). Whether it moves presence or read state is not settled (RES-5).

5. Reading is observational, by construction

CHAT_HISTORY (49) and CHAT_MARK (50) are separate. Nothing that reads sends 50; src/client.test.ts asserts opcode 50 is absent from everything a read sends. It goes out from client.chats.markRead only — max chats mark-read, messages list --mark-read, and the MCP tool behind --allow-mark-read — through the send guard as kind read. Its request is PyMax's shape, not measured (src/spec/operations/chats.ts).

⚠ Until the spec landed (2026-09-19) that assertion compared against a missing Opcode.CHAT_MARK, so it read not.toContain(undefined) and always passed — tsconfig.json excluded test files from type checking (OPS-8). 50 is now a declared opcode with the reason it is never sent.

Not proven: whether LOGIN itself moves presence. Needs a second device watching.

6. A send may be retried, and only because it was measured

Unlike Braze ("never retry a write"): MSG_SEND carries a client-generated cid, and MAX deduplicates by it. Measured 2026-09-19 in Saved messages: the same cid twice gave the same message id and one copy, also across two connections and logins — the case a retry faces.

  • A lost send is retried once, with the same cid. If that fails too, the result is outcome_unknown (neither failed nor sent) and names the cid as sendId; max messages send --send-id <n> repeats it without risking a second copy.
  • Measured in Saved messages — the same cid resent 5 and 15 minutes after the first send got the first message's id back each time, and one copy stayed. Fifteen minutes is the longest gap measured; beyond it is unproven, and --send-id reuse hours later should not be relied on.
  • cid is monotonic per process. Date.now() alone gave two sends in one millisecond the same cid, and deduplication would have silently dropped the second. A test caught it.
  • --silent sends notify: false (normally true). ⚠ Only true is measured; what MAX does with false has never been observed (that means messaging somebody), so it may be ignored. The first use against a real chat settles it.
  • Every send passes a guard first (cli-messaging's sendGuard since T6 item 3b, 2026-09-30 — correction: it was src/sends/guard.ts; src/sends.ts now builds it for a profile and stamps each journal line with the write's operationId; handed to MaxClient by createClient): a read-only profile (code 5), an optional recipient list (7), an hourly limit (8) — all before the socket when the chat is an id. Every outcome, refusals included, goes to <state>/sends/<profile>.jsonl without the text; the limit counts that file. a write that counts first holds a reserved line, written under a lock file (wx, so Windows too), and its outcome settles it; reading folds settled ones away, so two processes at the limit cannot both pass. Over max serve only the server reserves. These stop a model talked into sending by what it read, not an agent that edits the configuration (NEED-159). the operation-id wrapper retains one prepared request object across ask and check, because the shared guard binds confirmation to that object. The client-side server wrapper forwards ask too; a matching operation id on a different request grants no confirmation. MAX now uses canonical permission levels; legacy configuration is translated at read time until config migrate writes the canonical file. CLI, native reads, MCP tools and resources, and raw server requests enforce the same resource policy. Confirmed writes carry explicit permission keys to the server, which rechecks the current configuration; neither confirmation nor moderation consent bypasses a denied or read-only action.
  • max serve runs the same guard on every write it forwards, and journals it (NEED-269): anything of the owner's can write to its socket, not only a command that checked first. Every request is first checked against the operation's strict schema, as buildRequest checks it in a command, so a field the specification does not have is refused there too. It reads the configuration again for each write, so config set readOnly true needs no restart. The command still checks — a refusal before an upload — and journals only its own refusals; the server writes the rest, with the outcome it saw — one line per attempt. A retry after no answer repeats the cid in the same chat and leaves outcome_unknown then its outcome; the limit counts the two once. The first line is not held back for the retry, so a crash cannot lose a message that may have gone out. A cid that was sent, or reused in another chat, counts as a new send.
  • A forward is a send — MSG_SEND with a FORWARD link and no text — so it gets the same one retry with the same cid and counts against the hourly limit. An edit and a pin are not retried, like a reaction. An edit and a pin that notifies count against the hourly limit, and so does each person added to a group; a scheduled message counts in the hour it goes out. The guard's other two checks apply to all of them.
  • A deletion is guarded like a send, and each deleted message counts toward the hourly limit (MAX-47): it wakes nobody, but many at once is what MAX bans for. At most 10 per call, and --allow-dangerous on every one. Not retried. max serve logs in again after one, since MAX does not push a connection's own deletion back to it and the chat's last message may be gone.
  • An edit sends the message's attachments back as history gives them. Measured 2026-09-24: an edit with attachments: [] removes a photo. So messages edit reads the message first, and refuses somebody else's message or a forward before asking MAX.

7. The session is a token and a stable identity

What Where Why
token OS keyring, service max-cli, account = profile it is the credential
deviceId, viewerId, login count <state dir>/profiles/<name>.json, mode 0600 identifying, and it must be stable
  • The device identity is saved the first time it is read, before any use. Otherwise a failed login or a second call shows MAX a new device — what brief §34 forbids. Found by a test.
  • MAX_TOKEN is read before the keyring (CI, probes). max session start takes a token four ways (token, qr, qr-chrome, sms); every one ends in adoptToken. qr-chrome and sms let web.max.ru log in inside a throwaway Chromium profile and read __oneme_auth over the DevTools protocol (src/session/browser.ts); qr uses our socket (src/session/login.ts) and draws the code in the terminal. SMS over our socket meets a captcha (measured 2026-09-24), so it has no socket path.

In architecture/session.md:

  • Token exchange (MAX-11, measured 2026-09-22): LOGIN returns a new token once the old one has aged, then the same one. Stored after the account check; a keyring failure does not fail the command; never printed.
  • Login first, profile bound to one account (MAX-12, measured 2026-09-21): a token reaches the keyring only after MAX accepts it (src/session/adopt.ts); a profile refuses another account's token.
  • The login asks for a delta (MAX-10, NEED-103, measured 2026-09-20, pnpm probe:contacts): the four *Sync fields are timestamps; we send the stored marker, written in one transaction with its rows. max contacts sync forgets it.
  • Chat members are named: every group and dialog participant via CONTACT_INFO, batched at 100; channels skipped. 23 people in chats against 6 contacts (2026-09-20).

8. We look like the official client

Nothing on the wire names this tool (brief §34): WEB_USER_AGENT in src/spec/identity.ts is the web client's shape, Origin is https://web.max.ru, the device identity is stable per profile. We use ws, not Node's built-in WebSocket: measured on both runtimes, ws sends custom headers identically; the built-in's support depends on the bundled undici version.

9. A name is never resolved by guessing

  • max messages list "Ivan" matches chat titles as a fragment; an exact title does not win while another title contains it too. An ambiguous name is an error listing the candidates — a send to the wrong chat does not undo. Verified live (a full contact name matched two chats; the command stopped). Candidates print one per line with ids, and in the machine error as candidates: [{ id, title }], so an agent picks one without parsing a sentence.
  • A dialog has no title, so the partner's name comes from contacts: one CONTACT_INFO (32) for all unknown partners. Measured: 6 contacts in the login against 17 dialog partners; named dialogs went from 0 of 17 to 16 of 17.
  • The same lookup names group senders (FIND-45). The login names contacts only, so on 2026-09-22 every sender in a real group but the owner was a bare id. After a history read, unnamed ids are looked up in the store, then in one CONTACT_INFO, and kept; the next read of that chat asks nothing. A refused lookup leaves the ids, notes it on stderr, and still answers.
  • ⚠ Opcode 36 is CONTACT_LIST in tsmax and PyMax but GET_BLOCKED in the protocol documentation. Not used — not a difference to discover in production.

10. Output: stdout is data, stderr is everything else

--json, or any non-terminal stdout: one JSON value on stdout and nothing else — no spinner, ✓, warning or ANSI. Diagnostics go to stderr in every mode, so the contract holds by construction. Verified live: stdout one JSON value, stderr empty. Failures too: run() returns an exit code (never throws), the error goes to stderr (JSON when piped, a sentence in a terminal), codes from cli-core (4 authentication, 130 interrupt). Scripts branch on the code, never on text.

A listing answers one object, always the same one

{ "items": [ … ], "page": 1, "limit": 20, "hasMore": true }
  • chats list, contacts list, messages list, every machine mode — also --all (page: 1, hasMore: false) and --offline. The shape never says where rows came from; exit code and diagnostics do.
  • hasMore is a boolean, not a total (counting rows MAX has not sent is another cost). Exact for chats and contacts; a chat list MAX cut (#chatsCut) says hasMore: true on its last nonempty page. ⚠ For messages list it is a claim about our copy: history comes in windows, and MAX's answer says nothing of what is older. A page can come back short mid-chat, so a short page back is checked with one request for a single older message (#olderThan in src/client.ts).
  • In a terminal, the "more pages" line goes to stderr, from src/commands/paging.ts, not renderer.result: account show and session start|end are not listings and answer a bare object.

User-facing version: ../usage.md. Messages print as a feed, not a table; a message id holds its send time (id >> 16, measured 2026-09-22); a photo link opens without a token (NEED-120); a reply carries the other message whole — architecture/messages.md.

11. Trade-off order

When two conflict, the earlier wins:

correctness > not touching someone's account by accident > auditability
> agent determinism > human UX > protocol coverage > implementation cleverness

The second ranks high because this is a real personal account: an unwanted send, a read receipt or a message deleted by a retry happened to a person.

12. One operation, declared once

Every opcode and payload shape lives in src/spec/; its Valibot schemas are the definition (NEED-7). src/generated/ follows from it and is never edited by hand.

Generated, committed, checked in CI Handwritten, and staying that way
src/generated/opcodes.generated.ts — the registry the socket, seq correlation, timeouts
src/generated/operations.generated.ts — the table the INIT → LOGIN handshake
src/generated/client.generated.ts — wire wrappers the schemas themselves — they are the spec
protocol.md — the reference page the domain mapping, and error classification

Adding one

  1. In src/spec/operations/<subject>.ts, defineOperation: dotted name, MAX's constant, the opcode, a strict request shape, a loose response shape, where the shape came from, and its guard — null when nobody else sees it, otherwise what the send guard is asked, read from the request as it goes on the wire. The field is required: max serve passes a write on only through that declaration (NEED-269), and a request it cannot read — a send with no chat that is not a new group, a chat update that is two changes — is refused, never guessed.
  2. pnpm generate. A duplicate opcode or name, or a name not <group>.<method>, stops it with a sentence naming both sides.
  3. Call it from MaxClient. The generated wrapper is typed and stays internal.

A known number we will not send gets reserveOpcode, with the reason: it is in the registry but never gets a method, so "do not call it because it is in the enum" is enforced by code.

Two directions, two schemas

  • Requests are strict: an unknown field is refused before the socket.
  • Responses are loose: new fields pass through, fields we do not use are optional, nothing in a response fails a command (brief §29, NEED-35). Each answer is still checked against the spec; a mismatch is one line on stderr, so a renamed field does not silently blank a column.
  • ⚠ That note is built by hand from the field path and expected type. Valibot's message quotes the value, which may be somebody's message (brief §14, §24). A test asserts no body reaches a note.

An id is a string above the codec and a bigint on the wire

asId reads; id() in src/spec/scalars.ts writes. It replaced Number(chatId), which silently rounds past 15 digits (Number("7268926000000000001") is a different chat). Ids below 2⁵³ give the same bytes as before. It never mattered yet: measured 2026-09-19, nothing the login returned was past 2⁵³ (longest chat id 14 digits). Hardening, so nobody re-asks.

Staleness

pnpm generate writes and formats in one step; CI regenerates and asserts no diff (OPS-3). The banner has no date or version — anything that moves on its own makes that check fail forever.

13. One event per request, and the two places it can go

Each request is one object: direction, operation, opcode, seq, ids named, duration, bytes, how many things came back. Two sinks: --trace renders it on stderr live; --record writes it to a file. Either, both, or (default) neither. What it looks like and how to use it: ../diagnostics.md.

→ session.login     op 19  seq 2  871 B
← session.login     op 19  seq 2  213ms  48.0 kB  25 chats  6 contacts
  • Never in an event: a chat title, a person's name, a message body, a phone number, a token — not truncated, not hashed (brief §14, §24). Ids are in: opaque, only the session holder can correlate them, and every real complaint is about one conversation.
  • ⚠ This holds because idsOf in src/wire-events.ts (correction 2026-09-30: it was src/runs/events.ts, and it names the send identity send, not cid) builds the event from named fields instead of filtering the payload (a filter lets an unseen field through). No branch reaches session.login's token. Pino's redaction is the second line of defence.
  • MaxClient takes an injected event sink, like warn — it reports, never decides where. The hook is in #send (builds, times, checks).
  • ⚠ startSession gets the client's invoke, not the socket (since 2026-09-20). Before, it called connection.invoke, so INIT and LOGIN bypassed the hook, and chats list (nothing sent beyond LOGIN) recorded an empty run. It also made LOGIN's answer checked against the spec, which found messages is an object, not an array (PROTO-6).
  • A request refused before the socket (bad id, unknown field) emits no event; the run still records outcome and code.
  • A local answer gets its own event shape: no opcode, seq or bytes ("0 bytes" would be fiction), plus a reason: offline (caller said never connect) or history (window older than a fetch returns, so the record is authoritative). "Did not ask" and "nothing to ask" differ; only the second is the local copy doing its job.

The run directory

local commands discovery and runs read commands mount the shared cli-messaging factories; the MAX modules contain only the app binding. Discovery includes the common contract field, and truncated run output points to --limit. Reading these records starts no session and creates no new run.

<state dir>/runs/<UTC day>/<timestamp>-<command>-<suffix>/ with run.json and events.jsonl. ../diagnostics.md has the layout, modes (0700/0600), the twice-written run.json (running, then the outcome) and 30-day retention, pruned only when a recorded run starts, whole days by directory name. Rules not stated there:

  • Off unless asked (NEED-49, NEED-52): --record, --no-record, else the config file. a run that fails is kept anyway — recorded() holds the newest 500 events in memory and opens the directory only on a failure, with keptBecauseFailed: true. Recording turned off by name (--no-record, "record": false) keeps nothing (keepFailedRuns in src/config.ts). every failure, not only one inside run(). The last catch src/program.ts delegates parsing and fallback recording to cli-messaging's shared runner. Its consumer hooks supply legacy command context, server options and bot recording settlement; the shell uses MAX's resolver with the bot/personal scope. Anything no run settled (wasSettled) reaches the same recorded(): Commander's usage errors, checks before a command opens its run, and commands that never open one. The run is named by the command's words only (commandPath), never the arguments.
  • Beyond requests: warning events carry a code from a closed list (WarningCode), never the sentence; a crash adds a crash event with the class and up to ten function file:line frames, never the message; providerError is MAX's refusal key when it is shaped like one (providerErrorKey, cli-messaging's — correction 2026-09-30: it was maxError, src/runs/events.ts). run.json carries runtime, platform, arch.
  • run.json is written atomically. finish runs on every path and awaits the logger: Pino appends via a plain stream, and a process that exits first loses the tail — the part somebody wanted.
  • The day directory is UTC: a run at 01:35 in Madrid lands under the previous day.
  • Files, not the SQLite cache (NEED-51): opposite lifecycles, and two max runs can overlap. Two appends never conflict; two DB writers take a lock, and a diagnostic must not fail its command.
  • max runs list | show <id> | path <id> read them back. An empty list explains itself on stderr and names --record. runs path answers {"path": "…"} too (NEED-88, max runs path <id> --json | jq -r .path; a terminal gets the path on a labelled line): a contract with no exception is one nobody has to remember, and was worth more than cat "$(max runs path <id>)/events.jsonl".

14. Settings, and the order one is decided in

common settings resolve through cli-messaging settingsFor. MAX retains its strict configuration schema and config-write diagnostics; an adapter hook resolves serve, mcpTools and the default-only speech model from the same layers. Detailed source paths, legacy permission provenance and the existing duration syntax remain unchanged.

Flag → environment → config file → built-in default, decided once in resolveSettings (src/config.ts). Commands take what they are given; one that re-derived the order would disagree. Every field, variable and the file format: ../configuration.md; profiles and paging for users: ../usage.md. The profile follows the same order: the first word (max personal chats list), then MAX_PROFILE=personal, then "defaultProfile" in the file, then default.

The profile is the first word, not a flag

  • --profile was deleted (NEED-45). The first word is the profile unless it names a command (liftProfile in src/profile.ts). It must come first: max --json personal chats reads personal as a command and fails.
  • ⚠ A profile named after a command could never be selected (max chats must mean the command), so max session start refuses the name (refuseCommandName) — creation is the only moment to explain it. Names become file names and keyring accounts: letters, digits, ., -, _ only.
  • An unknown first word gets authentication_error naming that profile and the fix (max x session start, not max session start, which would log in the wrong profile). max chat list is the everyday case; the message says which word was read as what.

The file and paging

What a user sets and how: ../configuration.md, ../usage.md. The rules behind it:

  • ~/.config/max-cli/config.json, mode 0644, read by cli-core's loadConfigFile. A missing file is not an error; a malformed one is, in every output mode, naming the field.
  • The schema is strictObject: "limitt" is reported as profiles.default.limitt, where plain object() would drop it silently. The opposite of MAX answers, whose unknown fields are kept (NEED-35).
  • No field can hold a secret (token, phone, chat id). Having nowhere to put one beats a rule.
  • timeoutMs unset is the transport's 30 s (src/protocol/connection.ts); color unset means decide from the terminal.
  • --limit, --page (1-based), --all on every listing, resolved once in resolveSettings; --page with --all is a validation_error. Neither --page nor --all has a config field — a page number in a file is nobody's setting. Paging is SQL LIMIT ? OFFSET ? over the store.
  • ⚠ A page number over a live list can repeat or skip a row (newest first; a new message shifts the boundary). Documented in --help, not engineered away.
  • messages list pages with --before instead, anchored in time (chats.history takes from), so it is exact. It takes a message id, or an ISO 8601 time when the id is gone.
  • ⚠ A bare integer is always an id. Ids have 18 digits, ms timestamps 13; telling them apart by size breaks when either changes.
  • contacts list --order recent|name has no config field: a second spelling of a flag is how --profile came to be deleted.

Two things that jump the queue

  • MAX_TOKEN outranks the keyring (cli-core's Credentials.read) — a token for a container or probe without storing it.
  • ⚠ MAX_CONFIG_DIR, MAX_STATE_DIR and MAX_CACHE_DIR change which keyring entry a profile means. pathsAreOverridden makes the service max-cli:<configDir>, so a login made with them is invisible without them ("no session" for a profile that exists). Same environment for both, or neither. This cost an afternoon (UX-1).

Diagnostics all leave by one door

--quiet silences diagnostics only; a failure still prints, since an exit code says what kind of thing failed but not which chat (src/output.ts). A MaxClient is built in exactly one place (src/commands/context.ts), which hands it the renderer's note. Before, the protocol note went straight to stderr, so --quiet missed it and --json did not shape it (BUG-7). Six commands each remembering to pass a warn is a rule that breaks once and then stays invisible; one construction site cannot.

15. The store: people, and the chats they are in

max no longer opens its per-profile cache. src/record.ts keeps login chats, people, membership snapshots and the contact sync marker in cli-messaging's account-scoped messages.db. It opens lazily once the account is known. The shared stored adapter wrapper saves history; shared services own search, offline reads, edits and deletions. A failed record write warns without losing an answer MAX already gave, and does not advance its marker. max serve owns its record across reconnects and closes it on stop.

The native server also runs the shared daily member-fetch scheduler for explicitly tracked groups. commands/serve.ts injects its worker, keeping the server independent of the adapter. It waits one minute after login, uses that server's held connection, and skips rosters already saved today (UTC), including partial snapshots. Reconnects share one worker; shutdown drains its current fetch and closes its store. Tracking neither starts a server nor extends an auto-started server's idle lifetime. Run max server continuously for daily history.

max contacts sync forgets the contact marker before a full login. Only a complete nonempty chat snapshot marks departures; an empty answer keeps the previous list. max cache is removed; store clear --left replaces its departed-chat cleanup. doctor reports an existing legacy file without opening it. The old data is not migrated.

Details: architecture/store.md.

16. Searching

shared services search the account's messages.db; MaxClient no longer implements local search. Search finds recorded history, not unread history that has never been fetched. store fetch fills it; --offline reads it without connecting.

17. The MCP server is a second adapter, and it keeps the connection for minutes

max mcp (src/mcp/) mounts personalMcpTools(maxMessenger) from the shared SDK through registerPersonalMcpTools. MAX retains MaxSession, native permission guards and account-scoped archive services. Unsupported forum tools are filtered out; photo previews and cached direct transcripts retain native callbacks under the shared input schemas. Legacy chats_check and chats_rules names remain available.

  • One connection per agent session, never for long: calls serialize; the connection closes after 2 minutes idle, after 5 minutes since login, on a connection failure and on stdin EOF. Local evidence and conversation callbacks use the profile's known account without connecting.
  • Profile permissions govern discovery and execution. The mounting scope rechecks the exact key, including local reads. Native write guards retain recipient checks and the journal. Retired access flags grant no permissions.
  • MCP writes use effective profile permissions. ask and allow execute without a server confirmation form; readonly rejects writes and deny rejects access. Retired confirmation flags emit a diagnostic and change nothing. Native recipient and send-journal guards still apply.
  • Local inference releases the connection. Warm embedding models are disposed on shutdown; speech models are never downloaded by a tool. Native transcription retains same-model cache reuse.
  • Discovery uses search, read and write tools. max_tools_search finds command definitions; max_read and max_write execute a command path with its argument object. Exact command permissions are rechecked during execution; a client permitting max_write does not bypass them.
  • stdout is the protocol. Nothing in src/mcp/ writes data through the renderer; notes go to stderr. The process exits on stdin EOF — measured 110 ms under Node and Bun, 2026-09-24.
  • serveStdio may build a probe server before settling on the protocol era, so the server is a factory over one MaxSession, never one instance.

User side: ../mcp.md. The research behind the choices is local-only (docs_ai/plans/2026-09-23-mcp-research.md).

18. The bot API is a separate slice, generated from the official schema

max … is the personal account over the unofficial protocol; max bot … (CLI-25) is a bot over MAX's official HTTP Bot API. The two share no transport, no session and no generated code. Nothing under src/bot/ imports src/protocol/, src/session/ or src/generated/, and the other way round.

spec/bot/schema.yaml        the official OpenAPI document, committed with provenance.json
   │  pnpm bot:spec:sync    — the only step that reads the network, and only when run by hand
   ▼
scripts/bot/openapi-adapter.ts   OpenAPI → cli-core's format-neutral ApiModel; strict, names what it refuses
scripts/bot/overrides.ts         read / write / destructive, where the HTTP method is wrong
   │  pnpm bot:generate     — part of `pnpm generate`, so CI's freshness check covers it
   ▼
src/bot/generated/{types,schemas,manifest}.ts   docs/dev/bot-api-coverage.md

The generator itself is @wirecat/cli-core/codegen, shared with other CLIs; only the adapter from OpenAPI is ours. The generated Valibot schemas keep the schema's constraints and turn a 64-bit id into an exact decimal string. They expect numbers from lossless-json, and they decode only — a request is validated with them and then sent as the original lossless value. Enums and discriminated unions are strict, so an update type newer than the snapshot fails validation.

Live evidence 2026-10-03: addMembers remains in the current official OpenAPI0.0.33 and succeeded against the test group with a bot holding member-management rights. The participant was absent before the request, present in personal-account read-back afterwards, and the original membership and role were restored. The provider website announces removal on 2026-09-30; that notice conflicts with the observed backend behavior. Keep the generated and friendly commands; do not generalize one successful group check into availability for every bot or chat.

At run time: src/commands/bot.ts → src/bot/client.ts (the only door to the generated code) → src/bot/transport.ts. The transport sends the token as the bare Authorization header, parses with lossless-json, repeats only reads (429, 502, 503, no answer; Retry-After first), and turns a write that got no answer into outcome_unknown. It trusts the Минцифры root that signs platform-api2.max.ru inside this process only: Node's process-wide default CA list (22.19+), or per request on Bun (NEED-293). The bot token is bot:<profile> in the keyring (src/bot/auth.ts). A Biome rule keeps src/bot/ away from src/protocol/, src/session/, src/spec/ and src/client.ts.

Рейтинги архива и evidence реализованы в общих services/store cli-messaging; adapter сохраняет providerMetadata.graph v1 с доказанной reply-связью или известным отсутствием reply у полного сообщения. Неполный кадр оставляет связь неизвестной. Пользовательский контракт описывает формулы, пределы и качество данных.

Administrator statistics

The shared stats command mounts unanswered, responses, newcomers and discussion reports. Question roots use compiled Lucene selection; explicit reply context extends through a captured cutoff in the same SQLite read snapshot. Membership stays preserve actual join versus first-seen. CLI and the three-tool MCP frontend call the same shared service; report evidence uses a separate versioned selection inside the existing evidence commands. See the user guide.

Retention and counter freshness

cli-messaging migration 24 stores explicit roster batches with member/stay references and independent latest counter observations. Known joining dates define cohorts; firstSeenAt is not substituted. Retention uses the first roster within 24 hours after each checkpoint, disclosing observed/unknown/pending denominators and interval departures. The shared service and existing MCP frontend reuse bounded evidence. MAX's adapter marks authoritative history/context counter reads and refreshes views/reactions through exact messages.around reads. Comments are unsupported. Counter-only refresh updates the local store, preserves message bodies/attachments/tombstones and makes no send or read-mark request. Legacy differing values lose freshness. See user statistics guide.