@openmarket/rooms-client 0.5.1 → 0.7.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.
@@ -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,7 +183,10 @@ export function isTriggeringEntry(
172
183
  ): boolean {
173
184
  if (entry.type !== RoomLedgerEntryType.POST) return false;
174
185
  if (entry.isAgent) return false;
175
- if (!entry.text?.trim()) return false;
186
+ if (isWebhookEntry(entry)) return false;
187
+ // A poll is substantive content even with no caption, and a captionless
188
+ // poll REPLY is addressed at us like any other reply.
189
+ if (!entry.text?.trim() && entry.poll == null) return false;
176
190
  // Never trigger on our own message (we post as this handle/userId).
177
191
  const fromHandle = normalizeHandle(entry.handle).toLowerCase();
178
192
  const me = normalizeHandle(ownHandle).toLowerCase();
@@ -185,7 +199,9 @@ export function isTriggeringEntry(
185
199
  // old mentionsAgent) fired on ordinary words for short handles like "may" /
186
200
  // "btc"; armed posting has no human review, so it triggers ONLY on an `@handle`
187
201
  // token. Empty handle never matches (mentionsAgentExplicit guards it).
188
- return mentionsAgentExplicit(entry.text, ownHandle);
202
+ // Mentions can live in the poll question too (member-authored content).
203
+ const mentionSource = [entry.text ?? "", entry.poll?.question ?? ""].filter(Boolean).join(" ");
204
+ return mentionsAgentExplicit(mentionSource, ownHandle);
189
205
  }
190
206
 
191
207
  /**
@@ -379,11 +395,12 @@ export function isHumanResetEntry(
379
395
  ): boolean {
380
396
  if (entry.type !== RoomLedgerEntryType.POST) return false;
381
397
  if (entry.isAgent) return false;
398
+ if (isWebhookEntry(entry)) return false;
382
399
  if (entry.userId === ownUserId) return false;
383
400
  const fromHandle = normalizeHandle(entry.handle).toLowerCase();
384
401
  const me = normalizeHandle(ownHandle).toLowerCase();
385
402
  if (me.length > 0 && fromHandle === me) return false;
386
- return Boolean(entry.text?.trim());
403
+ return Boolean(entry.text?.trim()) || entry.poll != null;
387
404
  }
388
405
 
389
406
  /** Note a turn/post error against the kill switch; returns true once the room
@@ -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;
@@ -16,6 +24,11 @@ export type AgentMessage =
16
24
  export interface ToolDispatchResult {
17
25
  isError?: true;
18
26
  content: string;
27
+ /** Full-fidelity render payload for in-process SURFACES (e.g. the TUI's
28
+ * backtest card). Present only on the streamed copy channels consume —
29
+ * the agent runtime strips it before the result reaches the model's
30
+ * messages or the conversation store, so it never costs context. */
31
+ artifact?: unknown;
19
32
  }
20
33
 
