@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,266 @@
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
+ /** The reserved bucket key for untopicked (linear) messages. */
19
+ export const UNTOPICKED_BUCKET = "";
20
+ function emptyBucket() {
21
+ return { lastSeq: 0, unreadCount: 0, cursor: 0, attention: "default" };
22
+ }
23
+ export class RoomTopicInbox {
24
+ room;
25
+ /** True once a withState hydration has landed; counts are trustworthy. */
26
+ hydrated = false;
27
+ headSeq = 0;
28
+ topics = new Map();
29
+ buckets = new Map();
30
+ constructor(room) {
31
+ this.room = room;
32
+ }
33
+ /** Replace registry rows and per-bucket state from a withState TOPICS
34
+ * reply. Idempotent; later live frames layer on top.
35
+ *
36
+ * Positions only move FORWARD: the server read can lag local truth by
37
+ * the ack debounce plus the relay's cursor write buffer, so a rehydrate
38
+ * (TOPIC_UPDATED / ENTRY_UPDATED are hot triggers) must never resurrect
39
+ * a just-cleared badge. Cursors and lastSeq merge as max(local, server);
40
+ * a bucket whose merged cursor reaches its lastSeq is read, whatever
41
+ * count the stale server row carried. */
42
+ hydrate(payload) {
43
+ this.topics.clear();
44
+ for (const topic of payload.topics)
45
+ this.topics.set(topic.topicId, topic);
46
+ const previous = new Map(this.buckets);
47
+ this.buckets.clear();
48
+ for (const [key, state] of Object.entries(payload.state ?? {})) {
49
+ const local = previous.get(key);
50
+ const cursor = Math.max(state.cursor, local?.cursor ?? 0);
51
+ const lastSeq = Math.max(state.lastSeq, local?.lastSeq ?? 0);
52
+ this.buckets.set(key, {
53
+ ...state,
54
+ cursor,
55
+ lastSeq,
56
+ unreadCount: cursor >= lastSeq ? 0 : state.unreadCount,
57
+ });
58
+ }
59
+ if (!this.buckets.has(UNTOPICKED_BUCKET)) {
60
+ this.buckets.set(UNTOPICKED_BUCKET, emptyBucket());
61
+ }
62
+ this.headSeq = Math.max(this.headSeq, payload.headSeq ?? 0);
63
+ this.hydrated = true;
64
+ }
65
+ topicFor(topicId) {
66
+ return this.topics.get(topicId);
67
+ }
68
+ /** Follow a merged husk to its survivor (chains are flattened server-side
69
+ * to one hop). A live id, unknown id, or the untopicked key returns
70
+ * itself. */
71
+ survivorOf(key) {
72
+ if (key === UNTOPICKED_BUCKET)
73
+ return key;
74
+ const merged = this.topics.get(key)?.mergedInto;
75
+ return merged || key;
76
+ }
77
+ /** Merged-away buckets whose survivor is the given topic. */
78
+ aliasesOf(topicId) {
79
+ const aliases = [];
80
+ for (const topic of this.topics.values()) {
81
+ if (topic.mergedInto === topicId)
82
+ aliases.push(topic.topicId);
83
+ }
84
+ return aliases;
85
+ }
86
+ /** Registry rows in listed (creation) order, husks excluded. */
87
+ openTopics(options) {
88
+ const rows = [];
89
+ for (const topic of this.topics.values()) {
90
+ if (topic.mergedInto)
91
+ continue;
92
+ if (!options?.includeResolved && topic.resolved)
93
+ continue;
94
+ rows.push(topic);
95
+ }
96
+ return rows;
97
+ }
98
+ bucketFor(key) {
99
+ return this.buckets.get(key) ?? emptyBucket();
100
+ }
101
+ /** Every bucket still carrying unread, muted and merged husks included:
102
+ * the explicit mark-channel-read verb clears ALL of them (a muted or
103
+ * merged bucket left unacked would resurrect on unmute/reopen). */
104
+ unreadBuckets() {
105
+ const rows = [];
106
+ for (const [key, bucket] of this.buckets) {
107
+ if (bucket.unreadCount > 0 && bucket.lastSeq > bucket.cursor) {
108
+ rows.push({ key, lastSeq: bucket.lastSeq });
109
+ }
110
+ }
111
+ return rows;
112
+ }
113
+ attentionFor(topicId) {
114
+ return this.bucketFor(topicId).attention;
115
+ }
116
+ /** A registry mutation arrived (TOPIC_UPDATED, or a verb result echo). */
117
+ applyTopicUpdated(topic) {
118
+ this.topics.set(topic.topicId, topic);
119
+ if (!this.buckets.has(topic.topicId)) {
120
+ this.buckets.set(topic.topicId, emptyBucket());
121
+ }
122
+ }
123
+ /**
124
+ * A live entry arrived. viewedKey is the bucket the viewer is CURRENTLY
125
+ * looking at (or null): entries landing in the viewed bucket advance the
126
+ * local cursor instead of counting unread (the durable ACK rides the
127
+ * view batcher separately). Own posts never count unread, and an own
128
+ * post into a topic mirrors the server's auto-follow (mute survives).
129
+ */
130
+ applyEntry(entry, context) {
131
+ if (!Number.isFinite(entry.seq) || entry.seq <= 0)
132
+ return;
133
+ const key = entry.topicId || UNTOPICKED_BUCKET;
134
+ let bucket = this.buckets.get(key);
135
+ if (!bucket) {
136
+ bucket = emptyBucket();
137
+ this.buckets.set(key, bucket);
138
+ }
139
+ bucket.lastSeq = Math.max(bucket.lastSeq, entry.seq);
140
+ this.headSeq = Math.max(this.headSeq, entry.seq);
141
+ const own = entry.userId === context.selfUserId;
142
+ if (own) {
143
+ if (key !== UNTOPICKED_BUCKET && bucket.attention !== "mute") {
144
+ bucket.attention = "follow";
145
+ }
146
+ return;
147
+ }
148
+ if (context.viewedKey === key) {
149
+ bucket.cursor = Math.max(bucket.cursor, entry.seq);
150
+ return;
151
+ }
152
+ bucket.unreadCount += 1;
153
+ }
154
+ /** Mirror an attention change (a TOPIC_SET_ATTENTION result, optimistic
155
+ * or confirmed). */
156
+ applyAttention(topicId, attention) {
157
+ let bucket = this.buckets.get(topicId);
158
+ if (!bucket) {
159
+ bucket = emptyBucket();
160
+ this.buckets.set(topicId, bucket);
161
+ }
162
+ bucket.attention = attention;
163
+ }
164
+ /**
165
+ * The viewer saw a bucket up to seq (the view batcher owns the durable
166
+ * ACK; this mirrors it locally). Catching up to the bucket head clears
167
+ * its count; a partial view keeps the residual count as an upper bound
168
+ * until the next hydration trues it up.
169
+ */
170
+ noteViewed(key, seq) {
171
+ if (!Number.isFinite(seq) || seq <= 0)
172
+ return;
173
+ let bucket = this.buckets.get(key);
174
+ if (!bucket) {
175
+ bucket = emptyBucket();
176
+ this.buckets.set(key, bucket);
177
+ }
178
+ bucket.cursor = Math.max(bucket.cursor, seq);
179
+ if (bucket.cursor >= bucket.lastSeq)
180
+ bucket.unreadCount = 0;
181
+ }
182
+ /**
183
+ * The channel-level unread badge for a topic-aware room: every non-muted
184
+ * bucket's count, untopicked and resolved included (new activity in a
185
+ * resolved topic still deserves attention), husks counted once (their
186
+ * unread also rolls into the survivor's inbox item for display).
187
+ */
188
+ channelUnread() {
189
+ let total = 0;
190
+ for (const bucket of this.buckets.values()) {
191
+ if (bucket.attention === "mute")
192
+ continue;
193
+ total += bucket.unreadCount;
194
+ }
195
+ return total;
196
+ }
197
+ /**
198
+ * The inbox, in THE deterministic catch-up order every surface shares:
199
+ * the untopicked lane first (the channel's main stream), then followed
200
+ * topics, then the rest; within each group oldest activity first
201
+ * (ascending lastSeq, topicId tie-break). Muted buckets never appear;
202
+ * merged husks roll their unread into the survivor's aliasUnread; a
203
+ * resolved topic appears only while it carries unread.
204
+ */
205
+ inboxItems() {
206
+ const items = [];
207
+ const untopicked = this.bucketFor(UNTOPICKED_BUCKET);
208
+ if (untopicked.unreadCount > 0 && untopicked.attention !== "mute") {
209
+ items.push({
210
+ key: UNTOPICKED_BUCKET,
211
+ topic: undefined,
212
+ state: { ...untopicked },
213
+ aliasUnread: 0,
214
+ });
215
+ }
216
+ const grouped = [];
217
+ for (const topic of this.topics.values()) {
218
+ if (topic.mergedInto)
219
+ continue;
220
+ const state = this.bucketFor(topic.topicId);
221
+ if (state.attention === "mute")
222
+ continue;
223
+ let aliasUnread = 0;
224
+ for (const alias of this.aliasesOf(topic.topicId)) {
225
+ const aliasState = this.bucketFor(alias);
226
+ if (aliasState.attention === "mute")
227
+ continue;
228
+ aliasUnread += aliasState.unreadCount;
229
+ }
230
+ if (state.unreadCount + aliasUnread === 0)
231
+ continue;
232
+ grouped.push({ key: topic.topicId, topic, state: { ...state }, aliasUnread });
233
+ }
234
+ grouped.sort((a, b) => {
235
+ const aFollow = a.state.attention === "follow" ? 0 : 1;
236
+ const bFollow = b.state.attention === "follow" ? 0 : 1;
237
+ if (aFollow !== bFollow)
238
+ return aFollow - bFollow;
239
+ if (a.state.lastSeq !== b.state.lastSeq) {
240
+ return a.state.lastSeq - b.state.lastSeq;
241
+ }
242
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
243
+ });
244
+ items.push(...grouped);
245
+ return items;
246
+ }
247
+ /**
248
+ * The next bucket a catch-up walk should visit: the first inbox item
249
+ * after the given key (wrapping), or null when nothing is unread. The
250
+ * walk order is inboxItems(); keyboard nav and the catch-up flow step
251
+ * through the same list a user sees.
252
+ */
253
+ nextUnread(afterKey) {
254
+ const items = this.inboxItems();
255
+ if (items.length === 0)
256
+ return null;
257
+ if (afterKey === undefined || afterKey === null) {
258
+ return items[0]?.key ?? null;
259
+ }
260
+ const index = items.findIndex((item) => item.key === afterKey);
261
+ if (index < 0)
262
+ return items[0]?.key ?? null;
263
+ const next = items[(index + 1) % items.length];
264
+ return next && next.key !== afterKey ? next.key : null;
265
+ }
266
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * View-time cursor batching: the ONLY path from "a surface is showing
3
+ * messages" to a cursor ACK. Surfaces report views (beginView, noteSeq,
4
+ * endView); this batcher applies the locked phase 2 contract on top:
5
+ *
6
+ * - Dwell gating: a view must survive dwellMs before anything is acked, so
7
+ * a keyboard flash-through (next-unread skimming past a topic) never
8
+ * marks it read. "Marks read only after actual view time."
9
+ * - Batching: once dwelled, the highest seen seq forwards into the
10
+ * downstream ACK debouncer (one ACK frame per bucket per debounce
11
+ * window at most, relay-side write buffering below that). Per-message
12
+ * cursor writes are impossible by construction: the UI layer has no
13
+ * other ack path.
14
+ *
15
+ * A bucket key of '' (UNTOPICKED_BUCKET) acks the room-wide cursor; any
16
+ * other key acks that topic's cursor.
17
+ */
18
+ export interface TopicViewBatcherOptions {
19
+ /** Forward a dwelled view position; wire this to RoomAckDebouncer.schedule. */
20
+ scheduleAck: (room: string, seq: number, topicId?: string) => void;
21
+ /** Continuous view time required before the first ack (default 600ms). */
22
+ dwellMs?: number | undefined;
23
+ /** Injectable timers for tests. */
24
+ setTimeoutFn?: ((fn: () => void, ms: number) => unknown) | undefined;
25
+ clearTimeoutFn?: ((handle: unknown) => void) | undefined;
26
+ }
27
+ export declare class TopicViewBatcher {
28
+ private readonly scheduleAck;
29
+ private readonly dwellMs;
30
+ private readonly setTimeoutFn;
31
+ private readonly clearTimeoutFn;
32
+ private readonly sessions;
33
+ constructor(options: TopicViewBatcherOptions);
34
+ /** A surface started showing a bucket. Idempotent per (room, key). */
35
+ beginView(room: string, key: string): void;
36
+ /** The highest seq currently visible in a bucket the surface is showing.
37
+ * Before dwell it accumulates; after dwell it forwards (debounced
38
+ * downstream). Calls for a bucket not being viewed are ignored: only
39
+ * actual view time acks. */
40
+ noteSeq(room: string, key: string, seq: number): void;
41
+ /** The surface stopped showing the bucket. An un-dwelled view (a
42
+ * flash-through) is discarded without acking. */
43
+ endView(room: string, key: string): void;
44
+ /** True while the bucket is being viewed (dwelled or not). */
45
+ isViewing(room: string, key: string): boolean;
46
+ /** The bucket key currently viewed in a room, or null. */
47
+ viewedKey(room: string): string | null;
48
+ /** Force every dwelled view forward (window hide, disconnect boundary).
49
+ * Un-dwelled views stay pending: flushing must not turn a flash-through
50
+ * into a read. */
51
+ flush(): void;
52
+ close(): void;
53
+ private forward;
54
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * View-time cursor batching: the ONLY path from "a surface is showing
3
+ * messages" to a cursor ACK. Surfaces report views (beginView, noteSeq,
4
+ * endView); this batcher applies the locked phase 2 contract on top:
5
+ *
6
+ * - Dwell gating: a view must survive dwellMs before anything is acked, so
7
+ * a keyboard flash-through (next-unread skimming past a topic) never
8
+ * marks it read. "Marks read only after actual view time."
9
+ * - Batching: once dwelled, the highest seen seq forwards into the
10
+ * downstream ACK debouncer (one ACK frame per bucket per debounce
11
+ * window at most, relay-side write buffering below that). Per-message
12
+ * cursor writes are impossible by construction: the UI layer has no
13
+ * other ack path.
14
+ *
15
+ * A bucket key of '' (UNTOPICKED_BUCKET) acks the room-wide cursor; any
16
+ * other key acks that topic's cursor.
17
+ */
18
+ function viewKey(room, key) {
19
+ return `${encodeURIComponent(room)}:${encodeURIComponent(key)}`;
20
+ }
21
+ export class TopicViewBatcher {
22
+ scheduleAck;
23
+ dwellMs;
24
+ setTimeoutFn;
25
+ clearTimeoutFn;
26
+ sessions = new Map();
27
+ constructor(options) {
28
+ this.scheduleAck = options.scheduleAck;
29
+ this.dwellMs = options.dwellMs ?? 600;
30
+ this.setTimeoutFn =
31
+ options.setTimeoutFn ??
32
+ ((fn, ms) => {
33
+ const timer = setTimeout(fn, ms);
34
+ timer.unref?.();
35
+ return timer;
36
+ });
37
+ this.clearTimeoutFn = options.clearTimeoutFn ?? ((handle) => clearTimeout(handle));
38
+ }
39
+ /** A surface started showing a bucket. Idempotent per (room, key). */
40
+ beginView(room, key) {
41
+ const id = viewKey(room, key);
42
+ if (this.sessions.has(id))
43
+ return;
44
+ const session = { room, key, maxSeq: 0, dwelled: false, timer: undefined };
45
+ session.timer = this.setTimeoutFn(() => {
46
+ session.dwelled = true;
47
+ session.timer = undefined;
48
+ this.forward(session);
49
+ }, this.dwellMs);
50
+ this.sessions.set(id, session);
51
+ }
52
+ /** The highest seq currently visible in a bucket the surface is showing.
53
+ * Before dwell it accumulates; after dwell it forwards (debounced
54
+ * downstream). Calls for a bucket not being viewed are ignored: only
55
+ * actual view time acks. */
56
+ noteSeq(room, key, seq) {
57
+ if (!Number.isFinite(seq) || seq <= 0)
58
+ return;
59
+ const session = this.sessions.get(viewKey(room, key));
60
+ if (!session)
61
+ return;
62
+ if (seq <= session.maxSeq)
63
+ return;
64
+ session.maxSeq = seq;
65
+ if (session.dwelled)
66
+ this.forward(session);
67
+ }
68
+ /** The surface stopped showing the bucket. An un-dwelled view (a
69
+ * flash-through) is discarded without acking. */
70
+ endView(room, key) {
71
+ const id = viewKey(room, key);
72
+ const session = this.sessions.get(id);
73
+ if (!session)
74
+ return;
75
+ if (session.timer !== undefined) {
76
+ this.clearTimeoutFn(session.timer);
77
+ session.timer = undefined;
78
+ }
79
+ this.sessions.delete(id);
80
+ }
81
+ /** True while the bucket is being viewed (dwelled or not). */
82
+ isViewing(room, key) {
83
+ return this.sessions.has(viewKey(room, key));
84
+ }
85
+ /** The bucket key currently viewed in a room, or null. */
86
+ viewedKey(room) {
87
+ for (const session of this.sessions.values()) {
88
+ if (session.room === room)
89
+ return session.key;
90
+ }
91
+ return null;
92
+ }
93
+ /** Force every dwelled view forward (window hide, disconnect boundary).
94
+ * Un-dwelled views stay pending: flushing must not turn a flash-through
95
+ * into a read. */
96
+ flush() {
97
+ for (const session of this.sessions.values()) {
98
+ if (session.dwelled)
99
+ this.forward(session);
100
+ }
101
+ }
102
+ close() {
103
+ for (const session of this.sessions.values()) {
104
+ if (session.timer !== undefined)
105
+ this.clearTimeoutFn(session.timer);
106
+ }
107
+ this.flush();
108
+ this.sessions.clear();
109
+ }
110
+ forward(session) {
111
+ if (session.maxSeq <= 0)
112
+ return;
113
+ this.scheduleAck(session.room, session.maxSeq, session.key === "" ? undefined : session.key);
114
+ }
115
+ }