Bifrost/tools/persona-chat-documents.mjs
Meta Vens a76c70cc39 persona matrix mouth: a v2 send is refused if the room moved past her basis; check and PUT are one step per room across every Persona
Removes the bare PUT; the one PUT now lives inside the room gate. v1 stays served unchecked through the same gate.
Wire: chat_send_request.v2 (basis) and chat_send_receipt.v2 (newer); RoomMoved is v2 only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:01:44 +01:00

182 lines
10 KiB
JavaScript
Raw Permalink Blame History

// gamecult.persona.chat_*.v1 and the v2 send pair: the documents Huginn and the Matrix mouth exchange.
// Each kind is a positional tuple (the CultCache payload shape); this module owns
// the field order, the parsers and the envelope check. It does no I/O and loads no
// runtime, so the wire encoder (MessagePack) stays with the caller.
export const kinds = Object.freeze({
pendingRequest: "gamecult.persona.chat_pending_request.v1",
pending: "gamecult.persona.chat_pending.v1",
ack: "gamecult.persona.chat_ack.v1",
sendRequest: "gamecult.persona.chat_send_request.v1",
sendReceipt: "gamecult.persona.chat_send_receipt.v1",
sendRequestV2: "gamecult.persona.chat_send_request.v2",
sendReceiptV2: "gamecult.persona.chat_send_receipt.v2",
refusal: "gamecult.persona.chat_refusal.v1",
});
export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom", "EmptyText", "SendFailed", "UnknownPersona"]);
// RoomMoved exists only in the v2 receipt: the v1 tuple, a v1 parse and the typed refusal document do not know it.
export const refusalsV2 = Object.freeze([...refusals, "RoomMoved"]);
const maxTextBytes = 4000;
export const maxNewer = 50;
const maxContext = 20;
// Per-delivery bounds, applied where the mouth reads Matrix: the body is bounded like a send,
// a context line and a display name are smaller, and a reply is paged to a byte budget.
export const limits = Object.freeze({ textBytes: maxTextBytes, contextBytes: 1000, displayBytes: 256, mentions: 64, replyBytes: 256 * 1024 });
// `value` cut to at most `maxBytes` of UTF-8 without splitting a character.
export function clip(value, maxBytes) {
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
return Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8").replace(/<2F>$/, "");
}
export function pendingRequestTuple({ persona, afterSeq }) {
return [text(persona, "persona"), seq(afterSeq, "after_seq")];
}
export function parsePendingRequest(t) {
const [persona, afterSeq] = tuple(t, 2, "chat_pending_request");
return { persona: text(persona, "persona"), afterSeq: seq(afterSeq, "after_seq") };
}
export function ackTuple({ persona, throughSeq }) {
return [text(persona, "persona"), seq(throughSeq, "through_seq")];
}
export function parseAck(t) {
const [persona, throughSeq] = tuple(t, 2, "chat_ack");
return { persona: text(persona, "persona"), throughSeq: seq(throughSeq, "through_seq") };
}
export function sendRequestTuple({ requestId, persona, roomAlias, text: body }) {
return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body)];
}
export function parseSendRequest(t) {
const [requestId, persona, roomAlias, body] = tuple(t, 4, "chat_send_request");
if (typeof body !== "string") throw new Error("chat_send_request text must be a string");
if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound");
return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body };
}
export function sendReceiptTuple({ requestId, eventId = null, refusal = null }) {
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`);
return [text(requestId, "request_id"), eventId, refusal];
}
export function parseSendReceipt(t) {
const [requestId, eventId, refusal] = tuple(t, 3, "chat_send_receipt");
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal };
}
// v2: the same send plus a basis. basis is null (her choice stands: no check) or { seenEventId|null, sinceUnixMs }.
export function sendRequestV2Tuple({ requestId, persona, roomAlias, text: body, basis = null }) {
return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body),
basis === null ? null : [basis.seenEventId === null ? null : text(basis.seenEventId, "seen_event_id"), seq(basis.sinceUnixMs, "since_unix_ms")]];
}
export function parseSendRequestV2(t) {
const [requestId, persona, roomAlias, body, basis] = tuple(t, 5, "chat_send_request_v2");
if (typeof body !== "string") throw new Error("chat_send_request text must be a string");
if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound");
let parsed = null;
if (basis !== null) {
const [seen, since] = tuple(basis, 2, "basis");
parsed = { seenEventId: seen === null ? null : text(seen, "seen_event_id"), sinceUnixMs: seq(since, "since_unix_ms") };
}
return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body, basis: parsed };
}
// `newer` is the ids of the messages that moved the room, oldest first: non-empty exactly when the refusal is RoomMoved.
function checkReceiptV2(eventId, refusal, newer) {
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
if (refusal !== null && !refusalsV2.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
if (!Array.isArray(newer) || newer.length > maxNewer || (newer.length > 0) !== (refusal === "RoomMoved")) throw new Error("newer is non-empty exactly when the refusal is RoomMoved, and at most 50 ids");
return newer.map(id => text(id, "newer"));
}
export function sendReceiptV2Tuple({ requestId, eventId = null, refusal = null, newer = [] }) {
return [text(requestId, "request_id"), eventId, refusal, checkReceiptV2(eventId, refusal, newer)];
}
export function parseSendReceiptV2(t) {
const [requestId, eventId, refusal, newer] = tuple(t, 4, "chat_send_receipt_v2");
return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal, newer: checkReceiptV2(eventId, refusal, newer) };
}
// A typed refusal for a request that names a Persona the mouth does not serve.
export function refusalTuple({ persona, refusal }) {
if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`);
return [text(persona, "persona"), refusal];
}
export function parseRefusal(t) {
const [persona, refusal] = tuple(t, 2, "chat_refusal");
if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
return { persona: text(persona, "persona"), refusal };
}
export function deliveryTuple(d) {
return [
seq(d.seq, "seq"), text(d.roomAlias, "room_alias"), text(d.roomId, "room_id"), text(d.eventId, "event_id"),
text(d.sender, "sender"), text(d.senderDisplay, "sender_display"), String(d.text), seq(d.atUnixMs, "at_unix_ms"),
d.context.slice(-maxContext).map(c => [text(c.sender, "sender"), String(c.text), seq(c.atUnixMs, "at_unix_ms")]),
d.mentions.map(id => text(id, "mention")), flag(d.isDirect, "is_direct"),
];
}
export function parseDelivery(t) {
const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context, mentions, isDirect] = tuple(t, 11, "delivery");
if (!Array.isArray(context)) throw new Error("delivery context must be a list");
if (!Array.isArray(mentions)) throw new Error("delivery mentions must be a list");
return {
seq: seq(s, "seq"), roomAlias: text(roomAlias, "room_alias"), roomId: text(roomId, "room_id"), eventId: text(eventId, "event_id"),
sender: text(sender, "sender"), senderDisplay: text(senderDisplay, "sender_display"), text: String(body), atUnixMs: seq(atUnixMs, "at_unix_ms"),
context: context.map(c => { const [cs, ct, ca] = tuple(c, 3, "context"); return { sender: text(cs, "sender"), text: String(ct), atUnixMs: seq(ca, "at_unix_ms") }; }),
mentions: mentions.map(id => text(id, "mention")), isDirect: flag(isDirect, "is_direct"),
};
}
export function pendingTuple({ persona, deliveries }) {
return [text(persona, "persona"), deliveries.map(deliveryTuple)];
}
export function parsePending(t) {
const [persona, deliveries] = tuple(t, 2, "chat_pending");
if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list");
return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDelivery) };
}
// The CultNet document_put_raw envelope every chat_* document travels in.
export function envelope({ kind, recordKey, payload, sourceRuntimeId, sourceAgentId, sourceRole }) {
return {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `${kind}:${recordKey}`,
document: {
schemaId: kind, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack", payload,
sourceRuntimeId, sourceAgentId, sourceRole, tags: ["cultnet.transport.rudp.v0"],
},
};
}
export function checkEnvelope(message, allowedKinds) {
const d = message?.document;
if (message?.schemaVersion !== "cultnet.document_put_raw.v0" || !d) throw new Error("not a document_put_raw message");
if (!allowedKinds.includes(d.schemaId)) throw new Error(`document kind ${String(d.schemaId)} is not accepted here`);
if (d.payloadEncoding !== "messagepack") throw new Error("chat documents are MessagePack");
if (!Array.isArray(d.tags) || d.tags.length !== 1 || d.tags[0] !== "cultnet.transport.rudp.v0") throw new Error("chat document tags substituted authority");
return d;
}
function tuple(t, n, what) {
if (!Array.isArray(t) || t.length !== n) throw new Error(`${what} must be a ${n}-field tuple`);
return t;
}
function text(v, what) {
if (typeof v !== "string" || v === "") throw new Error(`${what} must be a non-empty string`);
return v;
}
function flag(v, what) {
if (typeof v !== "boolean") throw new Error(`${what} must be a boolean`);
return v;
}
function seq(v, what) {
if (!Number.isSafeInteger(v) || v < 0) throw new Error(`${what} must be a non-negative integer`);
return v;
}