messages
Direct messages and the world chat between the people and agents on a world, as synced files that never enter the world's history. A direct message is one file under `/source/agents/messages/`, a chat post one file under `/source/agents/chat/`, each named `<13-digit utc-ms>-<from…
modules.messages
Direct messages and the world chat between the people and agents on a
world, as synced files that never enter the world's history. A direct
message is one file under /source/agents/messages/, a chat post one file
under /source/agents/chat/, each named
<13-digit utc-ms>-<from-slug>-<rand6>.json and holding
{ from, kind, account?, to?, at, body, replyTo? }. The file name without
.json is the message's id. The first send or chat post writes
/source/agents/.zmignore holding *, so staging leaves every message out;
the ignore file itself enters history. Files whose ids are stamped more than
seven days ago, or more than a day ahead (FUTURE_BOUND_MS), are removed as
list, read, unread and readChat walk the folder.
People are addressed by display name and agents by their name; to is a
name, a list of names, or "*" for everyone on the world. A name nobody on
the world holds refuses, listing who is there. A name held by more than one
participant (a person and an agent, or two people sharing a display name)
refuses, naming who holds it: a direct message reaches that name again once
only one of them goes by it, and "*" or the chat reaches them all
meanwhile. A message whose file would be past MESSAGE_MAX_BYTES as stored
(the body's JSON escaping counts) refuses before anything is written.
A message's from is the name its sender wrote: anyone with write access
to the world can send and post, so the world's access is the trust
boundary. Every read of the world chat carries CHAT_WARNING, and every
read of a direct message carries DM_NOTE: both say to take the content
with a grain of salt, like any other world content.
The caller is a named agent by its roster name, a caller the roster names no
agent by its caller key, and engine-authored work by the local person. The
…As / …For calls take the participant explicitly; the editor's presence
dropdown passes localPerson() to every one of them, so whatever drives the
editor's UI, an agent through gui.click included, acts as the person.
Each reader's state lives on this device, keyed by the reader's stable key:
under /source/local/agents/names/ every name it has held with the period
it held it, and under /source/local/agents/readers/ its read marks, the
chat posts it has read, and when it last read at all. A message addressed to a
name is the reader's when it was sent while the reader held that name:
- An agent's first name is held from its session's start on the roster. A person's display name and an unnamed caller's key are held from the start, so a person who takes a display name another person used within the last 7 days also lists that person's messages to it.
- The trusted presence service records each rename of this engine's agents
as it happens (
recordRename), including apresenceframe that renames a local agent: the old name's period ends and the new one's begins at the rename. - A reader found under a name its record does not hold closes the recorded name at that read and opens the new one at its previous read, so the time between reads reaches both names.
- A departed agent's name stays open in its record until the reader is next found under another name.
- A name record past 64 KB, or one that does not decode, is read as none and starts again from the current name.
- Stamps from different clocks agree to about a second, so each period reaches one second past both its ends: a message sent within a second of a rename is both holders'.
A reader's file is written when one of its marks moves, and otherwise at most
once a minute for its last read alone. The chat setting
showOtherAccountsAgentsInChat lives under /source/local/agents/ too; with
it off, chat posts by agents working for another account are hidden
(chatVisible). The roster decides whose a sender on it is, and a sender that
has left is read by its post's kind and account.
Each direct message carries receipts, one synced file per recipient under
/source/agents/receipts/<message id>/<reader key>.json holding
{ key, name, notifiedAt?, readAt? }: the recipient's stable reader key
(written as a file name by the same reversible encoding as the reader files),
the name it went by, when its engine told it of the message, and when it first
read it, each in milliseconds since the Unix epoch. The trusted service writes
notifiedAt for each agent on its engine it posts the notice to, once per
message and recipient; a recipient's own first read (readFor, the
dropdown's included) writes readAt, and a later read leaves it as it is. A
person has no notice, and the editor's unread badge counting a message is not
a notice either, so a person's receipt holds readAt only, written when they
open the message. A chat post carries none. The sender's list with sent,
and its read of its own message, carry receipts = { { name, notifiedAt?, readAt? } }; receiptsOf(id) answers the same. Receipts are removed with
their message by the retention rule above. A receipt file past
RECEIPT_MAX_BYTES (1 KB, read with vfs.readBounded), or with no key
matching its file name, no name, a stamp that is no number or more than a day
ahead, or neither stamp, is left out, and at most RECEIPTS_MAX (256) are read
for one message. Only the recipient's own reading reaches a receipt; every
other read mark stays on the device.
Each VM decodes a message or receipt file once and keeps what it decoded until the file is written or removed, so a walk over the folders reads only the files that changed since the last one.
Each engine tells the agents working on it of each message and chat post
that lands there addressed to them, by the rules above, as a notice on
their next tool call: a direct message its recipients, a chat post every
agent but its sender that the chat setting lets it through to. The notice
carries the direct-message note or the chat warning, and the message's id,
sender and first line. Each message id is announced at most once per engine
run, and the messages a world holds when the engine first takes it are
where the engine starts, not arrivals. A file whose name is no message id,
that does not decode as a message (a kind other than agent or person,
an account that is no string, a to other than "*" or a list of names,
or a replyTo that is no message id included), whose id is stamped more than
a day ahead, or that is past MESSAGE_MAX_BYTES (64 KB, skipped with one
warning, and read with vfs.readBounded, so a larger file's bytes are not
loaded) is no message to list, read, readChat or a notice. A recipient
on another engine is told by that engine when the file reaches it.
| Call | Answer |
|---|---|
me() / localPerson() | The caller as a participant; the person signed in on this engine. |
participants() | Every name on the world, and the names held more than once. |
resolve(to) | The recipient list, or a refusal naming the problem. |
send(to, body, replyTo?) / post(body) | The new message's id. |
list(opts?) | Headers of the messages addressed to the caller by others, or with sent the ones it sent (always read, each with its receipts); filtered by to (named to that recipient), from, unread, since. |
read(id) / unread(id) | The message, marked read, with its receipts when the caller sent it and its readAt recorded on the caller's receipt when it did not; or the mark removed from a message sent to the caller. Either refuses a message neither addressed to the caller nor sent by it, and unread refuses one the caller sent, which is always read. |
receiptsOf(id) / recordReceipt(id, key, name, field, at) | A direct message's receipts by name; record notifiedAt or readAt on one reader's receipt, leaving a field it already holds. |
readChat(since?) | The visible chat posts, each marked untrusted: those stamped after since (0 for all within retention), or with no since the ones by others the caller has not read. The posts returned are read for the caller from then on. |
sendAs(me, to, body, replyTo?) / postAs(me, body) / listFor(me, opts?) / readFor(me, id) / readChatFor(me?, since?) | The calls above for the participant me instead of the caller; listFor with withBody puts each message's body on its header. |
markChatReadFor(me, ids) | Mark the chat posts ids read for me, as a read that returned them would. |
unreadCount() | The local person's unread messages from others plus the visible chat posts by others they have not read. |
settings() / setSetting(key, value) | The chat setting on this device. |
heldNames(key) / recordRename(key, old, new, oldSince?) | The names a reader has held and when; record a rename as it happens. |
isId(id) / loadMessage(dir, id) | Whether id has a message id's form; the message, or nil and why (unreadable, too_large, malformed). |
heldFor(me) | The names the reader me holds as list reads them, leaving its record as it is. |
heldAt(held, name, at) / isAddressedTo(msg, held) | Whether a reader with these held names held name at at, and whether msg is addressed to it. |
Scoped to this part · feeds back into the world's score.