@openmarket/rooms-client 0.1.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.
Files changed (87) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +25 -0
  3. package/dist/agent/armed-mode.d.ts +187 -0
  4. package/dist/agent/armed-mode.js +306 -0
  5. package/dist/agent/clients/common.d.ts +70 -0
  6. package/dist/agent/clients/common.js +46 -0
  7. package/dist/agent/lane-draft.d.ts +15 -0
  8. package/dist/agent/lane-draft.js +43 -0
  9. package/dist/agent/lane-prompts.d.ts +102 -0
  10. package/dist/agent/lane-prompts.js +120 -0
  11. package/dist/agent/library-context.d.ts +38 -0
  12. package/dist/agent/library-context.js +57 -0
  13. package/dist/agent/library-model.d.ts +41 -0
  14. package/dist/agent/library-model.js +173 -0
  15. package/dist/agent/room-context.d.ts +151 -0
  16. package/dist/agent/room-context.js +251 -0
  17. package/dist/agent/sidebar-model.d.ts +86 -0
  18. package/dist/agent/sidebar-model.js +162 -0
  19. package/dist/agent/topic-inbox.d.ts +119 -0
  20. package/dist/agent/topic-inbox.js +266 -0
  21. package/dist/agent/topic-view-batcher.d.ts +54 -0
  22. package/dist/agent/topic-view-batcher.js +115 -0
  23. package/dist/client.d.ts +421 -0
  24. package/dist/client.js +1428 -0
  25. package/dist/doc-reconcile.d.ts +100 -0
  26. package/dist/doc-reconcile.js +110 -0
  27. package/dist/doc-uri.d.ts +29 -0
  28. package/dist/doc-uri.js +55 -0
  29. package/dist/endpoints.d.ts +9 -0
  30. package/dist/endpoints.js +13 -0
  31. package/dist/jwt.d.ts +10 -0
  32. package/dist/jwt.js +51 -0
  33. package/dist/library-publisher.d.ts +52 -0
  34. package/dist/library-publisher.js +49 -0
  35. package/dist/merge3.d.ts +50 -0
  36. package/dist/merge3.js +280 -0
  37. package/dist/names.d.ts +6 -0
  38. package/dist/names.js +35 -0
  39. package/dist/shared/emoji-data.d.ts +22 -0
  40. package/dist/shared/emoji-data.js +8834 -0
  41. package/dist/shared/mentions.d.ts +6 -0
  42. package/dist/shared/mentions.js +32 -0
  43. package/dist/shared/rooms-protocol.d.ts +1183 -0
  44. package/dist/shared/rooms-protocol.js +160 -0
  45. package/dist/social-client.d.ts +361 -0
  46. package/dist/social-client.js +686 -0
  47. package/dist/social-types.d.ts +338 -0
  48. package/dist/social-types.js +1 -0
  49. package/dist/suggestion-attribution.d.ts +10 -0
  50. package/dist/suggestion-attribution.js +56 -0
  51. package/dist/topic-uri.d.ts +26 -0
  52. package/dist/topic-uri.js +40 -0
  53. package/dist/types.d.ts +2 -0
  54. package/dist/types.js +1 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.js +2 -0
  57. package/dist/ws-client.d.ts +115 -0
  58. package/dist/ws-client.js +491 -0
  59. package/package.json +180 -0
  60. package/src/agent/armed-mode.ts +368 -0
  61. package/src/agent/clients/common.ts +91 -0
  62. package/src/agent/lane-draft.ts +47 -0
  63. package/src/agent/lane-prompts.ts +267 -0
  64. package/src/agent/library-context.ts +97 -0
  65. package/src/agent/library-model.ts +210 -0
  66. package/src/agent/room-context.ts +351 -0
  67. package/src/agent/sidebar-model.ts +235 -0
  68. package/src/agent/topic-inbox.ts +297 -0
  69. package/src/agent/topic-view-batcher.ts +134 -0
  70. package/src/client.ts +2331 -0
  71. package/src/doc-reconcile.ts +160 -0
  72. package/src/doc-uri.ts +83 -0
  73. package/src/endpoints.ts +14 -0
  74. package/src/jwt.ts +59 -0
  75. package/src/library-publisher.ts +93 -0
  76. package/src/merge3.ts +326 -0
  77. package/src/names.ts +44 -0
  78. package/src/shared/emoji-data.ts +8868 -0
  79. package/src/shared/mentions.ts +32 -0
  80. package/src/shared/rooms-protocol.ts +1339 -0
  81. package/src/social-client.ts +1287 -0
  82. package/src/social-types.ts +376 -0
  83. package/src/suggestion-attribution.ts +83 -0
  84. package/src/topic-uri.ts +64 -0
  85. package/src/types.ts +83 -0
  86. package/src/version.ts +2 -0
  87. package/src/ws-client.ts +611 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The context spine for agent-native rooms.
