@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,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
|
+
}
|