@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,251 @@
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
+ /** Lane seeding: how many recent messages a lane turn carries room-wide when
17
+ * the whisper starts from the plain channel stream (today's behavior). */
18
+ export const LANE_ROOM_WINDOW = 30;
19
+ /** Lane seeding: how many of the topic's recent messages a lane turn carries
20
+ * when the whisper starts from inside a topic. */
21
+ export const LANE_TOPIC_WINDOW = 30;
22
+ /** Lane seeding: the smaller room-wide window that rides ALONGSIDE a topic
23
+ * slice so cross-topic references still resolve without a tool call. */
24
+ export const LANE_SCOPED_ROOM_WINDOW = 10;
25
+ /**
26
+ * Slice the loaded ledger into the lane's seed windows. Unscoped (plain
27
+ * channel whisper): the room-wide recent window, exactly as before. Scoped
28
+ * (whisper from inside a topic): that topic's recent slice (what exists, up
29
+ * to the window) PLUS a smaller room-wide window for cross-topic references.
30
+ * Entries arrive pre-filtered (no deleted rows, no empty text), oldest first.
31
+ */
32
+ export function laneSeedWindows(entries, topic) {
33
+ if (!topic?.topicId) {
34
+ return { recentWindow: renderRecentWindow(entries.slice(-LANE_ROOM_WINDOW)) };
35
+ }
36
+ const topicEntries = entries
37
+ .filter((entry) => entry.topicId === topic.topicId)
38
+ .slice(-LANE_TOPIC_WINDOW);
39
+ return {
40
+ recentWindow: renderRecentWindow(entries.slice(-LANE_SCOPED_ROOM_WINDOW)),
41
+ topicWindow: { name: topic.name, lines: renderRecentWindow(topicEntries) },
42
+ };
43
+ }
44
+ /** Parse a free-text scope argument into a structured scope. Accepts a bare
45
+ * count ("200"), `last N`, `since <text or "quoted marker">`, `since me` /
46
+ * `since my last`, or `thread <seq>`. A leading `from @handle` scopes to one
47
+ * author and composes with the rest ("from @henry", "from @henry last 100",
48
+ * "from @henry since 'ok new plan'"). Defaults to a recent count. */
49
+ export function parseHistoryScope(arg, myHandle) {
50
+ const raw = (arg ?? "").trim();
51
+ if (raw.length === 0)
52
+ return { kind: "count", n: 50 };
53
+ const from = /^from\s+@([\w.-]+)\s*/i.exec(raw);
54
+ if (from?.[1]) {
55
+ return { ...parseHistoryScope(raw.slice(from[0].length), myHandle), author: from[1] };
56
+ }
57
+ const lower = raw.toLowerCase();
58
+ const countOnly = /^(?:last\s+)?(\d{1,4})$/.exec(lower);
59
+ if (countOnly?.[1])
60
+ return { kind: "count", n: Number(countOnly[1]) };
61
+ if (lower === "since me" || lower === "since my last" || lower === "since my last message") {
62
+ return { kind: "sinceMyLast", myHandle };
63
+ }
64
+ const thread = /^thread\s+(\d{1,9})$/.exec(lower);
65
+ if (thread?.[1])
66
+ return { kind: "thread", rootSeq: Number(thread[1]) };
67
+ const since = /^since\s+(.+)$/i.exec(raw);
68
+ if (since?.[1]) {
69
+ const marker = since[1].trim().replace(/^["']|["']$/g, "");
70
+ return { kind: "sinceMarker", marker };
71
+ }
72
+ // A bare phrase is treated as a marker to find.
73
+ return { kind: "sinceMarker", marker: raw.replace(/^["']|["']$/g, "") };
74
+ }
75
+ /** Resolve a scope against the room's recent entries. Returns null when a
76
+ * marker or my-last-message cannot be found (the caller reports it). An
77
+ * author filter rides through: the window resolves the same way, and the
78
+ * caller keeps only that handle's messages inside it. */
79
+ export function resolveHistoryScope(scope, entries) {
80
+ const base = resolveScopeWindow(scope, entries);
81
+ if (!base || !scope.author)
82
+ return base;
83
+ const author = scope.author.replace(/^@/, "");
84
+ return { ...base, author, label: `${base.label} from @${author}` };
85
+ }
86
+ function resolveScopeWindow(scope, entries) {
87
+ switch (scope.kind) {
88
+ case "count":
89
+ return { limit: Math.max(1, Math.min(scope.n, 500)), label: `the last ${scope.n}` };
90
+ case "thread":
91
+ return { threadRootSeq: scope.rootSeq, label: `thread at ${scope.rootSeq}` };
92
+ case "sinceMyLast": {
93
+ const mine = [...entries]
94
+ .filter((e) => e.handle.replace(/^@/, "") === scope.myHandle.replace(/^@/, ""))
95
+ .sort((a, b) => a.seq - b.seq)
96
+ .at(-1);
97
+ if (!mine)
98
+ return null;
99
+ return { sinceSeq: mine.seq, label: "since your last message" };
100
+ }
101
+ case "sinceMarker": {
102
+ const needle = scope.marker.toLowerCase();
103
+ if (needle.length === 0)
104
+ return null;
105
+ // The LAST message that matches, so "since 'X'" starts at the most recent
106
+ // occurrence, not an older stray mention.
107
+ const match = [...entries]
108
+ .sort((a, b) => a.seq - b.seq)
109
+ .reverse()
110
+ .find((e) => e.text.toLowerCase().includes(needle));
111
+ if (!match)
112
+ return null;
113
+ // Inclusive of the marker message itself: fetch seq > (markerSeq - 1).
114
+ return { sinceSeq: match.seq - 1, label: `since "${scope.marker}"` };
115
+ }
116
+ }
117
+ }
118
+ /** `#btc` → `briefs/btc.md`. The brief for a room is a real doc in the space's
119
+ * library, so it rides the sync to disk and can be linked and read like any
120
+ * other note. */
121
+ export function briefPath(roomName) {
122
+ const slug = roomName
123
+ .trim()
124
+ .replace(/^#/, "")
125
+ .toLowerCase()
126
+ .replace(/[^a-z0-9]+/g, "-")
127
+ .replace(/^-+|-+$/g, "")
128
+ .slice(0, 60) || "room";
129
+ return `briefs/${slug}.md`;
130
+ }
131
+ /** Render the canonical brief markdown. Deterministic shell; the LLM only
132
+ * supplies the summary/threads/decisions prose. */
133
+ export function renderBrief(input) {
134
+ const lines = [];
135
+ lines.push(`# Brief: ${input.room}`);
136
+ lines.push("");
137
+ lines.push(`> Auto-generated by the room agent, read-only. Updated ${input.updatedAtLabel}.`);
138
+ lines.push("");
139
+ lines.push("## Summary");
140
+ lines.push("");
141
+ lines.push(input.summary.trim() || "_No summary yet._");
142
+ lines.push("");
143
+ lines.push("## Open threads");
144
+ lines.push("");
145
+ if (input.openThreads.length === 0)
146
+ lines.push("_None._");
147
+ else
148
+ for (const t of input.openThreads)
149
+ lines.push(`- ${t}`);
150
+ lines.push("");
151
+ lines.push("## Decisions");
152
+ lines.push("");
153
+ if (input.decisions.length === 0)
154
+ lines.push("_None yet._");
155
+ else
156
+ for (const d of input.decisions)
157
+ lines.push(`- ${d}`);
158
+ lines.push("");
159
+ lines.push("## Docs in play");
160
+ lines.push("");
161
+ if (input.docs.length === 0)
162
+ lines.push("_None referenced._");
163
+ else
164
+ for (const d of input.docs)
165
+ lines.push(`- \`${d.path}\` (r${d.headRev}, ${d.ageLabel})`);
166
+ lines.push("");
167
+ return lines.join("\n");
168
+ }
169
+ /**
170
+ * Assemble a turn's context cheapest-first within a character budget. The brief
171
+ * (tier 0) always goes in; epoch summaries (tier 1) and the recent window (tier
172
+ * 2) fill the remaining budget and STOP. This is the hard token-efficiency
173
+ * rule: a turn's cost scales with the budget, never with the room's history.
174
+ */
175
+ export function assembleTieredContext(input, budgetChars = 8000) {
176
+ const parts = [];
177
+ const includedTiers = [];
178
+ let used = 0;
179
+ const brief = input.brief?.trim();
180
+ if (brief) {
181
+ parts.push(`## Room brief\n${brief}`);
182
+ includedTiers.push("brief");
183
+ used += brief.length;
184
+ }
185
+ const epochs = input.epochSummaries ?? [];
186
+ if (epochs.length > 0 && used < budgetChars) {
187
+ const chosen = [];
188
+ for (const e of epochs) {
189
+ if (used + e.length > budgetChars)
190
+ break;
191
+ chosen.push(e);
192
+ used += e.length;
193
+ }
194
+ if (chosen.length > 0) {
195
+ parts.push(`## Earlier context\n${chosen.join("\n")}`);
196
+ includedTiers.push("epochs");
197
+ }
198
+ }
199
+ // Freshest-first fill shared by both message tiers: take lines from the
200
+ // end (newest) until the budget fills, keeping oldest-to-newest order.
201
+ const fillFreshest = (lines) => {
202
+ const chosen = [];
203
+ for (let i = lines.length - 1; i >= 0; i--) {
204
+ const line = lines[i];
205
+ if (used + line.length > budgetChars)
206
+ break;
207
+ chosen.unshift(line);
208
+ used += line.length;
209
+ }
210
+ return chosen;
211
+ };
212
+ const topic = input.topicWindow;
213
+ if (topic && topic.lines.length > 0 && used < budgetChars) {
214
+ const chosen = fillFreshest(topic.lines);
215
+ if (chosen.length > 0) {
216
+ parts.push(`## Recent messages in this topic: ⌗ ${topic.name}\n${chosen.join("\n")}`);
217
+ includedTiers.push("topic");
218
+ }
219
+ }
220
+ const recent = input.recentWindow ?? [];
221
+ if (recent.length > 0 && used < budgetChars) {
222
+ const chosen = fillFreshest(recent);
223
+ if (chosen.length > 0) {
224
+ // When a topic slice rides above, say plainly what this wider window is.
225
+ const header = input.topicWindow
226
+ ? "## Recent messages across the channel (all topics)"
227
+ : "## Recent messages";
228
+ parts.push(`${header}\n${chosen.join("\n")}`);
229
+ includedTiers.push("recent");
230
+ }
231
+ }
232
+ return { text: parts.join("\n\n"), includedTiers, usedChars: used };
233
+ }
234
+ /**
235
+ * Render history entries as prompt lines for the recent-window tier:
236
+ * `[seq] @handle: text`, one line per message, whitespace-flattened and
237
+ * clamped. The bracketed seq is the message's address: it is what
238
+ * om://msg/<room>/<seq> citations and room_history paging key on.
239
+ */
240
+ export function renderRecentWindow(entries, opts = {}) {
241
+ const clamp = Math.max(16, opts.clampChars ?? 240);
242
+ return entries.map((entry) => {
243
+ const handle = entry.handle.replace(/^@+/, "") || "unknown";
244
+ if (entry.sealed) {
245
+ return `[${entry.seq}] @${handle}${entry.isAgent ? "✦" : ""}: 🔒 sealed message (encrypted; content not available)`;
246
+ }
247
+ const flat = entry.text.replace(/\s+/g, " ").trim();
248
+ const text = flat.length > clamp ? `${flat.slice(0, clamp - 1)}…` : flat;
249
+ return `[${entry.seq}] @${handle}${entry.isAgent ? "✦" : ""}: ${text}`;
250
+ });
251
+ }
@@ -0,0 +1,86 @@
1
+ import type { RoomMeta, RoomSpace } from "../types.js";
2
+ /**
3
+ * Navigation data for the Discord-style sidebar: rooms grouped by space,
4
+ * DM conversations, and a heat-ranked "popular" tail of unjoined rooms.
5
+ *
6
+ * Unread semantics:
7
+ * - Rooms the user has OPEN (a live context) are authoritative: the live
8
+ * context's counter is injected and always wins.
9
+ * - Unjoined rooms diff the server head sequence (headSeq, attached by the
10
+ * backend room lists) against the user's read cursor. Missing data means
11
+ * no badge, never a guess.
12
+ */
13
+ export interface SidebarRoom {
14
+ name: string;
15
+ title: string;
16
+ spaceId: string | null;
17
+ category: string | null;
18
+ sortOrder: number;
19
+ access: RoomMeta["access"];
20
+ online: number;
21
+ heatScore: number;
22
+ headSeq: number | null;
23
+ lastReadSeq: number | null;
24
+ unread: number;
25
+ joined: boolean;
26
+ /** Topics dial from room meta. Absent reads as "allowed" since the
27
+ * 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
28
+ topicsPolicy: RoomMeta["topicsPolicy"] | null;
29
+ }
30
+ export interface SidebarDm {
31
+ username: string;
32
+ unread: number;
33
+ }
34
+ export interface SidebarSnapshot {
35
+ spaces: Array<{
36
+ space: RoomSpace;
37
+ rooms: SidebarRoom[];
38
+ }>;
39
+ unspaced: SidebarRoom[];
40
+ dms: SidebarDm[];
41
+ popular: SidebarRoom[];
42
+ globalReplyUnread: number;
43
+ fetchedAt: number;
44
+ }
45
+ export declare const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot;
46
+ export declare function computeRoomUnread(input: {
47
+ joinedUnread: number | null;
48
+ headSeq: number | null;
49
+ lastReadSeq: number | null;
50
+ }): number;
51
+ export declare function buildSidebarSnapshot(input: {
52
+ rooms: RoomMeta[];
53
+ spaces: RoomSpace[];
54
+ readState: Record<string, number>;
55
+ dms: SidebarDm[];
56
+ globalReplyUnread: number;
57
+ joinedUnread: (roomName: string) => number | null;
58
+ now: number;
59
+ }): SidebarSnapshot;
60
+ export interface SidebarModelDeps {
61
+ fetchRooms: () => Promise<RoomMeta[]>;
62
+ fetchSpaces: () => Promise<RoomSpace[]>;
63
+ fetchReadState: (roomNames: string[]) => Promise<Record<string, number>>;
64
+ fetchDms: () => Promise<SidebarDm[]>;
65
+ fetchGlobalReplyUnread: () => Promise<number>;
66
+ joinedUnread: (roomName: string) => number | null;
67
+ now?: () => number;
68
+ }
69
+ export declare class SidebarModel {
70
+ private readonly deps;
71
+ private snapshotValue;
72
+ private inflight;
73
+ private readonly listeners;
74
+ constructor(deps: SidebarModelDeps);
75
+ snapshot(): SidebarSnapshot;
76
+ onUpdate(listener: () => void): () => void;
77
+ /**
78
+ * Post-mutation refresh: if a poll-triggered load is already in flight it
79
+ * may predate the caller's writes, so wait it out and load AGAIN. Readers
80
+ * keep using the coalescing refresh(); writers use this barrier.
81
+ */
82
+ refreshAfterWrite(): Promise<SidebarSnapshot>;
83
+ /** Single-flight refresh; failures keep the previous snapshot. */
84
+ refresh(): Promise<SidebarSnapshot>;
85
+ private load;
86
+ }
@@ -0,0 +1,162 @@
1
+ export const EMPTY_SIDEBAR_SNAPSHOT = {
2
+ spaces: [],
3
+ unspaced: [],
4
+ dms: [],
5
+ popular: [],
6
+ globalReplyUnread: 0,
7
+ fetchedAt: 0,
8
+ };
9
+ const POPULAR_MAX = 6;
10
+ export function computeRoomUnread(input) {
11
+ if (input.joinedUnread !== null)
12
+ return Math.max(0, input.joinedUnread);
13
+ if (input.headSeq === null || input.headSeq <= 0)
14
+ return 0;
15
+ if (input.lastReadSeq === null)
16
+ return 0;
17
+ return Math.max(0, input.headSeq - input.lastReadSeq);
18
+ }
19
+ export function buildSidebarSnapshot(input) {
20
+ const toSidebarRoom = (room) => {
21
+ const name = room.name;
22
+ const joinedUnread = input.joinedUnread(name);
23
+ const headSeq = typeof room.headSeq === "number" ? room.headSeq : null;
24
+ const lastReadSeq = Object.hasOwn(input.readState, name) ? (input.readState[name] ?? 0) : null;
25
+ return {
26
+ name,
27
+ title: room.title || `#${name}`,
28
+ spaceId: room.spaceId ?? null,
29
+ category: room.category ?? null,
30
+ sortOrder: room.sortOrder ?? 0,
31
+ access: room.access,
32
+ online: room.onlineCount ?? room.heat?.onlineCount ?? 0,
33
+ heatScore: room.heat?.score ?? 0,
34
+ headSeq,
35
+ lastReadSeq,
36
+ unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
37
+ // Joined = an open context OR a server read cursor (you only get a
38
+ // cursor by having been in the room). This is what decides whether a
39
+ // room lives in your channel tree or in the POPULAR discovery shelf.
40
+ joined: joinedUnread !== null || lastReadSeq !== null,
41
+ topicsPolicy: room.topicsPolicy ?? null,
42
+ };
43
+ };
44
+ const rooms = input.rooms.map(toSidebarRoom);
45
+ const bySpace = new Map();
46
+ const unspaced = [];
47
+ for (const room of rooms) {
48
+ if (room.spaceId) {
49
+ const list = bySpace.get(room.spaceId) ?? [];
50
+ list.push(room);
51
+ bySpace.set(room.spaceId, list);
52
+ }
53
+ else {
54
+ unspaced.push(room);
55
+ }
56
+ }
57
+ const knownSpaces = new Map(input.spaces.map((space) => [space.spaceId, space]));
58
+ const spaces = [];
59
+ // Your servers render even when empty: a freshly created server must be
60
+ // visible and enterable immediately, not appear only once it has rooms.
61
+ for (const space of input.spaces) {
62
+ if (!space.joined)
63
+ continue;
64
+ spaces.push({ space, rooms: bySpace.get(space.spaceId) ?? [] });
65
+ bySpace.delete(space.spaceId);
66
+ }
67
+ for (const [spaceId, spaceRooms] of bySpace) {
68
+ const space = knownSpaces.get(spaceId) ?? {
69
+ spaceId,
70
+ name: spaceId,
71
+ ownerUserId: "",
72
+ createdAt: "",
73
+ };
74
+ spaces.push({ space, rooms: spaceRooms });
75
+ }
76
+ // Discord-stable server order: creation time, never name. Names arrive
77
+ // late for spaces synthesized from room leftovers, and sorting by them
78
+ // made the rail reshuffle mid-session. Unknown createdAt sorts last,
79
+ // deterministically by id.
80
+ const createdKey = (space) => {
81
+ const at = new Date(space.createdAt).getTime();
82
+ return Number.isFinite(at) && at > 0 ? at : Number.MAX_SAFE_INTEGER;
83
+ };
84
+ spaces.sort((a, b) => createdKey(a.space) - createdKey(b.space) || a.space.spaceId.localeCompare(b.space.spaceId));
85
+ const popular = rooms
86
+ .filter((room) => !room.joined)
87
+ .sort((a, b) => b.heatScore - a.heatScore || b.online - a.online)
88
+ .slice(0, POPULAR_MAX);
89
+ return {
90
+ spaces,
91
+ unspaced,
92
+ dms: input.dms,
93
+ popular,
94
+ globalReplyUnread: Math.max(0, input.globalReplyUnread),
95
+ fetchedAt: input.now,
96
+ };
97
+ }
98
+ export class SidebarModel {
99
+ deps;
100
+ snapshotValue = EMPTY_SIDEBAR_SNAPSHOT;
101
+ inflight = null;
102
+ listeners = new Set();
103
+ constructor(deps) {
104
+ this.deps = deps;
105
+ }
106
+ snapshot() {
107
+ return this.snapshotValue;
108
+ }
109
+ onUpdate(listener) {
110
+ this.listeners.add(listener);
111
+ return () => {
112
+ this.listeners.delete(listener);
113
+ };
114
+ }
115
+ /**
116
+ * Post-mutation refresh: if a poll-triggered load is already in flight it
117
+ * may predate the caller's writes, so wait it out and load AGAIN. Readers
118
+ * keep using the coalescing refresh(); writers use this barrier.
119
+ */
120
+ async refreshAfterWrite() {
121
+ if (this.inflight)
122
+ await this.inflight.catch(() => undefined);
123
+ return this.refresh();
124
+ }
125
+ /** Single-flight refresh; failures keep the previous snapshot. */
126
+ refresh() {
127
+ if (this.inflight)
128
+ return this.inflight;
129
+ this.inflight = this.load()
130
+ .then((snapshot) => {
131
+ this.snapshotValue = snapshot;
132
+ for (const listener of this.listeners)
133
+ listener();
134
+ return snapshot;
135
+ })
136
+ .catch(() => this.snapshotValue)
137
+ .finally(() => {
138
+ this.inflight = null;
139
+ });
140
+ return this.inflight;
141
+ }
142
+ async load() {
143
+ const [rooms, spaces, dms, globalReplyUnread] = await Promise.all([
144
+ this.deps.fetchRooms(),
145
+ this.deps.fetchSpaces().catch(() => []),
146
+ this.deps.fetchDms().catch(() => []),
147
+ this.deps.fetchGlobalReplyUnread().catch(() => 0),
148
+ ]);
149
+ const readState = await this.deps
150
+ .fetchReadState(rooms.map((room) => room.name))
151
+ .catch(() => ({}));
152
+ return buildSidebarSnapshot({
153
+ rooms,
154
+ spaces,
155
+ readState,
156
+ dms,
157
+ globalReplyUnread,
158
+ joinedUnread: this.deps.joinedUnread,
159
+ now: (this.deps.now ?? Date.now)(),
160
+ });
161
+ }
162
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The open-core topic model: one room's registry rows plus the viewer's
3
+ * per-bucket state (cursor, attention, unread), with the unread math,
4
+ * muted exclusions, merge roll-up, and the deterministic catch-up order
5
+ * that every surface (GUI store, TUI, inbox, keyboard nav) consumes
6
+ * instead of reimplementing semantics.
7
+ *
8
+ * A BUCKET is a topicId, or the reserved '' key (UNTOPICKED_BUCKET) for
9
+ * the room's untopicked linear messages, matching the durable store's
10
+ * reserved empty-string cursor row. Per the phase 2 contract the buckets
11
+ * are independent: reading the linear lane never marks a topic seen, and
12
+ * vice versa.
13
+ *
14
+ * This model is a pure state machine: no timers, no sockets, no writes.
15
+ * Cursor ACKs happen exclusively through the view batcher; the model only
16
+ * mirrors positions it is told about.
17
+ */
18
+ import type { RoomTopic, RoomTopicAttention, RoomTopicBucketState, RoomTopicsResultPayload } from "../shared/rooms-protocol.js";
19
+ /** The reserved bucket key for untopicked (linear) messages. */
20
+ export declare const UNTOPICKED_BUCKET = "";
21
+ export interface TopicInboxItem {
22
+ /** Bucket key: a topicId, or UNTOPICKED_BUCKET for the linear lane. */
23
+ key: string;
24
+ /** The registry row; undefined for the untopicked bucket. */
25
+ topic: RoomTopic | undefined;
26
+ state: RoomTopicBucketState;
27
+ /** Unread rolled up from merged-away alias buckets (survivors only). */
28
+ aliasUnread: number;
29
+ }
30
+ export interface TopicInboxEntry {
31
+ seq: number;
32
+ userId: string;
33
+ topicId?: string | undefined;
34
+ }
35
+ export declare class RoomTopicInbox {
36
+ readonly room: string;
37
+ /** True once a withState hydration has landed; counts are trustworthy. */
38
+ hydrated: boolean;
39
+ headSeq: number;
40
+ private readonly topics;
41
+ private readonly buckets;
42
+ constructor(room: string);
43
+ /** Replace registry rows and per-bucket state from a withState TOPICS
44
+ * reply. Idempotent; later live frames layer on top.
45
+ *
46
+ * Positions only move FORWARD: the server read can lag local truth by
47
+ * the ack debounce plus the relay's cursor write buffer, so a rehydrate
48
+ * (TOPIC_UPDATED / ENTRY_UPDATED are hot triggers) must never resurrect
49
+ * a just-cleared badge. Cursors and lastSeq merge as max(local, server);
50
+ * a bucket whose merged cursor reaches its lastSeq is read, whatever
51
+ * count the stale server row carried. */
52
+ hydrate(payload: Pick<RoomTopicsResultPayload, "topics" | "state" | "headSeq">): void;
53
+ topicFor(topicId: string): RoomTopic | undefined;
54
+ /** Follow a merged husk to its survivor (chains are flattened server-side
55
+ * to one hop). A live id, unknown id, or the untopicked key returns
56
+ * itself. */
57
+ survivorOf(key: string): string;
58
+ /** Merged-away buckets whose survivor is the given topic. */
59
+ aliasesOf(topicId: string): string[];
60
+ /** Registry rows in listed (creation) order, husks excluded. */
61
+ openTopics(options?: {
62
+ includeResolved?: boolean;
63
+ }): RoomTopic[];
64
+ bucketFor(key: string): RoomTopicBucketState;
65
+ /** Every bucket still carrying unread, muted and merged husks included:
66
+ * the explicit mark-channel-read verb clears ALL of them (a muted or
67
+ * merged bucket left unacked would resurrect on unmute/reopen). */
68
+ unreadBuckets(): Array<{
69
+ key: string;
70
+ lastSeq: number;
71
+ }>;
72
+ attentionFor(topicId: string): RoomTopicAttention;
73
+ /** A registry mutation arrived (TOPIC_UPDATED, or a verb result echo). */
74
+ applyTopicUpdated(topic: RoomTopic): void;
75
+ /**
76
+ * A live entry arrived. viewedKey is the bucket the viewer is CURRENTLY
77
+ * looking at (or null): entries landing in the viewed bucket advance the
78
+ * local cursor instead of counting unread (the durable ACK rides the
79
+ * view batcher separately). Own posts never count unread, and an own
80
+ * post into a topic mirrors the server's auto-follow (mute survives).
81
+ */
82
+ applyEntry(entry: TopicInboxEntry, context: {
83
+ selfUserId: string;
84
+ viewedKey?: string | null;
85
+ }): void;
86
+ /** Mirror an attention change (a TOPIC_SET_ATTENTION result, optimistic
87
+ * or confirmed). */
88
+ applyAttention(topicId: string, attention: RoomTopicAttention): void;
89
+ /**
90
+ * The viewer saw a bucket up to seq (the view batcher owns the durable
91
+ * ACK; this mirrors it locally). Catching up to the bucket head clears
92
+ * its count; a partial view keeps the residual count as an upper bound
93
+ * until the next hydration trues it up.
94
+ */
95
+ noteViewed(key: string, seq: number): void;
96
+ /**
97
+ * The channel-level unread badge for a topic-aware room: every non-muted
98
+ * bucket's count, untopicked and resolved included (new activity in a
99
+ * resolved topic still deserves attention), husks counted once (their
100
+ * unread also rolls into the survivor's inbox item for display).
101
+ */
102
+ channelUnread(): number;
103
+ /**
104
+ * The inbox, in THE deterministic catch-up order every surface shares:
105
+ * the untopicked lane first (the channel's main stream), then followed
106
+ * topics, then the rest; within each group oldest activity first
107
+ * (ascending lastSeq, topicId tie-break). Muted buckets never appear;
108
+ * merged husks roll their unread into the survivor's aliasUnread; a
109
+ * resolved topic appears only while it carries unread.
110
+ */
111
+ inboxItems(): TopicInboxItem[];
112
+ /**
113
+ * The next bucket a catch-up walk should visit: the first inbox item
114
+ * after the given key (wrapping), or null when nothing is unread. The
115
+ * walk order is inboxItems(); keyboard nav and the catch-up flow step
116
+ * through the same list a user sees.
117
+ */
118
+ nextUnread(afterKey?: string | null): string | null;
119
+ }