@openmarket/rooms-client 0.5.0 → 0.6.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.
@@ -100,14 +100,6 @@ export declare function rearmedRoomState(roomId: string, retired: ArmedRoomState
100
100
  /** Seqs of the user's own posts in a window of entries; the reply-trigger
101
101
  * check tests `inReplyTo` against this set. Pure. */
102
102
  export declare function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: string): Set<number>;
103
- /**
104
- * Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
105
- * the room that @mentions the user's own handle, OR (when `ownSeqs` is given)
106
- * directly replies to one of the user's own messages. Reply semantics match a
107
- * mention (replying pings the author), so armed mode treats both as addressed
108
- * to the user. Agent posts, system entries, the user's own posts, and
109
- * non-mentions never trigger. Pure.
110
- */
111
103
  export declare function isTriggeringEntry(entry: RoomLedgerEntry, ownHandle: string, ownUserId: string, ownSeqs?: ReadonlySet<number>): boolean;
112
104
  /**
113
105
  * The newest qualifying trigger among entries strictly newer than
@@ -117,11 +117,21 @@ export function ownSeqsOf(entries, ownUserId) {
117
117
  * to the user. Agent posts, system entries, the user's own posts, and
118
118
  * non-mentions never trigger. Pure.
119
119
  */
120
+ /** Webhook-ingested entries are machine posts even though isAgent is
121
+ * false (the flag marks agent USERS). They must never count as the human
122
+ * in any autonomy gate: a webhook @mention cannot start an unreviewed
123
+ * autonomous turn, and webhook chatter cannot satisfy the
124
+ * human-activity brake. */
125
+ function isWebhookEntry(entry) {
126
+ return Boolean(entry.metadata?.webhook);
127
+ }
120
128
  export function isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs) {
121
129
  if (entry.type !== RoomLedgerEntryType.POST)
122
130
  return false;
123
131
  if (entry.isAgent)
124
132
  return false;
133
+ if (isWebhookEntry(entry))
134
+ return false;
125
135
  if (!entry.text?.trim())
126
136
  return false;
127
137
  // Never trigger on our own message (we post as this handle/userId).
@@ -312,6 +322,8 @@ export function isHumanResetEntry(entry, ownHandle, ownUserId) {
312
322
  return false;
313
323
  if (entry.isAgent)
314
324
  return false;
325
+ if (isWebhookEntry(entry))
326
+ return false;
315
327
  if (entry.userId === ownUserId)
316
328
  return false;
317
329
  const fromHandle = normalizeHandle(entry.handle).toLowerCase();
@@ -1,5 +1,12 @@
1
1
  import { type KnownProvider } from "@earendil-works/pi-ai";
2
- export type SurfaceId = "cli" | "telegram" | "discord" | "slack" | "webui";
2
+ /** Every chat surface, as a runtime list. The `SurfaceId` union derives from
3
+ * this literal so per-surface policy checklists (packages/cli/test/
4
+ * harness-budgets.test.ts) can iterate the surfaces at runtime while a new
5
+ * entry still breaks type-exhaustive tables at compile time. Adding a
6
+ * surface here requires classifying it in EVERY policy set (see AGENTS.md,
7
+ * "Harness invariants"). */
8
+ export declare const SURFACE_IDS: readonly ["cli", "telegram", "discord", "slack", "webui"];
9
+ export type SurfaceId = (typeof SURFACE_IDS)[number];
3
10
  export interface AgentToolCall {
4
11
  id: string;
5
12
  name: string;
@@ -1,4 +1,11 @@
1
1
  import { getProviders } from "@earendil-works/pi-ai";
2
+ /** Every chat surface, as a runtime list. The `SurfaceId` union derives from
3
+ * this literal so per-surface policy checklists (packages/cli/test/
4
+ * harness-budgets.test.ts) can iterate the surfaces at runtime while a new
5
+ * entry still breaks type-exhaustive tables at compile time. Adding a
6
+ * surface here requires classifying it in EVERY policy set (see AGENTS.md,
7
+ * "Harness invariants"). */
8
+ export const SURFACE_IDS = ["cli", "telegram", "discord", "slack", "webui"];
2
9
  /** OM-level provider for any OpenAI-compatible /v1/chat/completions endpoint
3
10
  * (Ollama, vLLM, LM Studio, llama.cpp, custom proxies). Not a pi-ai
4
11
  * KnownProvider: the model object is constructed at turn time from the
@@ -32,7 +39,10 @@ export function stringifyToolContent(value) {
32
39
  return "";
33
40
  if (typeof value === "string")
34
41
  return value;
35
- return JSON.stringify(value, null, 2);
42
+ // Compact on purpose: this string is LLM input, and 2-space pretty-printing
43
+ // inflated every structured tool result by roughly a fifth in tokens for
44
+ // zero model benefit.
45
+ return JSON.stringify(value);
36
46
  }
37
47
  export function parseToolArguments(raw) {
38
48
  if (raw === undefined || raw === null)
@@ -22,6 +22,9 @@ export interface SidebarRoom {
22
22
  headSeq: number | null;
23
23
  lastReadSeq: number | null;
24
24
  unread: number;
25
+ /** Unread mention count from the latest read-state snapshot: the server's
26
+ * number verbatim, 0 when the snapshot carried none for this room. */
27
+ mentionUnread: number;
25
28
  joined: boolean;
26
29
  /** Topics dial from room meta. Absent reads as "allowed" since the
27
30
  * 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
@@ -40,6 +43,13 @@ export interface SidebarSnapshot {
40
43
  dms: SidebarDm[];
41
44
  popular: SidebarRoom[];
42
45
  globalReplyUnread: number;
46
+ /** Per-room unread mention counts exactly as the server sent them in the
47
+ * latest read-state snapshot: sparse (zero-count rooms absent) and
48
+ * global (rooms outside the current listings may appear). Empty when the
49
+ * snapshot carried none; a fresh snapshot replaces it wholesale. */
50
+ mentionCounts: Record<string, number>;
51
+ /** User-wide unread mention total from the same snapshot. */
52
+ mentionTotal: number;
43
53
  fetchedAt: number;
44
54
  }
45
55
  export declare const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot;
@@ -48,10 +58,22 @@ export declare function computeRoomUnread(input: {
48
58
  headSeq: number | null;
49
59
  lastReadSeq: number | null;
50
60
  }): number;
61
+ /** A READ_STATE reply that carries the additive mention decorations beside
62
+ * the cursor map (RoomReadStatePayload satisfies this shape). Old
63
+ * fetchReadState implementations returning the bare cursor Record keep
64
+ * working; mention fields then read as zero. */
65
+ export interface SidebarReadState {
66
+ readState: Record<string, number>;
67
+ mentionCounts?: Record<string, number> | undefined;
68
+ mentionTotal?: number | undefined;
69
+ }
51
70
  export declare function buildSidebarSnapshot(input: {
52
71
  rooms: RoomMeta[];
53
72
  spaces: RoomSpace[];
54
73
  readState: Record<string, number>;
74
+ /** Server numbers pass through verbatim; merge policy is the caller's. */
75
+ mentionCounts?: Record<string, number> | undefined;
76
+ mentionTotal?: number | undefined;
55
77
  dms: SidebarDm[];
56
78
  globalReplyUnread: number;
57
79
  joinedUnread: (roomName: string) => number | null;
@@ -60,7 +82,9 @@ export declare function buildSidebarSnapshot(input: {
60
82
  export interface SidebarModelDeps {
61
83
  fetchRooms: () => Promise<RoomMeta[]>;
62
84
  fetchSpaces: () => Promise<RoomSpace[]>;
63
- fetchReadState: (roomNames: string[]) => Promise<Record<string, number>>;
85
+ /** Either the bare cursor Record (the 0.2.0 shape) or a SidebarReadState
86
+ * whose mention decorations feed the snapshot's mention fields. */
87
+ fetchReadState: (roomNames: string[]) => Promise<Record<string, number> | SidebarReadState>;
64
88
  fetchDms: () => Promise<SidebarDm[]>;
65
89
  fetchGlobalReplyUnread: () => Promise<number>;
66
90
  joinedUnread: (roomName: string) => number | null;
@@ -4,6 +4,8 @@ export const EMPTY_SIDEBAR_SNAPSHOT = {
4
4
  dms: [],
5
5
  popular: [],
6
6
  globalReplyUnread: 0,
7
+ mentionCounts: {},
8
+ mentionTotal: 0,
7
9
  fetchedAt: 0,
8
10
  };
9
11
  const POPULAR_MAX = 6;
@@ -34,6 +36,11 @@ export function buildSidebarSnapshot(input) {
34
36
  headSeq,
35
37
  lastReadSeq,
36
38
  unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
39
+ // Own-key guard like the readState read above: rooms named after
40
+ // Object.prototype members must read 0 from a sparse map, not NaN.
41
+ mentionUnread: Math.max(0, input.mentionCounts && Object.hasOwn(input.mentionCounts, name)
42
+ ? (input.mentionCounts[name] ?? 0)
43
+ : 0),
37
44
  // Joined = an open context OR a server read cursor (you only get a
38
45
  // cursor by having been in the room). This is what decides whether a
39
46
  // room lives in your channel tree or in the POPULAR discovery shelf.
@@ -92,9 +99,30 @@ export function buildSidebarSnapshot(input) {
92
99
  dms: input.dms,
93
100
  popular,
94
101
  globalReplyUnread: Math.max(0, input.globalReplyUnread),
102
+ mentionCounts: input.mentionCounts ?? {},
103
+ mentionTotal: Math.max(0, input.mentionTotal ?? 0),
95
104
  fetchedAt: input.now,
96
105
  };
97
106
  }
107
+ /** A bare cursor Record maps room name to seq (numbers only), so an object
108
+ * under the "readState" key can only be the snapshot shape, even for a
109
+ * room literally named "readState" (its cursor would be a number). */
110
+ function normalizeSidebarReadState(reply) {
111
+ const nested = reply.readState;
112
+ if (typeof nested === "object" && nested !== null) {
113
+ const snapshot = reply;
114
+ return {
115
+ readState: snapshot.readState,
116
+ mentionCounts: snapshot.mentionCounts,
117
+ mentionTotal: snapshot.mentionTotal,
118
+ };
119
+ }
120
+ return {
121
+ readState: reply,
122
+ mentionCounts: undefined,
123
+ mentionTotal: undefined,
124
+ };
125
+ }
98
126
  export class SidebarModel {
99
127
  deps;
100
128
  snapshotValue = EMPTY_SIDEBAR_SNAPSHOT;
@@ -174,13 +202,16 @@ export class SidebarModel {
174
202
  this.deps.fetchDms().catch(() => []),
175
203
  this.deps.fetchGlobalReplyUnread().catch(() => 0),
176
204
  ]);
177
- const readState = await this.deps
205
+ const readStateReply = await this.deps
178
206
  .fetchReadState(rooms.map((room) => room.name))
179
207
  .catch(() => ({}));
208
+ const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
180
209
  return buildSidebarSnapshot({
181
210
  rooms,
182
211
  spaces,
183
212
  readState,
213
+ mentionCounts,
214
+ mentionTotal,
184
215
  dms,
185
216
  globalReplyUnread,
186
217
  joinedUnread: this.deps.joinedUnread,
@@ -231,7 +231,7 @@ export interface RoomLedgerEntry {
231
231
  pinnedAt?: string | number | Date | null | undefined;
232
232
  attachments?: RoomAttachment[] | undefined;
233
233
  roomRole?: RoomRole | null | undefined;
234
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
234
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
235
235
  }
236
236
  export type RoomForwardedFrom = {
237
237
  kind: "room";
@@ -245,6 +245,18 @@ export type RoomForwardedFrom = {
245
245
  export interface RoomForwardMetadata {
246
246
  forwardedFrom: RoomForwardedFrom;
247
247
  }
248
+ /** Sender identity for messages posted through a channel webhook (the
249
+ * Discord-compatible ingest). Entries carrying it render the webhook's
250
+ * name and avatar with an APP-style badge; the entry's handle mirrors
251
+ * the display name and its userId is the synthetic `webhook:<id>`, so a
252
+ * webhook can never impersonate a member. */
253
+ export interface RoomWebhookMetadata {
254
+ webhook: {
255
+ id: string;
256
+ name: string;
257
+ avatarUrl?: string | undefined;
258
+ };
259
+ }
248
260
  export interface RoomAttachment {
249
261
  /**
250
262
  * Object key under room-attachments/; clients sign it at render time.
@@ -330,6 +342,16 @@ export interface RoomReadStatePayload {
330
342
  * Present when the store surfaces it and on ROOM_SET_ATTENTION acks
331
343
  * (which reply READ_STATE scoped to the one room, readState empty). */
332
344
  attention?: Record<string, RoomTopicAttention> | undefined;
345
+ /** Unread mention counts by room, server-computed from the notify
346
+ * worker's ROOM_MENTION rows so badges survive reload and clear through
347
+ * the ack path. Unlike readState/attention the map is SPARSE AND
348
+ * GLOBAL: every room with a nonzero count appears (requested or not),
349
+ * zero-count rooms are absent. Missing on servers older than the
350
+ * mentions-completion store. */
351
+ mentionCounts?: Record<string, number> | undefined;
352
+ /** User-wide unread mention total (drives the app badge). Rides beside
353
+ * mentionCounts; missing on older servers. */
354
+ mentionTotal?: number | undefined;
333
355
  requestId?: string | undefined;
334
356
  }
335
357
  export interface RoomSearchResultsPayload {
@@ -491,7 +513,7 @@ export interface RoomDmMessage {
491
513
  createdAt?: string | number | undefined;
492
514
  /** Sealed DM: content is '' and the opaque envelope rides here. */
493
515
  secret?: RoomLedgerSecretPayload | null | undefined;
494
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
516
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
495
517
  /** Legacy public chat-image URL (renders as-is, no signing). */
496
518
  imageUrl?: string | null | undefined;
497
519
  /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
@@ -1,5 +1,5 @@
1
- import type { RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest } from "./social-types.js";
2
- export type { RoomAttachment, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomSocialConfig, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, } from "./social-types.js";
1
+ import type { RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomNotificationInboxResult, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest } from "./social-types.js";
2
+ export type { RoomAttachment, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomNotificationInboxItem, RoomNotificationInboxResult, RoomNotificationKind, RoomNotificationMessageData, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomSocialConfig, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, } from "./social-types.js";
3
3
  /** Largest per-file size any tier accepts (plus tier). The server enforces
4
4
  * the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
5
5
  * constant only pre-flights uploads no tier could ever accept. */
@@ -210,6 +210,31 @@ export declare function updateSpaceRole(config: RoomSocialConfig, spaceId: strin
210
210
  position?: number;
211
211
  }): Promise<RoomSpaceRole>;
212
212
  export declare function deleteSpaceRole(config: RoomSocialConfig, spaceId: string, roleId: string): Promise<void>;
213
+ /** A channel webhook: the Discord-compatible ingest capability. The
214
+ * posting URL is `<chat-api>/webhooks/<webhookId>/<token>`; the token is
215
+ * the whole credential, so treat listed webhooks like secrets. */
216
+ export interface RoomWebhook {
217
+ webhookId: string;
218
+ room: string;
219
+ name: string;
220
+ avatarUrl: string | null;
221
+ token: string;
222
+ createdBy: string;
223
+ /** Creator's handle at create time (display; createdBy is the id). */
224
+ createdByHandle?: string | null;
225
+ createdAt: string;
226
+ lastUsedAt: string | null;
227
+ deliveryCount: number;
228
+ disabled: boolean;
229
+ }
230
+ export declare function listRoomWebhooks(config: RoomSocialConfig, room: string): Promise<RoomWebhook[]>;
231
+ export declare function createRoomWebhook(config: RoomSocialConfig, room: string, input: {
232
+ name: string;
233
+ avatarUrl?: string | null;
234
+ }): Promise<RoomWebhook>;
235
+ export declare function deleteRoomWebhook(config: RoomSocialConfig, room: string, webhookId: string): Promise<void>;
236
+ /** Rotate the capability: the old URL dies immediately. */
237
+ export declare function regenerateRoomWebhookToken(config: RoomSocialConfig, room: string, webhookId: string): Promise<RoomWebhook>;
213
238
  export declare function setSpaceMemberRoles(config: RoomSocialConfig, spaceId: string, targetUserId: string, roleIds: string[]): Promise<{
214
239
  spaceId: string;
215
240
  userId: string;
@@ -368,6 +393,24 @@ export declare function markRoomRepliesRead(config: RoomSocialConfig, options?:
368
393
  room?: string;
369
394
  before?: string | Date;
370
395
  }): Promise<RoomRepliesReadResult>;
396
+ /**
397
+ * The chat-scoped notification inbox (GET /users/notifications): the durable
398
+ * rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
399
+ * Voucher-reachable like the rest of the chat surface (the bearer token may
400
+ * be an API key or a chat voucher). Pagination is the composite cursor: pass
401
+ * the previous page's lastCursor and lastCursorId back TOGETHER (the store
402
+ * rejects one without the other); unread=true filters to unread rows.
403
+ */
404
+ export declare function listRoomNotifications(config: RoomSocialConfig, options?: {
405
+ limit?: number | undefined;
406
+ /** Feed a page's lastCursor back here (explicit undefined reads as
407
+ * "first page", so `page.lastCursor ?? undefined` composes under
408
+ * exactOptionalPropertyTypes). */
409
+ cursor?: string | undefined;
410
+ cursorId?: string | undefined;
411
+ unread?: boolean | undefined;
412
+ signal?: AbortSignal | undefined;
413
+ }): Promise<RoomNotificationInboxResult>;
371
414
  export declare function suggestRooms(config: RoomSocialConfig, request: RoomSuggestionRequest, options?: {
372
415
  signal?: AbortSignal;
373
416
  }): Promise<RoomSuggestion[]>;
@@ -377,6 +420,9 @@ export declare function socialRequest<T>(config: RoomSocialConfig, path: string,
377
420
  signal?: AbortSignal;
378
421
  formData?: FormData;
379
422
  headers?: Record<string, string>;
423
+ /** Fetch cache mode; token-bearing reads pass "no-store" so posting
424
+ * credentials never persist in a browser HTTP cache. */
425
+ cache?: RequestCache;
380
426
  }): Promise<T>;
381
427
  export declare function cleanUsername(username: string): string;
382
428
  export declare function cleanRoomId(room: string): string;
@@ -378,6 +378,36 @@ export async function updateSpaceRole(config, spaceId, roleId, patch) {
378
378
  export async function deleteSpaceRole(config, spaceId, roleId) {
379
379
  await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/roles/${encodeURIComponent(roleId)}`, { method: "DELETE" });
380
380
  }
381
+ export async function listRoomWebhooks(config, room) {
382
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`, { cache: "no-store" });
383
+ return result.webhooks ?? [];
384
+ }
385
+ export async function createRoomWebhook(config, room, input) {
386
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`, { method: "POST", body: input });
387
+ if (!result.webhook)
388
+ throw new Error("Webhook create returned no webhook");
389
+ return result.webhook;
390
+ }
391
+ /** Store-minted webhook ids are `wh_<hex>`. Anything else is rejected
392
+ * BEFORE it reaches a URL: encodeURIComponent does not escape dots, and
393
+ * a `..` segment would normalize `/webhooks/..` onto the parent room
394
+ * route, turning a webhook delete into a channel archive. */
395
+ function assertWebhookId(webhookId) {
396
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(webhookId)) {
397
+ throw new Error(`Invalid webhook id: ${JSON.stringify(webhookId)}`);
398
+ }
399
+ return webhookId;
400
+ }
401
+ export async function deleteRoomWebhook(config, room, webhookId) {
402
+ await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}`, { method: "DELETE" });
403
+ }
404
+ /** Rotate the capability: the old URL dies immediately. */
405
+ export async function regenerateRoomWebhookToken(config, room, webhookId) {
406
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}/regenerate`, { method: "POST" });
407
+ if (!result.webhook)
408
+ throw new Error("Webhook regenerate returned no webhook");
409
+ return result.webhook;
410
+ }
381
411
  export async function setSpaceMemberRoles(config, spaceId, targetUserId, roleIds) {
382
412
  return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/roles`, { method: "PUT", body: { roleIds } });
383
413
  }
@@ -592,6 +622,35 @@ export async function markRoomRepliesRead(config, options = {}) {
592
622
  });
593
623
  return { markedRead: result.markedRead ?? 0 };
594
624
  }
625
+ /**
626
+ * The chat-scoped notification inbox (GET /users/notifications): the durable
627
+ * rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
628
+ * Voucher-reachable like the rest of the chat surface (the bearer token may
629
+ * be an API key or a chat voucher). Pagination is the composite cursor: pass
630
+ * the previous page's lastCursor and lastCursorId back TOGETHER (the store
631
+ * rejects one without the other); unread=true filters to unread rows.
632
+ */
633
+ export async function listRoomNotifications(config, options = {}) {
634
+ const params = new URLSearchParams();
635
+ if (options.limit !== undefined)
636
+ params.set("limit", String(options.limit));
637
+ if (options.cursor !== undefined)
638
+ params.set("cursor", options.cursor);
639
+ if (options.cursorId !== undefined)
640
+ params.set("cursorId", options.cursorId);
641
+ if (options.unread !== undefined)
642
+ params.set("unread", String(options.unread));
643
+ const init = {};
644
+ if (options.signal)
645
+ init.signal = options.signal;
646
+ const result = await socialRequest(config, `/users/notifications${params.size ? `?${params.toString()}` : ""}`, init);
647
+ return {
648
+ notifications: result.notifications ?? [],
649
+ hasMore: result.hasMore ?? false,
650
+ lastCursor: result.lastCursor ?? null,
651
+ lastCursorId: result.lastCursorId ?? null,
652
+ };
653
+ }
595
654
  export async function suggestRooms(config, request, options = {}) {
596
655
  const init = {
597
656
  method: "POST",
@@ -607,6 +666,7 @@ export async function socialRequest(config, path, init = {}) {
607
666
  const hasJsonBody = init.body !== undefined;
608
667
  const res = await fetch(url, {
609
668
  method: init.method ?? "GET",
669
+ ...(init.cache ? { cache: init.cache } : {}),
610
670
  headers: {
611
671
  Authorization: `Bearer ${config.apiKey}`,
612
672
  Accept: "application/json",
@@ -171,6 +171,44 @@ export interface RoomRepliesResult {
171
171
  export interface RoomRepliesReadResult {
172
172
  markedRead: number;
173
173
  }
174
+ /** The room kinds the chat notification inbox returns (the store's
175
+ * ROOM_INBOX_NOTIFICATION_TYPES). ROOM_FOLLOW rows stay out of the inbox:
176
+ * follow is a push channel, not a badge source. */
177
+ export type RoomNotificationKind = "ROOM_MENTION" | "ROOM_REPLY" | "ROOM_DM" | "ROOM_KEYWORD";
178
+ /** Shared room payload on a notification row, wire names verbatim from the
179
+ * store's notification schema (room_message_data). `preview` follows the
180
+ * push body rules (140 chars, empty for sealed content); `hash` is the
181
+ * client deep link. */
182
+ export interface RoomNotificationMessageData {
183
+ kind: "dm" | "mention" | "reply" | "follow" | "keyword";
184
+ room_id?: string;
185
+ room_name?: string;
186
+ space_id?: string;
187
+ conversation_id?: string;
188
+ seq?: number;
189
+ from_handle: string;
190
+ preview: string;
191
+ hash: string;
192
+ }
193
+ /** One chat notification inbox row: the store's explicit field pick
194
+ * (snake_case wire names, dates as ISO strings, room_message_data only
195
+ * when the row carries one). */
196
+ export interface RoomNotificationInboxItem {
197
+ id: string;
198
+ type: RoomNotificationKind;
199
+ created_at: string;
200
+ read_at: string | null;
201
+ room_message_data?: RoomNotificationMessageData;
202
+ }
203
+ /** A newest-first inbox page plus the composite cursor naming the last row
204
+ * returned; feed lastCursor and lastCursorId back together to request the
205
+ * next page. Both are null on an empty page. */
206
+ export interface RoomNotificationInboxResult {
207
+ notifications: RoomNotificationInboxItem[];
208
+ hasMore: boolean;
209
+ lastCursor: string | null;
210
+ lastCursorId: string | null;
211
+ }
174
212
  /** A living markdown doc scoped to a space (head state; content on demand). */
175
213
  export interface RoomDoc {
176
214
  docId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "OM Rooms protocol client: wire types, WebSocket + REST clients, and chat view-models. Browser-safe (no node:/bun: imports).",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -17,12 +17,7 @@
17
17
  "bun": ">=1.3.0",
18
18
  "node": ">=22"
19
19
  },
20
- "files": [
21
- "src",
22
- "dist",
23
- "README.md",
24
- "LICENSE"
25
- ],
20
+ "files": ["src", "dist", "README.md", "LICENSE"],
26
21
  "publishConfig": {
27
22
  "access": "public"
28
23
  },
@@ -164,6 +164,17 @@ export function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: st
164
164
  * to the user. Agent posts, system entries, the user's own posts, and
165
165
  * non-mentions never trigger. Pure.
166
166
  */
167
+ /** Webhook-ingested entries are machine posts even though isAgent is
168
+ * false (the flag marks agent USERS). They must never count as the human
169
+ * in any autonomy gate: a webhook @mention cannot start an unreviewed
170
+ * autonomous turn, and webhook chatter cannot satisfy the
171
+ * human-activity brake. */
172
+ function isWebhookEntry(entry: RoomLedgerEntry): boolean {
173
+ return Boolean(
174
+ (entry.metadata as { webhook?: unknown } | undefined)?.webhook,
175
+ );
176
+ }
177
+
167
178
  export function isTriggeringEntry(
168
179
  entry: RoomLedgerEntry,
169
180
  ownHandle: string,
@@ -172,6 +183,7 @@ export function isTriggeringEntry(
172
183
  ): boolean {
173
184
  if (entry.type !== RoomLedgerEntryType.POST) return false;
174
185
  if (entry.isAgent) return false;
186
+ if (isWebhookEntry(entry)) return false;
175
187
  if (!entry.text?.trim()) return false;
176
188
  // Never trigger on our own message (we post as this handle/userId).
177
189
  const fromHandle = normalizeHandle(entry.handle).toLowerCase();
@@ -379,6 +391,7 @@ export function isHumanResetEntry(
379
391
  ): boolean {
380
392
  if (entry.type !== RoomLedgerEntryType.POST) return false;
381
393
  if (entry.isAgent) return false;
394
+ if (isWebhookEntry(entry)) return false;
382
395
  if (entry.userId === ownUserId) return false;
383
396
  const fromHandle = normalizeHandle(entry.handle).toLowerCase();
384
397
  const me = normalizeHandle(ownHandle).toLowerCase();
@@ -1,6 +1,14 @@
1
1
  import { getProviders, type KnownProvider } from "@earendil-works/pi-ai";
2
2
 
3
- export type SurfaceId = "cli" | "telegram" | "discord" | "slack" | "webui";
3
+ /** Every chat surface, as a runtime list. The `SurfaceId` union derives from
4
+ * this literal so per-surface policy checklists (packages/cli/test/
5
+ * harness-budgets.test.ts) can iterate the surfaces at runtime while a new
6
+ * entry still breaks type-exhaustive tables at compile time. Adding a
7
+ * surface here requires classifying it in EVERY policy set (see AGENTS.md,
8
+ * "Harness invariants"). */
9
+ export const SURFACE_IDS = ["cli", "telegram", "discord", "slack", "webui"] as const;
10
+
11
+ export type SurfaceId = (typeof SURFACE_IDS)[number];
4
12
 
5
13
  export interface AgentToolCall {
6
14
  id: string;
@@ -79,7 +87,10 @@ export type RunLlmTurn = (input: RunLlmTurnInput) => AsyncIterable<TurnEvent>;
79
87
  export function stringifyToolContent(value: unknown): string {
80
88
  if (value === undefined) return "";
81
89
  if (typeof value === "string") return value;
82
- return JSON.stringify(value, null, 2);
90
+ // Compact on purpose: this string is LLM input, and 2-space pretty-printing
91
+ // inflated every structured tool result by roughly a fifth in tokens for
92
+ // zero model benefit.
93
+ return JSON.stringify(value);
83
94
  }
84
95
 
85
96
  export function parseToolArguments(raw: unknown): unknown {
@@ -23,6 +23,9 @@ export interface SidebarRoom {
23
23
  headSeq: number | null;
24
24
  lastReadSeq: number | null;
25
25
  unread: number;
26
+ /** Unread mention count from the latest read-state snapshot: the server's
27
+ * number verbatim, 0 when the snapshot carried none for this room. */
28
+ mentionUnread: number;
26
29
  joined: boolean;
27
30
  /** Topics dial from room meta. Absent reads as "allowed" since the
28
31
  * 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
@@ -40,6 +43,13 @@ export interface SidebarSnapshot {
40
43
  dms: SidebarDm[];
41
44
  popular: SidebarRoom[];
42
45
  globalReplyUnread: number;
46
+ /** Per-room unread mention counts exactly as the server sent them in the
47
+ * latest read-state snapshot: sparse (zero-count rooms absent) and
48
+ * global (rooms outside the current listings may appear). Empty when the
49
+ * snapshot carried none; a fresh snapshot replaces it wholesale. */
50
+ mentionCounts: Record<string, number>;
51
+ /** User-wide unread mention total from the same snapshot. */
52
+ mentionTotal: number;
43
53
  fetchedAt: number;
44
54
  }
45
55
 
@@ -49,6 +59,8 @@ export const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot = {
49
59
  dms: [],
50
60
  popular: [],
51
61
  globalReplyUnread: 0,
62
+ mentionCounts: {},
63
+ mentionTotal: 0,
52
64
  fetchedAt: 0,
53
65
  };
54
66
 
@@ -65,10 +77,23 @@ export function computeRoomUnread(input: {
65
77
  return Math.max(0, input.headSeq - input.lastReadSeq);
66
78
  }
67
79
 
80
+ /** A READ_STATE reply that carries the additive mention decorations beside
81
+ * the cursor map (RoomReadStatePayload satisfies this shape). Old
82
+ * fetchReadState implementations returning the bare cursor Record keep
83
+ * working; mention fields then read as zero. */
84
+ export interface SidebarReadState {
85
+ readState: Record<string, number>;
86
+ mentionCounts?: Record<string, number> | undefined;
87
+ mentionTotal?: number | undefined;
88
+ }
89
+
68
90
  export function buildSidebarSnapshot(input: {
69
91
  rooms: RoomMeta[];
70
92
  spaces: RoomSpace[];
71
93
  readState: Record<string, number>;
94
+ /** Server numbers pass through verbatim; merge policy is the caller's. */
95
+ mentionCounts?: Record<string, number> | undefined;
96
+ mentionTotal?: number | undefined;
72
97
  dms: SidebarDm[];
73
98
  globalReplyUnread: number;
74
99
  joinedUnread: (roomName: string) => number | null;
@@ -91,6 +116,14 @@ export function buildSidebarSnapshot(input: {
91
116
  headSeq,
92
117
  lastReadSeq,
93
118
  unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
119
+ // Own-key guard like the readState read above: rooms named after
120
+ // Object.prototype members must read 0 from a sparse map, not NaN.
121
+ mentionUnread: Math.max(
122
+ 0,
123
+ input.mentionCounts && Object.hasOwn(input.mentionCounts, name)
124
+ ? (input.mentionCounts[name] ?? 0)
125
+ : 0,
126
+ ),
94
127
  // Joined = an open context OR a server read cursor (you only get a
95
128
  // cursor by having been in the room). This is what decides whether a
96
129
  // room lives in your channel tree or in the POPULAR discovery shelf.
@@ -154,6 +187,8 @@ export function buildSidebarSnapshot(input: {
154
187
  dms: input.dms,
155
188
  popular,
156
189
  globalReplyUnread: Math.max(0, input.globalReplyUnread),
190
+ mentionCounts: input.mentionCounts ?? {},
191
+ mentionTotal: Math.max(0, input.mentionTotal ?? 0),
157
192
  fetchedAt: input.now,
158
193
  };
159
194
  }
@@ -161,13 +196,39 @@ export function buildSidebarSnapshot(input: {
161
196
  export interface SidebarModelDeps {
162
197
  fetchRooms: () => Promise<RoomMeta[]>;
163
198
  fetchSpaces: () => Promise<RoomSpace[]>;
164
- fetchReadState: (roomNames: string[]) => Promise<Record<string, number>>;
199
+ /** Either the bare cursor Record (the 0.2.0 shape) or a SidebarReadState
200
+ * whose mention decorations feed the snapshot's mention fields. */
201
+ fetchReadState: (roomNames: string[]) => Promise<Record<string, number> | SidebarReadState>;
165
202
  fetchDms: () => Promise<SidebarDm[]>;
166
203
  fetchGlobalReplyUnread: () => Promise<number>;
167
204
  joinedUnread: (roomName: string) => number | null;
168
205
  now?: () => number;
169
206
  }
170
207
 
208
+ /** A bare cursor Record maps room name to seq (numbers only), so an object
209
+ * under the "readState" key can only be the snapshot shape, even for a
210
+ * room literally named "readState" (its cursor would be a number). */
211
+ function normalizeSidebarReadState(reply: Record<string, number> | SidebarReadState): {
212
+ readState: Record<string, number>;
213
+ mentionCounts: Record<string, number> | undefined;
214
+ mentionTotal: number | undefined;
215
+ } {
216
+ const nested = (reply as SidebarReadState).readState;
217
+ if (typeof nested === "object" && nested !== null) {
218
+ const snapshot = reply as SidebarReadState;
219
+ return {
220
+ readState: snapshot.readState,
221
+ mentionCounts: snapshot.mentionCounts,
222
+ mentionTotal: snapshot.mentionTotal,
223
+ };
224
+ }
225
+ return {
226
+ readState: reply as Record<string, number>,
227
+ mentionCounts: undefined,
228
+ mentionTotal: undefined,
229
+ };
230
+ }
231
+
171
232
  export class SidebarModel {
172
233
  private snapshotValue: SidebarSnapshot = EMPTY_SIDEBAR_SNAPSHOT;
173
234
  private inflight: Promise<SidebarSnapshot> | null = null;
@@ -243,13 +304,16 @@ export class SidebarModel {
243
304
  this.deps.fetchDms().catch(() => [] as SidebarDm[]),
244
305
  this.deps.fetchGlobalReplyUnread().catch(() => 0),
245
306
  ]);
246
- const readState = await this.deps
307
+ const readStateReply = await this.deps
247
308
  .fetchReadState(rooms.map((room) => room.name))
248
309
  .catch(() => ({}) as Record<string, number>);
310
+ const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
249
311
  return buildSidebarSnapshot({
250
312
  rooms,
251
313
  spaces,
252
314
  readState,
315
+ mentionCounts,
316
+ mentionTotal,
253
317
  dms,
254
318
  globalReplyUnread,
255
319
  joinedUnread: this.deps.joinedUnread,
@@ -259,7 +259,7 @@ export interface RoomLedgerEntry {
259
259
  pinnedAt?: string | number | Date | null | undefined;
260
260
  attachments?: RoomAttachment[] | undefined;
261
261
  roomRole?: RoomRole | null | undefined;
262
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
262
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
263
263
  }
264
264
 
265
265
  export type RoomForwardedFrom =
@@ -278,6 +278,19 @@ export interface RoomForwardMetadata {
278
278
  forwardedFrom: RoomForwardedFrom;
279
279
  }
280
280
 
281
+ /** Sender identity for messages posted through a channel webhook (the
282
+ * Discord-compatible ingest). Entries carrying it render the webhook's
283
+ * name and avatar with an APP-style badge; the entry's handle mirrors
284
+ * the display name and its userId is the synthetic `webhook:<id>`, so a
285
+ * webhook can never impersonate a member. */
286
+ export interface RoomWebhookMetadata {
287
+ webhook: {
288
+ id: string;
289
+ name: string;
290
+ avatarUrl?: string | undefined;
291
+ };
292
+ }
293
+
281
294
  export interface RoomAttachment {
282
295
  /**
283
296
  * Object key under room-attachments/; clients sign it at render time.
@@ -370,6 +383,16 @@ export interface RoomReadStatePayload {
370
383
  * Present when the store surfaces it and on ROOM_SET_ATTENTION acks
371
384
  * (which reply READ_STATE scoped to the one room, readState empty). */
372
385
  attention?: Record<string, RoomTopicAttention> | undefined;
386
+ /** Unread mention counts by room, server-computed from the notify
387
+ * worker's ROOM_MENTION rows so badges survive reload and clear through
388
+ * the ack path. Unlike readState/attention the map is SPARSE AND
389
+ * GLOBAL: every room with a nonzero count appears (requested or not),
390
+ * zero-count rooms are absent. Missing on servers older than the
391
+ * mentions-completion store. */
392
+ mentionCounts?: Record<string, number> | undefined;
393
+ /** User-wide unread mention total (drives the app badge). Rides beside
394
+ * mentionCounts; missing on older servers. */
395
+ mentionTotal?: number | undefined;
373
396
  requestId?: string | undefined;
374
397
  }
375
398
 
@@ -583,7 +606,7 @@ export interface RoomDmMessage {
583
606
  createdAt?: string | number | undefined;
584
607
  /** Sealed DM: content is '' and the opaque envelope rides here. */
585
608
  secret?: RoomLedgerSecretPayload | null | undefined;
586
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
609
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
587
610
  /** Legacy public chat-image URL (renders as-is, no signing). */
588
611
  imageUrl?: string | null | undefined;
589
612
  /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
@@ -18,6 +18,7 @@ import type {
18
18
  RoomFollowResult,
19
19
  RoomFriend,
20
20
  RoomFriendRequests,
21
+ RoomNotificationInboxResult,
21
22
  RoomPublicProfile,
22
23
  RoomRepliesReadResult,
23
24
  RoomRepliesResult,
@@ -46,6 +47,10 @@ export type {
46
47
  RoomFriend,
47
48
  RoomFriendRequest,
48
49
  RoomFriendRequests,
50
+ RoomNotificationInboxItem,
51
+ RoomNotificationInboxResult,
52
+ RoomNotificationKind,
53
+ RoomNotificationMessageData,
49
54
  RoomPublicProfile,
50
55
  RoomRepliesReadResult,
51
56
  RoomRepliesResult,
@@ -744,6 +749,88 @@ export async function deleteSpaceRole(
744
749
  );
745
750
  }
746
751
 
752
+ /** A channel webhook: the Discord-compatible ingest capability. The
753
+ * posting URL is `<chat-api>/webhooks/<webhookId>/<token>`; the token is
754
+ * the whole credential, so treat listed webhooks like secrets. */
755
+ export interface RoomWebhook {
756
+ webhookId: string;
757
+ room: string;
758
+ name: string;
759
+ avatarUrl: string | null;
760
+ token: string;
761
+ createdBy: string;
762
+ /** Creator's handle at create time (display; createdBy is the id). */
763
+ createdByHandle?: string | null;
764
+ createdAt: string;
765
+ lastUsedAt: string | null;
766
+ deliveryCount: number;
767
+ disabled: boolean;
768
+ }
769
+
770
+ export async function listRoomWebhooks(
771
+ config: RoomSocialConfig,
772
+ room: string,
773
+ ): Promise<RoomWebhook[]> {
774
+ const result = await socialRequest<{ webhooks?: RoomWebhook[] }>(
775
+ config,
776
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`,
777
+ { cache: "no-store" },
778
+ );
779
+ return result.webhooks ?? [];
780
+ }
781
+
782
+ export async function createRoomWebhook(
783
+ config: RoomSocialConfig,
784
+ room: string,
785
+ input: { name: string; avatarUrl?: string | null },
786
+ ): Promise<RoomWebhook> {
787
+ const result = await socialRequest<{ webhook?: RoomWebhook }>(
788
+ config,
789
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`,
790
+ { method: "POST", body: input },
791
+ );
792
+ if (!result.webhook) throw new Error("Webhook create returned no webhook");
793
+ return result.webhook;
794
+ }
795
+
796
+ /** Store-minted webhook ids are `wh_<hex>`. Anything else is rejected
797
+ * BEFORE it reaches a URL: encodeURIComponent does not escape dots, and
798
+ * a `..` segment would normalize `/webhooks/..` onto the parent room
799
+ * route, turning a webhook delete into a channel archive. */
800
+ function assertWebhookId(webhookId: string): string {
801
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(webhookId)) {
802
+ throw new Error(`Invalid webhook id: ${JSON.stringify(webhookId)}`);
803
+ }
804
+ return webhookId;
805
+ }
806
+
807
+ export async function deleteRoomWebhook(
808
+ config: RoomSocialConfig,
809
+ room: string,
810
+ webhookId: string,
811
+ ): Promise<void> {
812
+ await socialRequest(
813
+ config,
814
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}`,
815
+ { method: "DELETE" },
816
+ );
817
+ }
818
+
819
+ /** Rotate the capability: the old URL dies immediately. */
820
+ export async function regenerateRoomWebhookToken(
821
+ config: RoomSocialConfig,
822
+ room: string,
823
+ webhookId: string,
824
+ ): Promise<RoomWebhook> {
825
+ const result = await socialRequest<{ webhook?: RoomWebhook }>(
826
+ config,
827
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}/regenerate`,
828
+ { method: "POST" },
829
+ );
830
+ if (!result.webhook) throw new Error("Webhook regenerate returned no webhook");
831
+ return result.webhook;
832
+ }
833
+
747
834
  export async function setSpaceMemberRoles(
748
835
  config: RoomSocialConfig,
749
836
  spaceId: string,
@@ -1197,6 +1284,47 @@ export async function markRoomRepliesRead(
1197
1284
  return { markedRead: result.markedRead ?? 0 };
1198
1285
  }
1199
1286
 
1287
+ /**
1288
+ * The chat-scoped notification inbox (GET /users/notifications): the durable
1289
+ * rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
1290
+ * Voucher-reachable like the rest of the chat surface (the bearer token may
1291
+ * be an API key or a chat voucher). Pagination is the composite cursor: pass
1292
+ * the previous page's lastCursor and lastCursorId back TOGETHER (the store
1293
+ * rejects one without the other); unread=true filters to unread rows.
1294
+ */
1295
+ export async function listRoomNotifications(
1296
+ config: RoomSocialConfig,
1297
+ options: {
1298
+ limit?: number | undefined;
1299
+ /** Feed a page's lastCursor back here (explicit undefined reads as
1300
+ * "first page", so `page.lastCursor ?? undefined` composes under
1301
+ * exactOptionalPropertyTypes). */
1302
+ cursor?: string | undefined;
1303
+ cursorId?: string | undefined;
1304
+ unread?: boolean | undefined;
1305
+ signal?: AbortSignal | undefined;
1306
+ } = {},
1307
+ ): Promise<RoomNotificationInboxResult> {
1308
+ const params = new URLSearchParams();
1309
+ if (options.limit !== undefined) params.set("limit", String(options.limit));
1310
+ if (options.cursor !== undefined) params.set("cursor", options.cursor);
1311
+ if (options.cursorId !== undefined) params.set("cursorId", options.cursorId);
1312
+ if (options.unread !== undefined) params.set("unread", String(options.unread));
1313
+ const init: { signal?: AbortSignal } = {};
1314
+ if (options.signal) init.signal = options.signal;
1315
+ const result = await socialRequest<Partial<RoomNotificationInboxResult>>(
1316
+ config,
1317
+ `/users/notifications${params.size ? `?${params.toString()}` : ""}`,
1318
+ init,
1319
+ );
1320
+ return {
1321
+ notifications: result.notifications ?? [],
1322
+ hasMore: result.hasMore ?? false,
1323
+ lastCursor: result.lastCursor ?? null,
1324
+ lastCursorId: result.lastCursorId ?? null,
1325
+ };
1326
+ }
1327
+
1200
1328
  export async function suggestRooms(
1201
1329
  config: RoomSocialConfig,
1202
1330
  request: RoomSuggestionRequest,
@@ -1224,12 +1352,16 @@ export async function socialRequest<T>(
1224
1352
  signal?: AbortSignal;
1225
1353
  formData?: FormData;
1226
1354
  headers?: Record<string, string>;
1355
+ /** Fetch cache mode; token-bearing reads pass "no-store" so posting
1356
+ * credentials never persist in a browser HTTP cache. */
1357
+ cache?: RequestCache;
1227
1358
  } = {},
1228
1359
  ): Promise<T> {
1229
1360
  const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
1230
1361
  const hasJsonBody = init.body !== undefined;
1231
1362
  const res = await fetch(url, {
1232
1363
  method: init.method ?? "GET",
1364
+ ...(init.cache ? { cache: init.cache } : {}),
1233
1365
  headers: {
1234
1366
  Authorization: `Bearer ${config.apiKey}`,
1235
1367
  Accept: "application/json",
@@ -192,6 +192,48 @@ export interface RoomRepliesReadResult {
192
192
  markedRead: number;
193
193
  }
194
194
 
195
+ /** The room kinds the chat notification inbox returns (the store's
196
+ * ROOM_INBOX_NOTIFICATION_TYPES). ROOM_FOLLOW rows stay out of the inbox:
197
+ * follow is a push channel, not a badge source. */
198
+ export type RoomNotificationKind = "ROOM_MENTION" | "ROOM_REPLY" | "ROOM_DM" | "ROOM_KEYWORD";
199
+
200
+ /** Shared room payload on a notification row, wire names verbatim from the
201
+ * store's notification schema (room_message_data). `preview` follows the
202
+ * push body rules (140 chars, empty for sealed content); `hash` is the
203
+ * client deep link. */
204
+ export interface RoomNotificationMessageData {
205
+ kind: "dm" | "mention" | "reply" | "follow" | "keyword";
206
+ room_id?: string;
207
+ room_name?: string;
208
+ space_id?: string;
209
+ conversation_id?: string;
210
+ seq?: number;
211
+ from_handle: string;
212
+ preview: string;
213
+ hash: string;
214
+ }
215
+
216
+ /** One chat notification inbox row: the store's explicit field pick
217
+ * (snake_case wire names, dates as ISO strings, room_message_data only
218
+ * when the row carries one). */
219
+ export interface RoomNotificationInboxItem {
220
+ id: string;
221
+ type: RoomNotificationKind;
222
+ created_at: string;
223
+ read_at: string | null;
224
+ room_message_data?: RoomNotificationMessageData;
225
+ }
226
+
227
+ /** A newest-first inbox page plus the composite cursor naming the last row
228
+ * returned; feed lastCursor and lastCursorId back together to request the
229
+ * next page. Both are null on an empty page. */
230
+ export interface RoomNotificationInboxResult {
231
+ notifications: RoomNotificationInboxItem[];
232
+ hasMore: boolean;
233
+ lastCursor: string | null;
234
+ lastCursorId: string | null;
235
+ }
236
+
195
237
  /** A living markdown doc scoped to a space (head state; content on demand). */
196
238
  export interface RoomDoc {
197
239
  docId: string;