@twinsearth/w2m-dsh-plugin 1.2.7 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,247 +1,258 @@
1
- /**
2
- * Fleet chat: plain-text messages scoped to a SESSION, with a byte budget.
3
- *
4
- * ## Why the session is the scope
5
- *
6
- * The operator's decision: messages belong to the conversation, like a group chat, and disappear with it. A machine that
7
- * joins or leaves a session changes who can read the flow, but never rewrites what was said -- and deleting the session
8
- * deletes the history, because keeping it would be a second, silently-growing store nobody asked for.
9
- *
10
- * ## The quota, as specified
11
- *
12
- * 100 MB per fleet per 24 hours is the baseline; when that is exceeded, the 7-day retention figure is compared and the
13
- * larger is kept, capped at 200 MB:
14
- *
15
- * limit = min(hardCap, max(quota24h, used7d))
16
- *
17
- * A message over the limit is REFUSED with the current usage attached -- never silently dropped, and never accepted by
18
- * evicting somebody else's words. The relay is the only clock here: a client-chosen timestamp would let one machine
19
- * claim its messages are older than they are.
20
- *
21
- * ## Sharding is not this file
22
- *
23
- * This keeps the authoritative list in memory for the relay to serve and to shard out; distributing encrypted shards
24
- * across the fleet's machines is the next step and depends on this shape staying the same.
25
- */
26
-
27
- /** 100 MB per 24h per fleet (operator decision). */
28
- export const QUOTA_24H_BYTES = 100 * 1024 * 1024;
29
- /** Hard ceiling regardless of how much the 7-day window would allow. */
30
- export const HARD_CAP_BYTES = 200 * 1024 * 1024;
31
- /** Retention window used for the "keep the larger" comparison. */
32
- export const RETENTION_DAYS = 7;
33
- /** One message. Text is cheap; a 4 KiB cap keeps a single paste from eating the budget. */
34
- export const MAX_MESSAGE_BYTES = 4 * 1024;
35
-
36
- const DAY_MS = 24 * 60 * 60 * 1000;
37
-
38
- /**
39
- * @param {{now?: () => number, quota24hBytes?: number, hardCapBytes?: number, retentionDays?: number,
40
- * maxMessageBytes?: number}} [options]
41
- */
42
- export function createFleetMessages(options = {}) {
43
- const now = typeof options.now === 'function' ? options.now : () => Date.now();
44
- const quota24hBytes = Number.isInteger(options.quota24hBytes) ? options.quota24hBytes : QUOTA_24H_BYTES;
45
- const hardCapBytes = Number.isInteger(options.hardCapBytes) ? options.hardCapBytes : HARD_CAP_BYTES;
46
- const retentionDays = Number.isInteger(options.retentionDays) ? options.retentionDays : RETENTION_DAYS;
47
- const maxMessageBytes = Number.isInteger(options.maxMessageBytes) ? options.maxMessageBytes : MAX_MESSAGE_BYTES;
48
-
49
- /**
50
- * Notification subscribers. An event carries NO text: the notification says "there is something new to read", and the
51
- * reader fetches the text through the endpoint that already authenticates them. Putting the text in a broadcast would
52
- * send every message to every connected listener regardless of session membership.
53
- */
54
- const listeners = new Set();
55
- /**
56
- * v1.2.5 idempotency: (session, key) -> message_id. A sender that retries after a lost response must not post the text twice,
57
- * and the answer to a replay is the FIRST message -- a second row would make the same sentence appear twice in the thread.
58
- */
59
- const idempotency = new Map();
60
- const MAX_IDEMPOTENCY_KEYS = 2000;
61
- /** session_id -> [{ message_id, session_id, machine_id, text, at_ms, bytes }] */
62
- const sessions = new Map();
63
- let seq = 0;
64
-
65
- const all = () => {
66
- const out = [];
67
- for (const rows of sessions.values()) out.push(...rows);
68
- return out;
69
- };
70
-
71
- function usage() {
72
- const t = now();
73
- const day = all().filter((m) => t - m.at_ms <= DAY_MS);
74
- const week = all().filter((m) => t - m.at_ms <= retentionDays * DAY_MS);
75
- const sum = (rows) => rows.reduce((total, m) => total + m.bytes, 0);
76
- return { used_24h_bytes: sum(day), used_retention_bytes: sum(week), messages: all().length };
77
- }
78
-
79
- function limitBytes() {
80
- const { used_retention_bytes: week } = usage();
81
- return Math.min(hardCapBytes, Math.max(quota24hBytes, week));
82
- }
83
-
84
- /**
85
- * @param {{session_id: string, fleet_id?: string|null, machine_id: string, text: string}} input
86
- * @returns {{ok: true, message: object} | {ok: false, code: string, detail?: object}}
87
- */
88
- function post(input) {
89
- const sessionId = typeof input?.session_id === 'string' ? input.session_id.trim() : '';
90
- if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
91
- const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
92
- if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
93
- if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
94
- const text = input.text;
95
- if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
96
- // Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
97
- // eslint-disable-next-line no-control-regex
98
- if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
99
- const bytes = Buffer.byteLength(text, 'utf8');
100
- if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
101
-
102
- // Addressing: 'all' is the group, a machine id is one recipient. ONE row either way -- N copies for N recipients
103
- // would multiply the byte budget by the fleet size for no extra meaning; visibility is a filter at read time.
104
- const to = typeof input?.to === 'string' && input.to.trim() !== '' ? input.to.trim() : 'all';
105
- if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
106
- // Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
107
- // exchange inside one thread. Plain fields, validated rather than trusted.
108
- /**
109
- * v1.2.6 end-to-end encryption: the relay carries the ciphertext and its metadata, and never a key. It validates the SHAPE
110
- * because a malformed envelope would be stored and later fail on every reader; it cannot check the content, and that is the
111
- * point -- which also means it cannot search messages or know their real length.
112
- */
113
- const rawEnc = input?.enc;
114
- let enc = null;
115
- if (rawEnc !== undefined && rawEnc !== null) {
116
- const ivOk = typeof rawEnc.iv === 'string' && rawEnc.iv.length > 0 && rawEnc.iv.length <= 64;
117
- const tagOk = typeof rawEnc.tag === 'string' && rawEnc.tag.length > 0 && rawEnc.tag.length <= 64;
118
- if (rawEnc.v !== 1 || rawEnc.alg !== 'aes-256-gcm' || !ivOk || !tagOk) return { ok: false, code: 'BAD_ENCRYPTION' };
119
- enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
120
- }
121
-
122
- // v1.2.5 idempotency: replay the first message instead of storing the text twice.
123
- const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
124
- ? input.idempotency_key.trim().slice(0, 64)
125
- : null;
126
- if (idempotencyKey !== null) {
127
- const seenId = idempotency.get(sessionId + '\u0000' + idempotencyKey) ?? null;
128
- if (seenId !== null) {
129
- const known = (sessions.get(sessionId) ?? []).find((entry) => entry.message_id === seenId) ?? null;
130
- if (known !== null) return { ok: true, message: { ...known }, deduped: true };
131
- }
132
- }
133
-
134
- // v1.2.5 mentions: names to alert, validated as a small set. The message still has ONE row; a mention is a notification
135
- // hint, not a delivery mechanism.
136
- const rawMentions = input?.mentions;
137
- let mentions = [];
138
- if (rawMentions !== undefined && rawMentions !== null) {
139
- if (!Array.isArray(rawMentions) || rawMentions.length > 10) return { ok: false, code: 'BAD_MENTIONS' };
140
- for (const entry of rawMentions) {
141
- if (typeof entry !== 'string' || entry === '' || entry.length > 64 || /\s/.test(entry)) return { ok: false, code: 'BAD_MENTIONS' };
142
- }
143
- mentions = [...new Set(rawMentions)];
144
- }
145
-
146
- // v1.2.7: a reply can be an ANSWER or a REFUSAL, and the difference is a state, not a wording. Validated here so the
147
- // turn state machine reads the sender's own word instead of guessing from text.
148
- const replyState = input?.reply_state === undefined || input?.reply_state === null ? null : input.reply_state;
149
- if (replyState !== null && replyState !== 'answered' && replyState !== 'refused') return { ok: false, code: 'BAD_REPLY_STATE' };
150
-
151
- const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
152
- ? input.thread_id.trim().slice(0, 64)
153
- : null;
154
- const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
155
-
156
- const before = usage();
157
- const limit = limitBytes();
158
- if (before.used_24h_bytes + bytes > limit) {
159
- return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
160
- }
161
-
162
- // A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
163
- const existing = sessions.get(sessionId) ?? [];
164
- if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
165
- return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
166
- }
167
- const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
168
- const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
169
-
170
- seq += 1;
171
- const message = {
172
- message_id: 'msg-' + String(seq).padStart(6, '0'),
173
- session_id: sessionId,
174
- fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
175
- machine_id: machineId,
176
- to,
177
- thread_id: threadOf,
178
- reply_to: replyTo,
179
- turn,
180
- text,
181
- // Omitted when empty so a message without mentions keeps the pre-1.2.5 shape.
182
- ...(mentions.length === 0 ? {} : { mentions }),
183
- // v1.2.6: ciphertext metadata travels beside the text so an old client can read it as opaque text and a new one can name
184
- // the cipher it does not have a key for.
185
- ...(enc === null ? {} : { enc }),
186
- ...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
187
- ...(replyState === null ? {} : { reply_state: replyState }),
188
- at_ms: now(),
189
- bytes,
190
- };
191
- if (!sessions.has(sessionId)) sessions.set(sessionId, []);
192
- sessions.get(sessionId).push(message);
193
- for (const listener of listeners) {
194
- if (listener.sessionId !== null && listener.sessionId !== sessionId) continue;
195
- try {
196
- // The event carries addressing and mentions so a listener can say "you were named"; it never carries the text.
197
- listener.fn({
198
- session_id: sessionId,
199
- message_id: message.message_id,
200
- machine_id: message.machine_id,
201
- at_ms: message.at_ms,
202
- to: message.to,
203
- ...(mentions.length === 0 ? {} : { mentions }),
204
- });
205
- }
206
- catch { /* a dead listener must not fail the write */ }
207
- }
208
- if (idempotencyKey !== null) {
209
- idempotency.set(sessionId + '\u0000' + idempotencyKey, message.message_id);
210
- if (idempotency.size > MAX_IDEMPOTENCY_KEYS) {
211
- const oldest = idempotency.keys().next().value;
212
- if (oldest !== undefined) idempotency.delete(oldest);
213
- }
214
- }
215
-
216
- return { ok: true, message };
217
- }
218
-
219
- /** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
220
- function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
221
- thread_id: threadId = null, to: toFilter = null } = {}) {
222
- const rows = sessions.get(sessionId) ?? [];
223
- let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
224
- // A reader sees the group and what was addressed to it, never another machine's private message.
225
- if (typeof forMachine === 'string' && forMachine !== '') {
226
- filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
227
- }
228
- if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
229
- if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
230
- const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
231
- return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
232
- }
233
-
234
- /** Deleting a session deletes its history -- the operator's rule, not a side effect. */
235
- function deleteSession(sessionId) {
236
- return sessions.delete(sessionId);
237
- }
238
-
239
- /** @param {string|null} sessionId null subscribes to every session; returns the unsubscribe function. */
240
- function subscribe(sessionId, fn) {
241
- const listener = { sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null, fn };
242
- listeners.add(listener);
243
- return () => listeners.delete(listener);
244
- }
245
-
246
- return { post, list, usage, limitBytes, deleteSession, subscribe, _sessions: sessions };
247
- }
1
+ /**
2
+ * Fleet chat: plain-text messages scoped to a SESSION, with a byte budget.
3
+ *
4
+ * ## Why the session is the scope
5
+ *
6
+ * The operator's decision: messages belong to the conversation, like a group chat, and disappear with it. A machine that
7
+ * joins or leaves a session changes who can read the flow, but never rewrites what was said -- and deleting the session
8
+ * deletes the history, because keeping it would be a second, silently-growing store nobody asked for.
9
+ *
10
+ * ## The quota, as specified
11
+ *
12
+ * 100 MB per fleet per 24 hours is the baseline; when that is exceeded, the 7-day retention figure is compared and the
13
+ * larger is kept, capped at 200 MB:
14
+ *
15
+ * limit = min(hardCap, max(quota24h, used7d))
16
+ *
17
+ * A message over the limit is REFUSED with the current usage attached -- never silently dropped, and never accepted by
18
+ * evicting somebody else's words. The relay is the only clock here: a client-chosen timestamp would let one machine
19
+ * claim its messages are older than they are.
20
+ *
21
+ * ## Sharding is not this file
22
+ *
23
+ * This keeps the authoritative list in memory for the relay to serve and to shard out; distributing encrypted shards
24
+ * across the fleet's machines is the next step and depends on this shape staying the same.
25
+ */
26
+ import { randomBytes } from 'node:crypto';
27
+
28
+
29
+ /** 100 MB per 24h per fleet (operator decision). */
30
+ export const QUOTA_24H_BYTES = 100 * 1024 * 1024;
31
+ /** Hard ceiling regardless of how much the 7-day window would allow. */
32
+ export const HARD_CAP_BYTES = 200 * 1024 * 1024;
33
+ /** Retention window used for the "keep the larger" comparison. */
34
+ export const RETENTION_DAYS = 7;
35
+ /** One message. Text is cheap; a 4 KiB cap keeps a single paste from eating the budget. */
36
+ export const MAX_MESSAGE_BYTES = 4 * 1024;
37
+
38
+ const DAY_MS = 24 * 60 * 60 * 1000;
39
+
40
+ /**
41
+ * @param {{now?: () => number, quota24hBytes?: number, hardCapBytes?: number, retentionDays?: number,
42
+ * maxMessageBytes?: number}} [options]
43
+ */
44
+ export function createFleetMessages(options = {}) {
45
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
46
+ /**
47
+ * v1.2.8: the relay's message sequence resets on restart, so a bare `msg-000001` names a DIFFERENT message after every
48
+ * restart. Clients persist message ids -- an open turn, a read watermark, a reply_to -- and a reused id makes a new message
49
+ * settle an old turn with the wrong state. The epoch is random per process, so ids are unique across restarts without a
50
+ * persisted counter, and old clients that render ids verbatim keep working.
51
+ */
52
+ const epoch = typeof options.epoch === 'string' && options.epoch !== ''
53
+ ? options.epoch
54
+ : randomBytes(3).toString('hex');
55
+ const quota24hBytes = Number.isInteger(options.quota24hBytes) ? options.quota24hBytes : QUOTA_24H_BYTES;
56
+ const hardCapBytes = Number.isInteger(options.hardCapBytes) ? options.hardCapBytes : HARD_CAP_BYTES;
57
+ const retentionDays = Number.isInteger(options.retentionDays) ? options.retentionDays : RETENTION_DAYS;
58
+ const maxMessageBytes = Number.isInteger(options.maxMessageBytes) ? options.maxMessageBytes : MAX_MESSAGE_BYTES;
59
+
60
+ /**
61
+ * Notification subscribers. An event carries NO text: the notification says "there is something new to read", and the
62
+ * reader fetches the text through the endpoint that already authenticates them. Putting the text in a broadcast would
63
+ * send every message to every connected listener regardless of session membership.
64
+ */
65
+ const listeners = new Set();
66
+ /**
67
+ * v1.2.5 idempotency: (session, key) -> message_id. A sender that retries after a lost response must not post the text twice,
68
+ * and the answer to a replay is the FIRST message -- a second row would make the same sentence appear twice in the thread.
69
+ */
70
+ const idempotency = new Map();
71
+ const MAX_IDEMPOTENCY_KEYS = 2000;
72
+ /** session_id -> [{ message_id, session_id, machine_id, text, at_ms, bytes }] */
73
+ const sessions = new Map();
74
+ let seq = 0;
75
+
76
+ const all = () => {
77
+ const out = [];
78
+ for (const rows of sessions.values()) out.push(...rows);
79
+ return out;
80
+ };
81
+
82
+ function usage() {
83
+ const t = now();
84
+ const day = all().filter((m) => t - m.at_ms <= DAY_MS);
85
+ const week = all().filter((m) => t - m.at_ms <= retentionDays * DAY_MS);
86
+ const sum = (rows) => rows.reduce((total, m) => total + m.bytes, 0);
87
+ return { used_24h_bytes: sum(day), used_retention_bytes: sum(week), messages: all().length };
88
+ }
89
+
90
+ function limitBytes() {
91
+ const { used_retention_bytes: week } = usage();
92
+ return Math.min(hardCapBytes, Math.max(quota24hBytes, week));
93
+ }
94
+
95
+ /**
96
+ * @param {{session_id: string, fleet_id?: string|null, machine_id: string, text: string}} input
97
+ * @returns {{ok: true, message: object} | {ok: false, code: string, detail?: object}}
98
+ */
99
+ function post(input) {
100
+ const sessionId = typeof input?.session_id === 'string' ? input.session_id.trim() : '';
101
+ if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
102
+ const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
103
+ if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
104
+ if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
105
+ const text = input.text;
106
+ if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
107
+ // Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
108
+ // eslint-disable-next-line no-control-regex
109
+ if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
110
+ const bytes = Buffer.byteLength(text, 'utf8');
111
+ if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
112
+
113
+ // Addressing: 'all' is the group, a machine id is one recipient. ONE row either way -- N copies for N recipients
114
+ // would multiply the byte budget by the fleet size for no extra meaning; visibility is a filter at read time.
115
+ const to = typeof input?.to === 'string' && input.to.trim() !== '' ? input.to.trim() : 'all';
116
+ if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
117
+ // Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
118
+ // exchange inside one thread. Plain fields, validated rather than trusted.
119
+ /**
120
+ * v1.2.6 end-to-end encryption: the relay carries the ciphertext and its metadata, and never a key. It validates the SHAPE
121
+ * because a malformed envelope would be stored and later fail on every reader; it cannot check the content, and that is the
122
+ * point -- which also means it cannot search messages or know their real length.
123
+ */
124
+ const rawEnc = input?.enc;
125
+ let enc = null;
126
+ if (rawEnc !== undefined && rawEnc !== null) {
127
+ const ivOk = typeof rawEnc.iv === 'string' && rawEnc.iv.length > 0 && rawEnc.iv.length <= 64;
128
+ const tagOk = typeof rawEnc.tag === 'string' && rawEnc.tag.length > 0 && rawEnc.tag.length <= 64;
129
+ if (rawEnc.v !== 1 || rawEnc.alg !== 'aes-256-gcm' || !ivOk || !tagOk) return { ok: false, code: 'BAD_ENCRYPTION' };
130
+ enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
131
+ }
132
+
133
+ // v1.2.5 idempotency: replay the first message instead of storing the text twice.
134
+ const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
135
+ ? input.idempotency_key.trim().slice(0, 64)
136
+ : null;
137
+ if (idempotencyKey !== null) {
138
+ const seenId = idempotency.get(sessionId + '\u0000' + idempotencyKey) ?? null;
139
+ if (seenId !== null) {
140
+ const known = (sessions.get(sessionId) ?? []).find((entry) => entry.message_id === seenId) ?? null;
141
+ if (known !== null) return { ok: true, message: { ...known }, deduped: true };
142
+ }
143
+ }
144
+
145
+ // v1.2.5 mentions: names to alert, validated as a small set. The message still has ONE row; a mention is a notification
146
+ // hint, not a delivery mechanism.
147
+ const rawMentions = input?.mentions;
148
+ let mentions = [];
149
+ if (rawMentions !== undefined && rawMentions !== null) {
150
+ if (!Array.isArray(rawMentions) || rawMentions.length > 10) return { ok: false, code: 'BAD_MENTIONS' };
151
+ for (const entry of rawMentions) {
152
+ if (typeof entry !== 'string' || entry === '' || entry.length > 64 || /\s/.test(entry)) return { ok: false, code: 'BAD_MENTIONS' };
153
+ }
154
+ mentions = [...new Set(rawMentions)];
155
+ }
156
+
157
+ // v1.2.7: a reply can be an ANSWER or a REFUSAL, and the difference is a state, not a wording. Validated here so the
158
+ // turn state machine reads the sender's own word instead of guessing from text.
159
+ const replyState = input?.reply_state === undefined || input?.reply_state === null ? null : input.reply_state;
160
+ if (replyState !== null && replyState !== 'answered' && replyState !== 'refused') return { ok: false, code: 'BAD_REPLY_STATE' };
161
+
162
+ const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
163
+ ? input.thread_id.trim().slice(0, 64)
164
+ : null;
165
+ const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
166
+
167
+ const before = usage();
168
+ const limit = limitBytes();
169
+ if (before.used_24h_bytes + bytes > limit) {
170
+ return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
171
+ }
172
+
173
+ // A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
174
+ const existing = sessions.get(sessionId) ?? [];
175
+ if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
176
+ return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
177
+ }
178
+ const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
179
+ const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
180
+
181
+ seq += 1;
182
+ const message = {
183
+ message_id: 'msg-' + epoch + '-' + String(seq).padStart(6, '0'),
184
+ session_id: sessionId,
185
+ fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
186
+ machine_id: machineId,
187
+ to,
188
+ thread_id: threadOf,
189
+ reply_to: replyTo,
190
+ turn,
191
+ text,
192
+ // Omitted when empty so a message without mentions keeps the pre-1.2.5 shape.
193
+ ...(mentions.length === 0 ? {} : { mentions }),
194
+ // v1.2.6: ciphertext metadata travels beside the text so an old client can read it as opaque text and a new one can name
195
+ // the cipher it does not have a key for.
196
+ ...(enc === null ? {} : { enc }),
197
+ ...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
198
+ ...(replyState === null ? {} : { reply_state: replyState }),
199
+ at_ms: now(),
200
+ bytes,
201
+ };
202
+ if (!sessions.has(sessionId)) sessions.set(sessionId, []);
203
+ sessions.get(sessionId).push(message);
204
+ for (const listener of listeners) {
205
+ if (listener.sessionId !== null && listener.sessionId !== sessionId) continue;
206
+ try {
207
+ // The event carries addressing and mentions so a listener can say "you were named"; it never carries the text.
208
+ listener.fn({
209
+ session_id: sessionId,
210
+ message_id: message.message_id,
211
+ machine_id: message.machine_id,
212
+ at_ms: message.at_ms,
213
+ to: message.to,
214
+ ...(mentions.length === 0 ? {} : { mentions }),
215
+ });
216
+ }
217
+ catch { /* a dead listener must not fail the write */ }
218
+ }
219
+ if (idempotencyKey !== null) {
220
+ idempotency.set(sessionId + '\u0000' + idempotencyKey, message.message_id);
221
+ if (idempotency.size > MAX_IDEMPOTENCY_KEYS) {
222
+ const oldest = idempotency.keys().next().value;
223
+ if (oldest !== undefined) idempotency.delete(oldest);
224
+ }
225
+ }
226
+
227
+ return { ok: true, message };
228
+ }
229
+
230
+ /** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
231
+ function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
232
+ thread_id: threadId = null, to: toFilter = null } = {}) {
233
+ const rows = sessions.get(sessionId) ?? [];
234
+ let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
235
+ // A reader sees the group and what was addressed to it, never another machine's private message.
236
+ if (typeof forMachine === 'string' && forMachine !== '') {
237
+ filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
238
+ }
239
+ if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
240
+ if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
241
+ const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
242
+ return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
243
+ }
244
+
245
+ /** Deleting a session deletes its history -- the operator's rule, not a side effect. */
246
+ function deleteSession(sessionId) {
247
+ return sessions.delete(sessionId);
248
+ }
249
+
250
+ /** @param {string|null} sessionId null subscribes to every session; returns the unsubscribe function. */
251
+ function subscribe(sessionId, fn) {
252
+ const listener = { sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null, fn };
253
+ listeners.add(listener);
254
+ return () => listeners.delete(listener);
255
+ }
256
+
257
+ return { post, list, usage, limitBytes, deleteSession, subscribe, _sessions: sessions };
258
+ }
@@ -29,7 +29,11 @@ import {
29
29
  // one shared limit the blob channel could never accept a single byte -- a pipeline blob upload from a real relay failed with
30
30
  // FRAME_TOO_LARGE while the fake relay in the tests accepted it. The dataflow hard cap stays the agent-side bound; this is the
31
31
  // transport bound, and it is still bounded.
32
- export const BLOB_FRAME_BYTES = 1024 * 1024; // 1 MiB per blob frame
32
+ export const BLOB_FRAME_BYTES = 16 * 1024 * 1024; // 16 MiB per blob frame -- the same cap the agent's dataflow plan uses.
33
+ //
34
+ // v1.2.8-beta.1: it was 1 MiB while the agent's hard cap is 16 MiB, so an artifact between the two failed its upload with
35
+ // BLOB_UPLOAD_FAILED even though the agent had accepted it. One number for "how large may one artifact be" is the honest shape;
36
+ // the JSON frame limit stays 64 KiB for every other route.
33
37
  import { createFleetMessages } from './fleet-messages.mjs';
34
38
  import { can as mayAct } from '../util/policy-matrix.mjs';
35
39
  import { createFleetMemory } from './fleet-memory.mjs';