@openmarket/rooms-client 0.5.1 → 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)
@@ -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.
@@ -501,7 +513,7 @@ export interface RoomDmMessage {
501
513
  createdAt?: string | number | undefined;
502
514
  /** Sealed DM: content is '' and the opaque envelope rides here. */
503
515
  secret?: RoomLedgerSecretPayload | null | undefined;
504
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
516
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
505
517
  /** Legacy public chat-image URL (renders as-is, no signing). */
506
518
  imageUrl?: string | null | undefined;
507
519
  /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
@@ -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;
@@ -395,6 +420,9 @@ export declare function socialRequest<T>(config: RoomSocialConfig, path: string,
395
420
  signal?: AbortSignal;
396
421
  formData?: FormData;
397
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;
398
426
  }): Promise<T>;
399
427
  export declare function cleanUsername(username: string): string;
400
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
  }
@@ -636,6 +666,7 @@ export async function socialRequest(config, path, init = {}) {
636
666
  const hasJsonBody = init.body !== undefined;
637
667
  const res = await fetch(url, {
638
668
  method: init.method ?? "GET",
669
+ ...(init.cache ? { cache: init.cache } : {}),
639
670
  headers: {
640
671
  Authorization: `Bearer ${config.apiKey}`,
641
672
  Accept: "application/json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.5.1",
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 {
@@ -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.
@@ -593,7 +606,7 @@ export interface RoomDmMessage {
593
606
  createdAt?: string | number | undefined;
594
607
  /** Sealed DM: content is '' and the opaque envelope rides here. */
595
608
  secret?: RoomLedgerSecretPayload | null | undefined;
596
- metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
609
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
597
610
  /** Legacy public chat-image URL (renders as-is, no signing). */
598
611
  imageUrl?: string | null | undefined;
599
612
  /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
@@ -749,6 +749,88 @@ export async function deleteSpaceRole(
749
749
  );
750
750
  }
751
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
+
752
834
  export async function setSpaceMemberRoles(
753
835
  config: RoomSocialConfig,
754
836
  spaceId: string,
@@ -1270,12 +1352,16 @@ export async function socialRequest<T>(
1270
1352
  signal?: AbortSignal;
1271
1353
  formData?: FormData;
1272
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;
1273
1358
  } = {},
1274
1359
  ): Promise<T> {
1275
1360
  const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
1276
1361
  const hasJsonBody = init.body !== undefined;
1277
1362
  const res = await fetch(url, {
1278
1363
  method: init.method ?? "GET",
1364
+ ...(init.cache ? { cache: init.cache } : {}),
1279
1365
  headers: {
1280
1366
  Authorization: `Bearer ${config.apiKey}`,
1281
1367
  Accept: "application/json",