3
+ *
4
+ * Two jobs, both about spending tokens on the ask and not on the room's whole
5
+ * history:
6
+ * - resolve a conversational scope ("the last 200", "since 'ok new plan'",
7
+ * "since my last message") to a concrete history cursor the agent fetches;
8
+ * - render the per-room BRIEF (a generated, human-readable doc: purpose,
9
+ * running summary, open threads, decisions, and every doc the room refers to
10
+ * with its freshness) and assemble a turn's context in cheapest-first tiers,
11
+ * stopping at a budget so a turn never eats the ledger.
12
+ *
13
+ * Pure and dependency-free so every rule is unit-pinned; the runtime supplies
14
+ * the live history, the LLM summary, and the doc writes.
15
+ */
16
+
17
+ export interface HistoryEntry {
18
+ seq: number;
19
+ handle: string;
20
+ text: string;
21
+ /** Marks agent-authored posts in prompt renders (`@handle✦`). */
22
+ isAgent?: boolean;
23
+ /** The topic this message is filed into, when any ("" is the untopicked
24
+ * lane and reads as unfiled). Lets the lane seed a topic-scoped slice. */
25
+ topicId?: string;
26
+ /** A sealed (end-to-end encrypted) entry. Its plaintext never reaches this
27
+ * layer (the relay stores none); rendered as a locked placeholder so the
28
+ * agent knows one exists without any content leaking into context. */
29
+ sealed?: boolean;
30
+ }
31
+
32
+ /** Lane seeding: how many recent messages a lane turn carries room-wide when
33
+ * the whisper starts from the plain channel stream (today's behavior). */
34
+ export const LANE_ROOM_WINDOW = 30;
35
+ /** Lane seeding: how many of the topic's recent messages a lane turn carries
36
+ * when the whisper starts from inside a topic. */
37
+ export const LANE_TOPIC_WINDOW = 30;
38
+ /** Lane seeding: the smaller room-wide window that rides ALONGSIDE a topic
39
+ * slice so cross-topic references still resolve without a tool call. */
40
+ export const LANE_SCOPED_ROOM_WINDOW = 10;
41
+
42
+ /** The topic a lane turn is scoped to (the conversation the composer was in
43
+ * when the whisper started). An empty topicId is the untopicked lane and
44
+ * callers treat it as unscoped. */
45
+ export interface LaneTopicScope {
46
+ topicId: string;
47
+ name: string;
48
+ }
49
+
50
+ /**
51
+ * Slice the loaded ledger into the lane's seed windows. Unscoped (plain
52
+ * channel whisper): the room-wide recent window, exactly as before. Scoped
53
+ * (whisper from inside a topic): that topic's recent slice (what exists, up
54
+ * to the window) PLUS a smaller room-wide window for cross-topic references.
55
+ * Entries arrive pre-filtered (no deleted rows, no empty text), oldest first.
56
+ */
57
+ export function laneSeedWindows(
58
+ entries: ReadonlyArray<HistoryEntry>,
59
+ topic?: LaneTopicScope | null,
60
+ ): Pick<TieredContextInput, "recentWindow" | "topicWindow"> {
61
+ if (!topic?.topicId) {
62
+ return { recentWindow: renderRecentWindow(entries.slice(-LANE_ROOM_WINDOW)) };
63
+ }
64
+ const topicEntries = entries
65
+ .filter((entry) => entry.topicId === topic.topicId)
66
+ .slice(-LANE_TOPIC_WINDOW);
67
+ return {
68
+ recentWindow: renderRecentWindow(entries.slice(-LANE_SCOPED_ROOM_WINDOW)),
69
+ topicWindow: { name: topic.name, lines: renderRecentWindow(topicEntries) },
70
+ };
71
+ }
72
+
73
+ export type HistoryScope = (
74
+ | { kind: "count"; n: number }
75
+ | { kind: "sinceMarker"; marker: string }
76
+ | { kind: "sinceMyLast"; myHandle: string }
77
+ | { kind: "thread"; rootSeq: number }
78
+ ) & {
79
+ /** Restrict to one author's messages within the window ("from @henry"). */
80
+ author?: string;
81
+ };
82
+
83
+ export interface ResolvedScope {
84
+ /** Fetch messages with seq strictly greater than this (when present). */
85
+ sinceSeq?: number;
86
+ /** Cap the number of messages (when present). */
87
+ limit?: number;
88
+ /** For a thread scope: the root message to expand. */
89
+ threadRootSeq?: number;
90
+ /** Only this handle's messages count; the seq window itself is unchanged. */
91
+ author?: string;
92
+ /** A human label for the change note / prompt ("since 'ok new plan'"). */
93
+ label: string;
94
+ }
95
+
96
+ /** Parse a free-text scope argument into a structured scope. Accepts a bare
97
+ * count ("200"), `last N`, `since <text or "quoted marker">`, `since me` /
98
+ * `since my last`, or `thread <seq>`. A leading `from @handle` scopes to one
99
+ * author and composes with the rest ("from @henry", "from @henry last 100",
100
+ * "from @henry since 'ok new plan'"). Defaults to a recent count. */
101
+ export function parseHistoryScope(arg: string | undefined, myHandle: string): HistoryScope {
102
+ const raw = (arg ?? "").trim();
103
+ if (raw.length === 0) return { kind: "count", n: 50 };
104
+
105
+ const from = /^from\s+@([\w.-]+)\s*/i.exec(raw);
106
+ if (from?.[1]) {
107
+ return { ...parseHistoryScope(raw.slice(from[0].length), myHandle), author: from[1] };
108
+ }
109
+
110
+ const lower = raw.toLowerCase();
111
+ const countOnly = /^(?:last\s+)?(\d{1,4})$/.exec(lower);
112
+ if (countOnly?.[1]) return { kind: "count", n: Number(countOnly[1]) };
113
+
114
+ if (lower === "since me" || lower === "since my last" || lower === "since my last message") {
115
+ return { kind: "sinceMyLast", myHandle };
116
+ }
117
+ const thread = /^thread\s+(\d{1,9})$/.exec(lower);
118
+ if (thread?.[1]) return { kind: "thread", rootSeq: Number(thread[1]) };
119
+
120
+ const since = /^since\s+(.+)$/i.exec(raw);
121
+ if (since?.[1]) {
122
+ const marker = since[1].trim().replace(/^["']|["']$/g, "");
123
+ return { kind: "sinceMarker", marker };
124
+ }
125
+ // A bare phrase is treated as a marker to find.
126
+ return { kind: "sinceMarker", marker: raw.replace(/^["']|["']$/g, "") };
127
+ }
128
+
129
+ /** Resolve a scope against the room's recent entries. Returns null when a
130
+ * marker or my-last-message cannot be found (the caller reports it). An
131
+ * author filter rides through: the window resolves the same way, and the
132
+ * caller keeps only that handle's messages inside it. */
133
+ export function resolveHistoryScope(
134
+ scope: HistoryScope,
135
+ entries: ReadonlyArray<HistoryEntry>,
136
+ ): ResolvedScope | null {
137
+ const base = resolveScopeWindow(scope, entries);
138
+ if (!base || !scope.author) return base;
139
+ const author = scope.author.replace(/^@/, "");
140
+ return { ...base, author, label: `${base.label} from @${author}` };
141
+ }
142
+
143
+ function resolveScopeWindow(
144
+ scope: HistoryScope,
145
+ entries: ReadonlyArray<HistoryEntry>,
146
+ ): ResolvedScope | null {
147
+ switch (scope.kind) {
148
+ case "count":
149
+ return { limit: Math.max(1, Math.min(scope.n, 500)), label: `the last ${scope.n}` };
150
+ case "thread":
151
+ return { threadRootSeq: scope.rootSeq, label: `thread at ${scope.rootSeq}` };
152
+ case "sinceMyLast": {
153
+ const mine = [...entries]
154
+ .filter((e) => e.handle.replace(/^@/, "") === scope.myHandle.replace(/^@/, ""))
155
+ .sort((a, b) => a.seq - b.seq)
156
+ .at(-1);
157
+ if (!mine) return null;
158
+ return { sinceSeq: mine.seq, label: "since your last message" };
159
+ }
160
+ case "sinceMarker": {
161
+ const needle = scope.marker.toLowerCase();
162
+ if (needle.length === 0) return null;
163
+ // The LAST message that matches, so "since 'X'" starts at the most recent
164
+ // occurrence, not an older stray mention.
165
+ const match = [...entries]
166
+ .sort((a, b) => a.seq - b.seq)
167
+ .reverse()
168
+ .find((e) => e.text.toLowerCase().includes(needle));
169
+ if (!match) return null;
170
+ // Inclusive of the marker message itself: fetch seq > (markerSeq - 1).
171
+ return { sinceSeq: match.seq - 1, label: `since "${scope.marker}"` };
172
+ }
173
+ }
174
+ }
175
+
176
+ /** `#btc` → `briefs/btc.md`. The brief for a room is a real doc in the space's
177
+ * library, so it rides the sync to disk and can be linked and read like any
178
+ * other note. */
179
+ export function briefPath(roomName: string): string {
180
+ const slug =
181
+ roomName
182
+ .trim()
183
+ .replace(/^#/, "")
184
+ .toLowerCase()
185
+ .replace(/[^a-z0-9]+/g, "-")
186
+ .replace(/^-+|-+$/g, "")
187
+ .slice(0, 60) || "room";
188
+ return `briefs/${slug}.md`;
189
+ }
190
+
191
+ export interface BriefDocRef {
192
+ path: string;
193
+ headRev: number;
194
+ ageLabel: string;
195
+ }
196
+
197
+ export interface BriefInput {
198
+ room: string;
199
+ updatedAtLabel: string;
200
+ /** One or two paragraphs of running summary (LLM-written). */
201
+ summary: string;
202
+ openThreads: string[];
203
+ decisions: string[];
204
+ docs: BriefDocRef[];
205
+ }
206
+
207
+ /** Render the canonical brief markdown. Deterministic shell; the LLM only
208
+ * supplies the summary/threads/decisions prose. */
209
+ export function renderBrief(input: BriefInput): string {
210
+ const lines: string[] = [];
211
+ lines.push(`# Brief: ${input.room}`);
212
+ lines.push("");
213
+ lines.push(`> Auto-generated by the room agent, read-only. Updated ${input.updatedAtLabel}.`);
214
+ lines.push("");
215
+ lines.push("## Summary");
216
+ lines.push("");
217
+ lines.push(input.summary.trim() || "_No summary yet._");
218
+ lines.push("");
219
+ lines.push("## Open threads");
220
+ lines.push("");
221
+ if (input.openThreads.length === 0) lines.push("_None._");
222
+ else for (const t of input.openThreads) lines.push(`- ${t}`);
223
+ lines.push("");
224
+ lines.push("## Decisions");
225
+ lines.push("");
226
+ if (input.decisions.length === 0) lines.push("_None yet._");
227
+ else for (const d of input.decisions) lines.push(`- ${d}`);
228
+ lines.push("");
229
+ lines.push("## Docs in play");
230
+ lines.push("");
231
+ if (input.docs.length === 0) lines.push("_None referenced._");
232
+ else for (const d of input.docs) lines.push(`- \`${d.path}\` (r${d.headRev}, ${d.ageLabel})`);
233
+ lines.push("");
234
+ return lines.join("\n");
235
+ }
236
+
237
+ export interface TieredContextInput {
238
+ /** Tier 0: always included (tiny by design). */
239
+ brief?: string;
240
+ /** Tier 1: rolling compaction of older history, newest-relevant first. */
241
+ epochSummaries?: string[];
242
+ /** Tier 2a: the scoped topic's recent slice, oldest-to-newest, labeled with
243
+ * the topic's name. Present only for topic-scoped lane turns; it outranks
244
+ * the room-wide window in the budget because it IS the whisper's
245
+ * conversation. */
246
+ topicWindow?: { name: string; lines: string[] };
247
+ /** Tier 2 (2b when a topic slice rides above it): the recent verbatim
248
+ * window, oldest-to-newest. */
249
+ recentWindow?: string[];
250
+ }
251
+
252
+ export interface AssembledContext {
253
+ text: string;
254
+ includedTiers: Array<"brief" | "epochs" | "topic" | "recent">;
255
+ usedChars: number;
256
+ }
257
+
258
+ /**
259
+ * Assemble a turn's context cheapest-first within a character budget. The brief
260
+ * (tier 0) always goes in; epoch summaries (tier 1) and the recent window (tier
261
+ * 2) fill the remaining budget and STOP. This is the hard token-efficiency
262
+ * rule: a turn's cost scales with the budget, never with the room's history.
263
+ */
264
+ export function assembleTieredContext(
265
+ input: TieredContextInput,
266
+ budgetChars = 8000,
267
+ ): AssembledContext {
268
+ const parts: string[] = [];
269
+ const includedTiers: AssembledContext["includedTiers"] = [];
270
+ let used = 0;
271
+
272
+ const brief = input.brief?.trim();
273
+ if (brief) {
274
+ parts.push(`## Room brief\n${brief}`);
275
+ includedTiers.push("brief");
276
+ used += brief.length;
277
+ }
278
+
279
+ const epochs = input.epochSummaries ?? [];
280
+ if (epochs.length > 0 && used < budgetChars) {
281
+ const chosen: string[] = [];
282
+ for (const e of epochs) {
283
+ if (used + e.length > budgetChars) break;
284
+ chosen.push(e);
285
+ used += e.length;
286
+ }
287
+ if (chosen.length > 0) {
288
+ parts.push(`## Earlier context\n${chosen.join("\n")}`);
289
+ includedTiers.push("epochs");
290
+ }
291
+ }
292
+
293
+ // Freshest-first fill shared by both message tiers: take lines from the
294
+ // end (newest) until the budget fills, keeping oldest-to-newest order.
295
+ const fillFreshest = (lines: string[]): string[] => {
296
+ const chosen: string[] = [];
297
+ for (let i = lines.length - 1; i >= 0; i--) {
298
+ const line = lines[i] as string;
299
+ if (used + line.length > budgetChars) break;
300
+ chosen.unshift(line);
301
+ used += line.length;
302
+ }
303
+ return chosen;
304
+ };
305
+
306
+ const topic = input.topicWindow;
307
+ if (topic && topic.lines.length > 0 && used < budgetChars) {
308
+ const chosen = fillFreshest(topic.lines);
309
+ if (chosen.length > 0) {
310
+ parts.push(`## Recent messages in this topic: ⌗ ${topic.name}\n${chosen.join("\n")}`);
311
+ includedTiers.push("topic");
312
+ }
313
+ }
314
+
315
+ const recent = input.recentWindow ?? [];
316
+ if (recent.length > 0 && used < budgetChars) {
317
+ const chosen = fillFreshest(recent);
318
+ if (chosen.length > 0) {
319
+ // When a topic slice rides above, say plainly what this wider window is.
320
+ const header = input.topicWindow
321
+ ? "## Recent messages across the channel (all topics)"
322
+ : "## Recent messages";
323
+ parts.push(`${header}\n${chosen.join("\n")}`);
324
+ includedTiers.push("recent");
325
+ }
326
+ }
327
+
328
+ return { text: parts.join("\n\n"), includedTiers, usedChars: used };
329
+ }
330
+
331
+ /**
332
+ * Render history entries as prompt lines for the recent-window tier:
333
+ * `[seq] @handle: text`, one line per message, whitespace-flattened and
334
+ * clamped. The bracketed seq is the message's address: it is what
335
+ * om://msg/<room>/<seq> citations and room_history paging key on.
336
+ */
337
+ export function renderRecentWindow(
338
+ entries: ReadonlyArray<HistoryEntry>,
339
+ opts: { clampChars?: number } = {},
340
+ ): string[] {
341
+ const clamp = Math.max(16, opts.clampChars ?? 240);
342
+ return entries.map((entry) => {
343
+ const handle = entry.handle.replace(/^@+/, "") || "unknown";
344
+ if (entry.sealed) {
345
+ return `[${entry.seq}] @${handle}${entry.isAgent ? "✦" : ""}: 🔒 sealed message (encrypted; content not available)`;
346
+ }
347
+ const flat = entry.text.replace(/\s+/g, " ").trim();
348
+ const text = flat.length > clamp ? `${flat.slice(0, clamp - 1)}…` : flat;
349
+ return `[${entry.seq}] @${handle}${entry.isAgent ? "✦" : ""}: ${text}`;
350
+ });
351
+ }
@@ -0,0 +1,235 @@
1
+ import type { RoomMeta, RoomSpace } from "../types.js";
2
+
3
+ /**
4
+ * Navigation data for the Discord-style sidebar: rooms grouped by space,
5
+ * DM conversations, and a heat-ranked "popular" tail of unjoined rooms.
6
+ *
7
+ * Unread semantics:
8
+ * - Rooms the user has OPEN (a live context) are authoritative: the live
9
+ * context's counter is injected and always wins.
10
+ * - Unjoined rooms diff the server head sequence (headSeq, attached by the
11
+ * backend room lists) against the user's read cursor. Missing data means
12
+ * no badge, never a guess.
13
+ */
14
+ export interface SidebarRoom {
15
+ name: string;
16
+ title: string;
17
+ spaceId: string | null;
18
+ category: string | null;
19
+ sortOrder: number;
20
+ access: RoomMeta["access"];
21
+ online: number;
22
+ heatScore: number;
23
+ headSeq: number | null;
24
+ lastReadSeq: number | null;
25
+ unread: number;
26
+ joined: boolean;
27
+ /** Topics dial from room meta. Absent reads as "allowed" since the
28
+ * 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
29
+ topicsPolicy: RoomMeta["topicsPolicy"] | null;
30
+ }
31
+
32
+ export interface SidebarDm {
33
+ username: string;
34
+ unread: number;
35
+ }
36
+
37
+ export interface SidebarSnapshot {
38
+ spaces: Array<{ space: RoomSpace; rooms: SidebarRoom[] }>;
39
+ unspaced: SidebarRoom[];
40
+ dms: SidebarDm[];
41
+ popular: SidebarRoom[];
42
+ globalReplyUnread: number;
43
+ fetchedAt: number;
44
+ }
45
+
46
+ export const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot = {
47
+ spaces: [],
48
+ unspaced: [],
49
+ dms: [],
50
+ popular: [],
51
+ globalReplyUnread: 0,
52
+ fetchedAt: 0,
53
+ };
54
+
55
+ const POPULAR_MAX = 6;
56
+
57
+ export function computeRoomUnread(input: {
58
+ joinedUnread: number | null;
59
+ headSeq: number | null;
60
+ lastReadSeq: number | null;
61
+ }): number {
62
+ if (input.joinedUnread !== null) return Math.max(0, input.joinedUnread);
63
+ if (input.headSeq === null || input.headSeq <= 0) return 0;
64
+ if (input.lastReadSeq === null) return 0;
65
+ return Math.max(0, input.headSeq - input.lastReadSeq);
66
+ }
67
+
68
+ export function buildSidebarSnapshot(input: {
69
+ rooms: RoomMeta[];
70
+ spaces: RoomSpace[];
71
+ readState: Record<string, number>;
72
+ dms: SidebarDm[];
73
+ globalReplyUnread: number;
74
+ joinedUnread: (roomName: string) => number | null;
75
+ now: number;
76
+ }): SidebarSnapshot {
77
+ const toSidebarRoom = (room: RoomMeta): SidebarRoom => {
78
+ const name = room.name;
79
+ const joinedUnread = input.joinedUnread(name);
80
+ const headSeq = typeof room.headSeq === "number" ? room.headSeq : null;
81
+ const lastReadSeq = Object.hasOwn(input.readState, name) ? (input.readState[name] ?? 0) : null;
82
+ return {
83
+ name,
84
+ title: room.title || `#${name}`,
85
+ spaceId: room.spaceId ?? null,
86
+ category: room.category ?? null,
87
+ sortOrder: room.sortOrder ?? 0,
88
+ access: room.access,
89
+ online: room.onlineCount ?? room.heat?.onlineCount ?? 0,
90
+ heatScore: room.heat?.score ?? 0,
91
+ headSeq,
92
+ lastReadSeq,
93
+ unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
94
+ // Joined = an open context OR a server read cursor (you only get a
95
+ // cursor by having been in the room). This is what decides whether a
96
+ // room lives in your channel tree or in the POPULAR discovery shelf.
97
+ joined: joinedUnread !== null || lastReadSeq !== null,
98
+ topicsPolicy: room.topicsPolicy ?? null,
99
+ };
100
+ };
101
+
102
+ const rooms = input.rooms.map(toSidebarRoom);
103
+ const bySpace = new Map<string, SidebarRoom[]>();
104
+ const unspaced: SidebarRoom[] = [];
105
+ for (const room of rooms) {
106
+ if (room.spaceId) {
107
+ const list = bySpace.get(room.spaceId) ?? [];
108
+ list.push(room);
109
+ bySpace.set(room.spaceId, list);
110
+ } else {
111
+ unspaced.push(room);
112
+ }
113
+ }
114
+
115
+ const knownSpaces = new Map(input.spaces.map((space) => [space.spaceId, space]));
116
+ const spaces: SidebarSnapshot["spaces"] = [];
117
+ // Your servers render even when empty: a freshly created server must be
118
+ // visible and enterable immediately, not appear only once it has rooms.
119
+ for (const space of input.spaces) {
120
+ if (!space.joined) continue;
121
+ spaces.push({ space, rooms: bySpace.get(space.spaceId) ?? [] });
122
+ bySpace.delete(space.spaceId);
123
+ }
124
+ for (const [spaceId, spaceRooms] of bySpace) {
125
+ const space = knownSpaces.get(spaceId) ?? {
126
+ spaceId,
127
+ name: spaceId,
128
+ ownerUserId: "",
129
+ createdAt: "",
130
+ };
131
+ spaces.push({ space, rooms: spaceRooms });
132
+ }
133
+ // Discord-stable server order: creation time, never name. Names arrive
134
+ // late for spaces synthesized from room leftovers, and sorting by them
135
+ // made the rail reshuffle mid-session. Unknown createdAt sorts last,
136
+ // deterministically by id.
137
+ const createdKey = (space: RoomSpace): number => {
138
+ const at = new Date(space.createdAt).getTime();
139
+ return Number.isFinite(at) && at > 0 ? at : Number.MAX_SAFE_INTEGER;
140
+ };
141
+ spaces.sort(
142
+ (a, b) =>
143
+ createdKey(a.space) - createdKey(b.space) || a.space.spaceId.localeCompare(b.space.spaceId),
144
+ );
145
+
146
+ const popular = rooms
147
+ .filter((room) => !room.joined)
148
+ .sort((a, b) => b.heatScore - a.heatScore || b.online - a.online)
149
+ .slice(0, POPULAR_MAX);
150
+
151
+ return {
152
+ spaces,
153
+ unspaced,
154
+ dms: input.dms,
155
+ popular,
156
+ globalReplyUnread: Math.max(0, input.globalReplyUnread),
157
+ fetchedAt: input.now,
158
+ };
159
+ }
160
+
161
+ export interface SidebarModelDeps {
162
+ fetchRooms: () => Promise<RoomMeta[]>;
163
+ fetchSpaces: () => Promise<RoomSpace[]>;
164
+ fetchReadState: (roomNames: string[]) => Promise<Record<string, number>>;
165
+ fetchDms: () => Promise<SidebarDm[]>;
166
+ fetchGlobalReplyUnread: () => Promise<number>;
167
+ joinedUnread: (roomName: string) => number | null;
168
+ now?: () => number;
169
+ }
170
+
171
+ export class SidebarModel {
172
+ private snapshotValue: SidebarSnapshot = EMPTY_SIDEBAR_SNAPSHOT;
173
+ private inflight: Promise<SidebarSnapshot> | null = null;
174
+ private readonly listeners = new Set<() => void>();
175
+
176
+ constructor(private readonly deps: SidebarModelDeps) {}
177
+
178
+ snapshot(): SidebarSnapshot {
179
+ return this.snapshotValue;
180
+ }
181
+
182
+ onUpdate(listener: () => void): () => void {
183
+ this.listeners.add(listener);
184
+ return () => {
185
+ this.listeners.delete(listener);
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Post-mutation refresh: if a poll-triggered load is already in flight it
191
+ * may predate the caller's writes, so wait it out and load AGAIN. Readers
192
+ * keep using the coalescing refresh(); writers use this barrier.
193
+ */
194
+ async refreshAfterWrite(): Promise<SidebarSnapshot> {
195
+ if (this.inflight) await this.inflight.catch(() => undefined);
196
+ return this.refresh();
197
+ }
198
+
199
+ /** Single-flight refresh; failures keep the previous snapshot. */
200
+ refresh(): Promise<SidebarSnapshot> {
201
+ if (this.inflight) return this.inflight;
202
+ this.inflight = this.load()
203
+ .then((snapshot) => {
204
+ this.snapshotValue = snapshot;
205
+ for (const listener of this.listeners) listener();
206
+ return snapshot;
207
+ })
208
+ .catch(() => this.snapshotValue)
209
+ .finally(() => {
210
+ this.inflight = null;
211
+ }) as Promise<SidebarSnapshot>;
212
+ return this.inflight;
213
+ }
214
+
215
+ private async load(): Promise<SidebarSnapshot> {
216
+ const [rooms, spaces, dms, globalReplyUnread] = await Promise.all([
217
+ this.deps.fetchRooms(),
218
+ this.deps.fetchSpaces().catch(() => [] as RoomSpace[]),
219
+ this.deps.fetchDms().catch(() => [] as SidebarDm[]),
220
+ this.deps.fetchGlobalReplyUnread().catch(() => 0),
221
+ ]);
222
+ const readState = await this.deps
223
+ .fetchReadState(rooms.map((room) => room.name))
224
+ .catch(() => ({}) as Record<string, number>);
225
+ return buildSidebarSnapshot({
226
+ rooms,
227
+ spaces,
228
+ readState,
229
+ dms,
230
+ globalReplyUnread,
231
+ joinedUnread: this.deps.joinedUnread,
232
+ now: (this.deps.now ?? Date.now)(),
233
+ });
234
+ }
235
+ }