@twinsearth/w2m-dsh-plugin 1.2.2 → 1.2.5

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.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Turns inside a thread, derived from the messages themselves.
3
+ *
4
+ * The relay assigns `turn` when it stores a message, so the only thing this module does is check that the sequence it
5
+ * receives is usable: a reader that trusts client timestamps for ordering shows answers above questions. When a message has
6
+ * no turn, it inherits the previous one so a machine writing plain chat does not split every message into its own turn.
7
+ */
8
+ /**
9
+ * @param {Array<{message_id?: string, thread_id?: string|null, turn?: number, at_ms?: number, text?: string}>} messages
10
+ * @returns {{threads: Array<{thread_id: string|null, turns: Array<{turn: number, messages: object[]}>}>}}
11
+ */
12
+ export function deriveTurns(messages = {}) {
13
+ const list = Array.isArray(messages) ? messages : [];
14
+ const byThread = new Map();
15
+ let inferred = 0;
16
+ for (const message of list) {
17
+ const threadId = typeof message?.thread_id === 'string' && message.thread_id !== '' ? message.thread_id : null;
18
+ if (!byThread.has(threadId)) byThread.set(threadId, []);
19
+ let turn = Number.isInteger(message?.turn) ? message.turn : null;
20
+ if (turn === null) {
21
+ const history = byThread.get(threadId);
22
+ const last = history[history.length - 1] ?? null;
23
+ turn = last === null ? 1 : last.turn;
24
+ inferred += 1;
25
+ }
26
+ byThread.get(threadId).push({ message, turn });
27
+ }
28
+ const threads = [...byThread.entries()].map(([threadId, entries]) => {
29
+ const turns = [];
30
+ for (const entry of entries) {
31
+ const last = turns[turns.length - 1] ?? null;
32
+ if (last !== null && last.turn === entry.turn) { last.messages.push(entry.message); continue; }
33
+ turns.push({ turn: entry.turn, messages: [entry.message] });
34
+ }
35
+ // Turns are reported in the order the relay assigned them, never the order the list happened to arrive in.
36
+ turns.sort((a, b) => a.turn - b.turn);
37
+ return { thread_id: threadId, turns };
38
+ });
39
+ threads.sort((a, b) => String(a.thread_id ?? '').localeCompare(String(b.thread_id ?? '')));
40
+ return { threads, inferred_turns: inferred };
41
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The fleet's member list: who is in this fleet, what role they hold, and when they joined.
3
+ *
4
+ * ## Why a role table and not a field on the device
5
+ *
6
+ * A device record is written by pairing and by reconnect; a role is an operator decision that must survive both. Keeping it in
7
+ * its own table also makes the default explicit: an unknown machine is a `worker`, never an `owner`, so a machine cannot gain
8
+ * authority by being re-paired.
9
+ *
10
+ * ## Why the refusals are values
11
+ *
12
+ * `role` is a closed set and the owner may only be changed by the operator token. Both are refused by name, because a member
13
+ * list that silently ignored a role change would be a board that disagrees with the fleet.
14
+ */
15
+ export const MEMBER_ROLES = Object.freeze(['owner', 'worker', 'observer']);
16
+
17
+ export const MEMBERS_REFUSAL = Object.freeze({
18
+ UNKNOWN_MACHINE: 'MEMBERS_UNKNOWN_MACHINE',
19
+ BAD_ROLE: 'MEMBERS_BAD_ROLE',
20
+ LAST_OWNER: 'MEMBERS_LAST_OWNER',
21
+ });
22
+
23
+ /** @param {unknown} role */
24
+ export function isMemberRole(role) {
25
+ return typeof role === 'string' && MEMBER_ROLES.includes(role);
26
+ }
27
+
28
+ /**
29
+ * @param {{devices?: Map<string, object>, roles?: Map<string, string>}} input
30
+ * @returns {Array<{machine_id: string, machine_name: string, role: string, joined_at_ms: number|null, online: boolean, last_seen_at_ms: number|null}>}
31
+ */
32
+ export function buildMembers({ devices = new Map(), roles = new Map() } = {}) {
33
+ const list = [];
34
+ for (const [machineId, device] of devices.entries()) {
35
+ const role = roles.get(machineId);
36
+ list.push({
37
+ machine_id: machineId,
38
+ machine_name: typeof device?.machine_name === 'string' && device.machine_name !== '' ? device.machine_name : machineId,
39
+ role: isMemberRole(role) ? role : 'worker',
40
+ joined_at_ms: Number.isFinite(device?.paired_at_ms) ? device.paired_at_ms : null,
41
+ online: device?.online === true,
42
+ last_seen_at_ms: Number.isFinite(device?.last_seen_at_ms) ? device.last_seen_at_ms : null,
43
+ });
44
+ }
45
+ list.sort((a, b) => a.machine_id.localeCompare(b.machine_id));
46
+ return list;
47
+ }
48
+
49
+ /**
50
+ * @param {{machineId: string, role: unknown, devices: Map<string, object>, roles: Map<string, string>}} input
51
+ * @returns {{ok: true, member: object} | {ok: false, code: string, detail?: object}}
52
+ */
53
+ export function setMemberRole({ machineId, role, devices = new Map(), roles = new Map() } = {}) {
54
+ if (typeof machineId !== 'string' || !devices.has(machineId)) {
55
+ return { ok: false, code: MEMBERS_REFUSAL.UNKNOWN_MACHINE, detail: { machine_id: machineId ?? null } };
56
+ }
57
+ if (!isMemberRole(role)) {
58
+ return { ok: false, code: MEMBERS_REFUSAL.BAD_ROLE, detail: { role: role ?? null, allowed: [...MEMBER_ROLES] } };
59
+ }
60
+ // The fleet must keep at least one owner: demoting the last one would leave nobody able to readmit a machine.
61
+ if (role !== 'owner') {
62
+ const owners = [...roles.entries()].filter(([id, held]) => held === 'owner' && id !== machineId);
63
+ const current = roles.get(machineId);
64
+ if (current === 'owner' && owners.length === 0) {
65
+ return { ok: false, code: MEMBERS_REFUSAL.LAST_OWNER, detail: { machine_id: machineId } };
66
+ }
67
+ }
68
+ roles.set(machineId, role);
69
+ const device = devices.get(machineId);
70
+ return {
71
+ ok: true,
72
+ member: {
73
+ machine_id: machineId,
74
+ machine_name: typeof device?.machine_name === 'string' ? device.machine_name : machineId,
75
+ role,
76
+ },
77
+ };
78
+ }
@@ -1,173 +1,224 @@
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
- /** session_id -> [{ message_id, session_id, machine_id, text, at_ms, bytes }] */
56
- const sessions = new Map();
57
- let seq = 0;
58
-
59
- const all = () => {
60
- const out = [];
61
- for (const rows of sessions.values()) out.push(...rows);
62
- return out;
63
- };
64
-
65
- function usage() {
66
- const t = now();
67
- const day = all().filter((m) => t - m.at_ms <= DAY_MS);
68
- const week = all().filter((m) => t - m.at_ms <= retentionDays * DAY_MS);
69
- const sum = (rows) => rows.reduce((total, m) => total + m.bytes, 0);
70
- return { used_24h_bytes: sum(day), used_retention_bytes: sum(week), messages: all().length };
71
- }
72
-
73
- function limitBytes() {
74
- const { used_retention_bytes: week } = usage();
75
- return Math.min(hardCapBytes, Math.max(quota24hBytes, week));
76
- }
77
-
78
- /**
79
- * @param {{session_id: string, fleet_id?: string|null, machine_id: string, text: string}} input
80
- * @returns {{ok: true, message: object} | {ok: false, code: string, detail?: object}}
81
- */
82
- function post(input) {
83
- const sessionId = typeof input?.session_id === 'string' ? input.session_id.trim() : '';
84
- if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
85
- const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
86
- if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
87
- if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
88
- const text = input.text;
89
- if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
90
- // Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
91
- // eslint-disable-next-line no-control-regex
92
- if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
93
- const bytes = Buffer.byteLength(text, 'utf8');
94
- if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
95
-
96
- // Addressing: 'all' is the group, a machine id is one recipient. ONE row either way -- N copies for N recipients
97
- // would multiply the byte budget by the fleet size for no extra meaning; visibility is a filter at read time.
98
- const to = typeof input?.to === 'string' && input.to.trim() !== '' ? input.to.trim() : 'all';
99
- if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
100
- // Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
101
- // exchange inside one thread. Plain fields, validated rather than trusted.
102
- const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
103
- ? input.thread_id.trim().slice(0, 64)
104
- : null;
105
- const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
106
-
107
- const before = usage();
108
- const limit = limitBytes();
109
- if (before.used_24h_bytes + bytes > limit) {
110
- return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
111
- }
112
-
113
- // A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
114
- const existing = sessions.get(sessionId) ?? [];
115
- if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
116
- return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
117
- }
118
- const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
119
- const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
120
-
121
- seq += 1;
122
- const message = {
123
- message_id: 'msg-' + String(seq).padStart(6, '0'),
124
- session_id: sessionId,
125
- fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
126
- machine_id: machineId,
127
- to,
128
- thread_id: threadOf,
129
- reply_to: replyTo,
130
- turn,
131
- text,
132
- at_ms: now(),
133
- bytes,
134
- };
135
- if (!sessions.has(sessionId)) sessions.set(sessionId, []);
136
- sessions.get(sessionId).push(message);
137
- for (const listener of listeners) {
138
- if (listener.sessionId !== null && listener.sessionId !== sessionId) continue;
139
- try { listener.fn({ session_id: sessionId, message_id: message.message_id, machine_id: message.machine_id, at_ms: message.at_ms }); }
140
- catch { /* a dead listener must not fail the write */ }
141
- }
142
- return { ok: true, message };
143
- }
144
-
145
- /** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
146
- function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
147
- thread_id: threadId = null, to: toFilter = null } = {}) {
148
- const rows = sessions.get(sessionId) ?? [];
149
- let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
150
- // A reader sees the group and what was addressed to it, never another machine's private message.
151
- if (typeof forMachine === 'string' && forMachine !== '') {
152
- filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
153
- }
154
- if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
155
- if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
156
- const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
157
- return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
158
- }
159
-
160
- /** Deleting a session deletes its history -- the operator's rule, not a side effect. */
161
- function deleteSession(sessionId) {
162
- return sessions.delete(sessionId);
163
- }
164
-
165
- /** @param {string|null} sessionId null subscribes to every session; returns the unsubscribe function. */
166
- function subscribe(sessionId, fn) {
167
- const listener = { sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null, fn };
168
- listeners.add(listener);
169
- return () => listeners.delete(listener);
170
- }
171
-
172
- return { post, list, usage, limitBytes, deleteSession, subscribe, _sessions: sessions };
173
- }
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
+ // v1.2.5 idempotency: replay the first message instead of storing the text twice.
109
+ const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
110
+ ? input.idempotency_key.trim().slice(0, 64)
111
+ : null;
112
+ if (idempotencyKey !== null) {
113
+ const seenId = idempotency.get(sessionId + '\u0000' + idempotencyKey) ?? null;
114
+ if (seenId !== null) {
115
+ const known = (sessions.get(sessionId) ?? []).find((entry) => entry.message_id === seenId) ?? null;
116
+ if (known !== null) return { ok: true, message: { ...known }, deduped: true };
117
+ }
118
+ }
119
+
120
+ // v1.2.5 mentions: names to alert, validated as a small set. The message still has ONE row; a mention is a notification
121
+ // hint, not a delivery mechanism.
122
+ const rawMentions = input?.mentions;
123
+ let mentions = [];
124
+ if (rawMentions !== undefined && rawMentions !== null) {
125
+ if (!Array.isArray(rawMentions) || rawMentions.length > 10) return { ok: false, code: 'BAD_MENTIONS' };
126
+ for (const entry of rawMentions) {
127
+ if (typeof entry !== 'string' || entry === '' || entry.length > 64 || /\s/.test(entry)) return { ok: false, code: 'BAD_MENTIONS' };
128
+ }
129
+ mentions = [...new Set(rawMentions)];
130
+ }
131
+
132
+ const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
133
+ ? input.thread_id.trim().slice(0, 64)
134
+ : null;
135
+ const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
136
+
137
+ const before = usage();
138
+ const limit = limitBytes();
139
+ if (before.used_24h_bytes + bytes > limit) {
140
+ return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
141
+ }
142
+
143
+ // A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
144
+ const existing = sessions.get(sessionId) ?? [];
145
+ if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
146
+ return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
147
+ }
148
+ const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
149
+ const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
150
+
151
+ seq += 1;
152
+ const message = {
153
+ message_id: 'msg-' + String(seq).padStart(6, '0'),
154
+ session_id: sessionId,
155
+ fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
156
+ machine_id: machineId,
157
+ to,
158
+ thread_id: threadOf,
159
+ reply_to: replyTo,
160
+ turn,
161
+ text,
162
+ // Omitted when empty so a message without mentions keeps the pre-1.2.5 shape.
163
+ ...(mentions.length === 0 ? {} : { mentions }),
164
+ ...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
165
+ at_ms: now(),
166
+ bytes,
167
+ };
168
+ if (!sessions.has(sessionId)) sessions.set(sessionId, []);
169
+ sessions.get(sessionId).push(message);
170
+ for (const listener of listeners) {
171
+ if (listener.sessionId !== null && listener.sessionId !== sessionId) continue;
172
+ try {
173
+ // The event carries addressing and mentions so a listener can say "you were named"; it never carries the text.
174
+ listener.fn({
175
+ session_id: sessionId,
176
+ message_id: message.message_id,
177
+ machine_id: message.machine_id,
178
+ at_ms: message.at_ms,
179
+ to: message.to,
180
+ ...(mentions.length === 0 ? {} : { mentions }),
181
+ });
182
+ }
183
+ catch { /* a dead listener must not fail the write */ }
184
+ }
185
+ if (idempotencyKey !== null) {
186
+ idempotency.set(sessionId + '\u0000' + idempotencyKey, message.message_id);
187
+ if (idempotency.size > MAX_IDEMPOTENCY_KEYS) {
188
+ const oldest = idempotency.keys().next().value;
189
+ if (oldest !== undefined) idempotency.delete(oldest);
190
+ }
191
+ }
192
+
193
+ return { ok: true, message };
194
+ }
195
+
196
+ /** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
197
+ function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
198
+ thread_id: threadId = null, to: toFilter = null } = {}) {
199
+ const rows = sessions.get(sessionId) ?? [];
200
+ let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
201
+ // A reader sees the group and what was addressed to it, never another machine's private message.
202
+ if (typeof forMachine === 'string' && forMachine !== '') {
203
+ filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
204
+ }
205
+ if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
206
+ if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
207
+ const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
208
+ return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
209
+ }
210
+
211
+ /** Deleting a session deletes its history -- the operator's rule, not a side effect. */
212
+ function deleteSession(sessionId) {
213
+ return sessions.delete(sessionId);
214
+ }
215
+
216
+ /** @param {string|null} sessionId null subscribes to every session; returns the unsubscribe function. */
217
+ function subscribe(sessionId, fn) {
218
+ const listener = { sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null, fn };
219
+ listeners.add(listener);
220
+ return () => listeners.delete(listener);
221
+ }
222
+
223
+ return { post, list, usage, limitBytes, deleteSession, subscribe, _sessions: sessions };
224
+ }