21
34
  export interface LlmTool {
@@ -79,7 +92,10 @@ export type RunLlmTurn = (input: RunLlmTurnInput) => AsyncIterable<TurnEvent>;
79
92
  export function stringifyToolContent(value: unknown): string {
80
93
  if (value === undefined) return "";
81
94
  if (typeof value === "string") return value;
82
- return JSON.stringify(value, null, 2);
95
+ // Compact on purpose: this string is LLM input, and 2-space pretty-printing
96
+ // inflated every structured tool result by roughly a fifth in tokens for
97
+ // zero model benefit.
98
+ return JSON.stringify(value);
83
99
  }
84
100
 
85
101
  export function parseToolArguments(raw: unknown): unknown {
@@ -26,6 +26,20 @@ import type {
26
26
  /** The reserved bucket key for untopicked (linear) messages. */
27
27
  export const UNTOPICKED_BUCKET = "";
28
28
 
29
+ /** Effective attention for a bucket: an elapsed timed mute reads as the
30
+ * base attention ('default'). The store expires timed mutes lazily (no
31
+ * deadline-triggered frame), so a hydrated inbox would otherwise stay
32
+ * muted past the deadline until the next hydration. A null or absent
33
+ * deadline keeps the stored attention (permanent mute). */
34
+ export function resolveTopicAttention(
35
+ state: Pick<RoomTopicBucketState, "attention" | "muteUntil">,
36
+ nowMs: number = Date.now(),
37
+ ): RoomTopicAttention {
38
+ if (state.attention !== "mute" || state.muteUntil == null) return state.attention;
39
+ const deadline = Date.parse(state.muteUntil);
40
+ return Number.isFinite(deadline) && deadline <= nowMs ? "default" : "mute";
41
+ }
42
+
29
43
  export interface TopicInboxItem {
30
44
  /** Bucket key: a topicId, or UNTOPICKED_BUCKET for the linear lane. */
31
45
  key: string;
@@ -141,7 +155,7 @@ export class RoomTopicInbox {
141
155
  }
142
156
 
143
157
  attentionFor(topicId: string): RoomTopicAttention {
144
- return this.bucketFor(topicId).attention;
158
+ return resolveTopicAttention(this.bucketFor(topicId));
145
159
  }
146
160
 
147
161
  /** A registry mutation arrived (TOPIC_UPDATED, or a verb result echo). */
@@ -196,6 +210,10 @@ export class RoomTopicInbox {
196
210
  this.buckets.set(topicId, bucket);
197
211
  }
198
212
  bucket.attention = attention;
213
+ // The mirror knows only the new attention: a stale deadline from a
214
+ // previous timed mute must not expire it. The next hydration restores
215
+ // any server-side deadline.
216
+ delete bucket.muteUntil;
199
217
  }
200
218
 
201
219
  /**
@@ -224,7 +242,7 @@ export class RoomTopicInbox {
224
242
  channelUnread(): number {
225
243
  let total = 0;
226
244
  for (const bucket of this.buckets.values()) {
227
- if (bucket.attention === "mute") continue;
245
+ if (resolveTopicAttention(bucket) === "mute") continue;
228
246
  total += bucket.unreadCount;
229
247
  }
230
248
  return total;
@@ -241,7 +259,7 @@ export class RoomTopicInbox {
241
259
  inboxItems(): TopicInboxItem[] {
242
260
  const items: TopicInboxItem[] = [];
243
261
  const untopicked = this.bucketFor(UNTOPICKED_BUCKET);
244
- if (untopicked.unreadCount > 0 && untopicked.attention !== "mute") {
262
+ if (untopicked.unreadCount > 0 && resolveTopicAttention(untopicked) !== "mute") {
245
263
  items.push({
246
264
  key: UNTOPICKED_BUCKET,
247
265
  topic: undefined,
@@ -254,11 +272,11 @@ export class RoomTopicInbox {
254
272
  for (const topic of this.topics.values()) {
255
273
  if (topic.mergedInto) continue;
256
274
  const state = this.bucketFor(topic.topicId);
257
- if (state.attention === "mute") continue;
275
+ if (resolveTopicAttention(state) === "mute") continue;
258
276
  let aliasUnread = 0;
259
277
  for (const alias of this.aliasesOf(topic.topicId)) {
260
278
  const aliasState = this.bucketFor(alias);
261
- if (aliasState.attention === "mute") continue;
279
+ if (resolveTopicAttention(aliasState) === "mute") continue;
262
280
  aliasUnread += aliasState.unreadCount;
263
281
  }
264
282
  if (state.unreadCount + aliasUnread === 0) continue;
@@ -250,6 +250,19 @@ export interface RoomLedgerEntry {
250
250
  since?: string | number | null | undefined;
251
251
  reactions?: Array<{ emoji: string; count: number }> | undefined;
252
252
  editedAt?: string | number | Date | undefined;
253
+ /** Database-serialized edit counter: positive integer, strictly
254
+ * increasing per event row, incremented on every successful text or
255
+ * caption edit; absent means never edited. Orders caption edits where
256
+ * wall-clock editedAt stamps can tie or skew across store replicas.
257
+ * Missing on stores older than edit revisions. */
258
+ editRevision?: number | undefined;
259
+ /** Database-serialized card-mutation counter: positive integer,
260
+ * strictly increasing per event row, bumped on every unfurl card
261
+ * mutation (attach, edit-drop, purge); absent means cards were never
262
+ * mutated. Consumers apply card patches only at or above their known
263
+ * revision, making stale post-removal unfurl frames inert. Missing on
264
+ * stores older than unfurl revisions. */
265
+ unfurlsRevision?: number | undefined;
253
266
  deletedAt?: string | number | Date | undefined;
254
267
  deletedBy?: string | undefined;
255
268
  replyCount?: number | undefined;
@@ -258,8 +271,14 @@ export interface RoomLedgerEntry {
258
271
  pinnedBy?: string | null | undefined;
259
272
  pinnedAt?: string | number | Date | null | undefined;
260
273
  attachments?: RoomAttachment[] | undefined;
274
+ /** Counts-only poll summary when this message carries a poll. Additive:
275
+ * non-poll entries omit it, and live updates ride ENTRY_UPDATED. */
276
+ poll?: RoomPollSummary | null | undefined;
277
+ /** Server-rendered link unfurl cards. Additive: entries without links
278
+ * (and servers older than the unfurl worker) omit it. */
279
+ unfurls?: RoomUnfurl[] | undefined;
261
280
  roomRole?: RoomRole | null | undefined;
262
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
281
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
263
282
  }
264
283
 
265
284
  export type RoomForwardedFrom =
@@ -278,6 +297,19 @@ export interface RoomForwardMetadata {
278
297
  forwardedFrom: RoomForwardedFrom;
279
298
  }
280
299
 
300
+ /** Sender identity for messages posted through a channel webhook (the
301
+ * Discord-compatible ingest). Entries carrying it render the webhook's
302
+ * name and avatar with an APP-style badge; the entry's handle mirrors
303
+ * the display name and its userId is the synthetic `webhook:<id>`, so a
304
+ * webhook can never impersonate a member. */
305
+ export interface RoomWebhookMetadata {
306
+ webhook: {
307
+ id: string;
308
+ name: string;
309
+ avatarUrl?: string | undefined;
310
+ };
311
+ }
312
+
281
313
  export interface RoomAttachment {
282
314
  /**
283
315
  * Object key under room-attachments/; clients sign it at render time.
@@ -300,6 +332,49 @@ export interface RoomAttachment {
300
332
  */
301
333
  export type RoomAttachmentInput = RoomAttachment & { key: string };
302
334
 
335
+ export interface RoomPollOption {
336
+ id: string;
337
+ text: string;
338
+ /** Vote count for this option. Counts only, never voter identities
339
+ * (voters are fetched on demand, exactly like reactors). */
340
+ count: number;
341
+ }
342
+
343
+ /**
344
+ * Counts-only poll summary denormalized onto the carrying message (a poll
345
+ * IS a message). Vote/close/edit updates ride the existing ENTRY_UPDATED
346
+ * frame with the refreshed summary; the server's side collections stay the
347
+ * authoritative copy.
348
+ */
349
+ export interface RoomPollSummary {
350
+ question: string;
351
+ options: RoomPollOption[];
352
+ multi: boolean;
353
+ /** ISO date when the poll auto-closes; null or absent means open-ended. */
354
+ closesAt?: string | null | undefined;
355
+ closed: boolean;
356
+ /** Store-serialized poll ordering revision, strictly increasing per
357
+ * poll: consumers reject frames below their known revision so
358
+ * bus-reordered vote/close updates cannot regress tallies. Present
359
+ * where the relay forwards it; missing on older relays. */
360
+ revision?: number | undefined;
361
+ }
362
+
363
+ /**
364
+ * Server-rendered link unfurl card on an entry. `url` is the canonical
365
+ * URL; `thumbnailKey` is the re-hosted copy in the attachments bucket,
366
+ * signed through the member-scoped attachment path. The origin's image URL
367
+ * never crosses the wire. Cards appear via ENTRY_UPDATED when the unfurl
368
+ * worker lands.
369
+ */
370
+ export interface RoomUnfurl {
371
+ url: string;
372
+ title?: string | undefined;
373
+ description?: string | undefined;
374
+ siteName?: string | undefined;
375
+ thumbnailKey?: string | undefined;
376
+ }
377
+
303
378
  /**
304
379
  * A registry-backed topic inside a channel. The id is permanent: rename
305
380
  * changes `name` only, and links (`om://topic/<topicId>`) keep resolving.
@@ -370,6 +445,21 @@ export interface RoomReadStatePayload {
370
445
  * Present when the store surfaces it and on ROOM_SET_ATTENTION acks
371
446
  * (which reply READ_STATE scoped to the one room, readState empty). */
372
447
  attention?: Record<string, RoomTopicAttention> | undefined;
448
+ /** Per-room timed-mute expiry (ISO date), keyed like attention. Rides
449
+ * beside a 'mute' attention entry when the mute carries an expiry; null
450
+ * marks an explicitly cleared or permanent mute. A lapsed expiry never
451
+ * appears: the server resolves it at read time and the attention value
452
+ * reads as its base ('default'). Missing on servers older than
453
+ * mute-with-duration. */
454
+ muteUntil?: Record<string, string | null> | undefined;
455
+ /** Per-room attention commit revision, keyed like attention: a
456
+ * database-serialized, strictly increasing counter per (user, room),
457
+ * present only for rooms whose attention has ever been committed.
458
+ * ORDERING CONTRACT: a consumer that has synced revision R for a room
459
+ * must ignore snapshot attention entries whose revision is <= R (a
460
+ * snapshot read before a newer commit can arrive after that commit's
461
+ * sync frame). Missing on stores older than attention revisions. */
462
+ attentionRevision?: Record<string, number> | undefined;
373
463
  /** Unread mention counts by room, server-computed from the notify
374
464
  * worker's ROOM_MENTION rows so badges survive reload and clear through
375
465
  * the ack path. Unlike readState/attention the map is SPARSE AND
@@ -593,7 +683,7 @@ export interface RoomDmMessage {
593
683
  createdAt?: string | number | undefined;
594
684
  /** Sealed DM: content is '' and the opaque envelope rides here. */
595
685
  secret?: RoomLedgerSecretPayload | null | undefined;
596
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
686
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
597
687
  /** Legacy public chat-image URL (renders as-is, no signing). */
598
688
  imageUrl?: string | null | undefined;
599
689
  /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
@@ -982,6 +1072,14 @@ export interface RoomTopicBucketState {
982
1072
  unreadCount: number;
983
1073
  cursor: number;
984
1074
  attention: RoomTopicAttention;
1075
+ /** Standing timed-mute deadline (ISO date; null = permanent mute).
1076
+ * Present only on the room-wide '' bucket and only while its attention
1077
+ * is an active 'mute'; never on topic buckets. Same semantics as the
1078
+ * read-state muteUntil: the store expires timed mutes lazily, so
1079
+ * consumers resolve an elapsed deadline back to the base attention
1080
+ * ('default') at read time (see resolveTopicAttention). Missing on
1081
+ * servers older than mute-with-duration. */
1082
+ muteUntil?: string | null | undefined;
985
1083
  }
986
1084
 
987
1085
  export interface RoomTopicResultPayload {