@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.
- package/LICENSE +201 -0
- package/README.md +25 -0
- package/dist/agent/armed-mode.d.ts +187 -0
- package/dist/agent/armed-mode.js +306 -0
- package/dist/agent/clients/common.d.ts +70 -0
- package/dist/agent/clients/common.js +46 -0
- package/dist/agent/lane-draft.d.ts +15 -0
- package/dist/agent/lane-draft.js +43 -0
- package/dist/agent/lane-prompts.d.ts +102 -0
- package/dist/agent/lane-prompts.js +120 -0
- package/dist/agent/library-context.d.ts +38 -0
- package/dist/agent/library-context.js +57 -0
- package/dist/agent/library-model.d.ts +41 -0
- package/dist/agent/library-model.js +173 -0
- package/dist/agent/room-context.d.ts +151 -0
- package/dist/agent/room-context.js +251 -0
- package/dist/agent/sidebar-model.d.ts +86 -0
- package/dist/agent/sidebar-model.js +162 -0
- package/dist/agent/topic-inbox.d.ts +119 -0
- package/dist/agent/topic-inbox.js +266 -0
- package/dist/agent/topic-view-batcher.d.ts +54 -0
- package/dist/agent/topic-view-batcher.js +115 -0
- package/dist/client.d.ts +421 -0
- package/dist/client.js +1428 -0
- package/dist/doc-reconcile.d.ts +100 -0
- package/dist/doc-reconcile.js +110 -0
- package/dist/doc-uri.d.ts +29 -0
- package/dist/doc-uri.js +55 -0
- package/dist/endpoints.d.ts +9 -0
- package/dist/endpoints.js +13 -0
- package/dist/jwt.d.ts +10 -0
- package/dist/jwt.js +51 -0
- package/dist/library-publisher.d.ts +52 -0
- package/dist/library-publisher.js +49 -0
- package/dist/merge3.d.ts +50 -0
- package/dist/merge3.js +280 -0
- package/dist/names.d.ts +6 -0
- package/dist/names.js +35 -0
- package/dist/shared/emoji-data.d.ts +22 -0
- package/dist/shared/emoji-data.js +8834 -0
- package/dist/shared/mentions.d.ts +6 -0
- package/dist/shared/mentions.js +32 -0
- package/dist/shared/rooms-protocol.d.ts +1183 -0
- package/dist/shared/rooms-protocol.js +160 -0
- package/dist/social-client.d.ts +361 -0
- package/dist/social-client.js +686 -0
- package/dist/social-types.d.ts +338 -0
- package/dist/social-types.js +1 -0
- package/dist/suggestion-attribution.d.ts +10 -0
- package/dist/suggestion-attribution.js +56 -0
- package/dist/topic-uri.d.ts +26 -0
- package/dist/topic-uri.js +40 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/ws-client.d.ts +115 -0
- package/dist/ws-client.js +491 -0
- package/package.json +180 -0
- package/src/agent/armed-mode.ts +368 -0
- package/src/agent/clients/common.ts +91 -0
- package/src/agent/lane-draft.ts +47 -0
- package/src/agent/lane-prompts.ts +267 -0
- package/src/agent/library-context.ts +97 -0
- package/src/agent/library-model.ts +210 -0
- package/src/agent/room-context.ts +351 -0
- package/src/agent/sidebar-model.ts +235 -0
- package/src/agent/topic-inbox.ts +297 -0
- package/src/agent/topic-view-batcher.ts +134 -0
- package/src/client.ts +2331 -0
- package/src/doc-reconcile.ts +160 -0
- package/src/doc-uri.ts +83 -0
- package/src/endpoints.ts +14 -0
- package/src/jwt.ts +59 -0
- package/src/library-publisher.ts +93 -0
- package/src/merge3.ts +326 -0
- package/src/names.ts +44 -0
- package/src/shared/emoji-data.ts +8868 -0
- package/src/shared/mentions.ts +32 -0
- package/src/shared/rooms-protocol.ts +1339 -0
- package/src/social-client.ts +1287 -0
- package/src/social-types.ts +376 -0
- package/src/suggestion-attribution.ts +83 -0
- package/src/topic-uri.ts +64 -0
- package/src/types.ts +83 -0
- package/src/version.ts +2 -0
- 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
|
+
}
|