@openmarket/rooms-client 0.9.0 → 0.11.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/README.md CHANGED
@@ -6,6 +6,12 @@ The OM Rooms protocol client: wire types and frame schemas, the WebSocket client
6
6
 
7
7
  Everything in this package runs unmodified in a browser: no `node:` or `bun:` imports, no filesystem or OS access, static imports only. Environment reads go through `envString()` in `endpoints.ts`, which returns `undefined` where `process` does not exist. The repo's boundary gate enforces this on every CI run; if you need daemon-side facilities (SQLite, vault, HTTP server), they live in the `om` CLI, not here.
8
8
 
9
+ Provider adapters and agent runtime contracts are also CLI-owned. In particular,
10
+ LLM messages, streamed turn events, and usage accounting live under
11
+ `packages/cli/src/agent/`; this package must not import a provider SDK or expose
12
+ those contracts as public subpaths. Only browser-safe rooms protocol and view-model
13
+ types belong here, and the CLI may depend on them in that direction.
14
+
9
15
  ## Usage
10
16
 
11
17
  There is no root export; import the surface you need by subpath:
@@ -16,7 +22,7 @@ import { RoomClientMessageType, SUPPORTED_ROOM_PROTOCOL_VERSION } from "@openmar
16
22
  import type { RoomSpaceSummary } from "@openmarket/rooms-client/social-types";
17
23
  ```
18
24
 
19
- Subpaths: `client`, `ws-client`, `social-client`, `social-types`, `library-publisher`, `doc-reconcile`, `topic-uri`, `doc-uri`, `endpoints`, `jwt`, `names`, `suggestion-attribution`, `merge3`, `types`, `version`, `agent/{armed-mode, lane-draft, lane-prompts, library-context, library-model, room-context, sidebar-model, topic-inbox, topic-view-batcher}`, `agent/clients/common`, `shared/{rooms-protocol, mentions, emoji-data}`.
25
+ Subpaths: `client`, `ws-client`, `social-client`, `social-types`, `library-publisher`, `doc-reconcile`, `topic-uri`, `doc-uri`, `endpoints`, `jwt`, `names`, `suggestion-attribution`, `merge3`, `types`, `version`, `agent/{armed-mode, lane-draft, lane-prompts, library-context, library-model, room-context, sidebar-model, topic-inbox, topic-view-batcher}`, `shared/{rooms-protocol, mentions, emoji-data}`.
20
26
 
21
27
  Bun consumers resolve raw TypeScript via the `bun` export condition; Node and bundlers get compiled JS plus `.d.ts` from `dist/`.
22
28
 
@@ -181,10 +181,11 @@ export declare function canPostAutonomously(input: {
181
181
  * successful `post()`. Mutates `state`.
182
182
  */
183
183
  export declare function recordAutonomousPost(state: ArmedRoomState, now?: number): void;
184
- /** Record token spend from an armed turn against the session cost guard. Reads
185
- * `totalTokens` from a turn's `usage` (pi-ai shape) when present; a no-op when
186
- * the provider reported nothing (the post counter is the fallback guard). */
187
- export declare function recordArmedTurnUsage(state: ArmedRoomState, usage: Record<string, unknown> | undefined): void;
184
+ /** Record token spend from an armed turn against the session cost guard. The
185
+ * caller owns provider usage shapes; armed mode needs only the canonical
186
+ * total. A missing or invalid value is a no-op because the post counter is
187
+ * the fallback guard. */
188
+ export declare function recordArmedTurnUsage(state: ArmedRoomState, totalTokens: number | undefined): void;
188
189
  /** A human (non-agent) POST that is NOT our own resets the consecutive-chain
189
190
  * counter, freeing a chain-limit pause. Pure. */
190
191
  export declare function isHumanResetEntry(entry: RoomLedgerEntry, ownHandle: string, ownUserId: string): boolean;
@@ -299,25 +299,14 @@ export function recordAutonomousPost(state, now = Date.now()) {
299
299
  state.consecutiveAutoPosts += 1;
300
300
  state.postsThisSession += 1;
301
301
  }
302
- /** Record token spend from an armed turn against the session cost guard. Reads
303
- * `totalTokens` from a turn's `usage` (pi-ai shape) when present; a no-op when
304
- * the provider reported nothing (the post counter is the fallback guard). */
305
- export function recordArmedTurnUsage(state, usage) {
306
- const total = readTotalTokens(usage);
307
- if (total > 0)
308
- state.tokensThisSession += total;
309
- }
310
- function readTotalTokens(usage) {
311
- if (!usage)
312
- return 0;
313
- const direct = usage.totalTokens;
314
- if (typeof direct === "number" && Number.isFinite(direct) && direct > 0)
315
- return direct;
316
- // Fall back to summing input + output if a client reports those instead.
317
- const input = typeof usage.input === "number" ? usage.input : 0;
318
- const output = typeof usage.output === "number" ? usage.output : 0;
319
- const sum = input + output;
320
- return Number.isFinite(sum) && sum > 0 ? sum : 0;
302
+ /** Record token spend from an armed turn against the session cost guard. The
303
+ * caller owns provider usage shapes; armed mode needs only the canonical
304
+ * total. A missing or invalid value is a no-op because the post counter is
305
+ * the fallback guard. */
306
+ export function recordArmedTurnUsage(state, totalTokens) {
307
+ if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens <= 0)
308
+ return;
309
+ state.tokensThisSession += totalTokens;
321
310
  }
322
311
  /** A human (non-agent) POST that is NOT our own resets the consecutive-chain
323
312
  * counter, freeing a chain-limit pause. Pure. */
@@ -0,0 +1,17 @@
1
+ import type { RoomRole } from "./rooms-protocol.js";
2
+ /** Channel-scope actions, byte-stable in store order. */
3
+ export declare const OM_ROOM_PERMISSION_ACTIONS: readonly ["post", "edit-own", "delete-any", "pin", "invite", "remove-member", "set-read-only", "manage-roles", "mute", "kick", "ban", "unban", "view-audit", "manage-room", "mass-mention"];
4
+ export type OmRoomPermissionAction = (typeof OM_ROOM_PERMISSION_ACTIONS)[number];
5
+ /** Space-scope actions, byte-stable in store order. */
6
+ export declare const OM_SPACE_PERMISSION_ACTIONS: readonly ["create-channel", "manage-server", "manage-server-roles", "create-invite", "manage-invites", "manage-members", "manage-nicknames", "change-nickname", "administrator"];
7
+ export type OmSpacePermissionAction = (typeof OM_SPACE_PERMISSION_ACTIONS)[number];
8
+ /** Any grantable action. The two vocabularies are disjoint; a role's
9
+ * grants[] may hold values from both. */
10
+ export type OmPermissionAction = OmRoomPermissionAction | OmSpacePermissionAction;
11
+ /** The space ladder (RoomSpaceMember.role). Channel tiers are RoomRole. */
12
+ export type OmSpaceLadderRole = "owner" | "admin" | "librarian" | "member";
13
+ /** Channel ladder base: what each channel tier natively holds. */
14
+ export declare const OM_ROOM_PERMISSION_MATRIX: Record<RoomRole, ReadonlySet<OmRoomPermissionAction>>;
15
+ /** Space ladder base: preserves the pre-grants behavior exactly (managers
16
+ * hold everything, everyone may change their own nickname). */
17
+ export declare const OM_SPACE_PERMISSION_MATRIX: Record<OmSpaceLadderRole, ReadonlySet<OmSpacePermissionAction>>;
@@ -0,0 +1,73 @@
1
+ // The permission vocabulary shared by the store, relay, and clients: the
2
+ // channel-scope actions (per-room moderation) and the space-scope actions
3
+ // (server management), plus the fixed ladder base matrices. Parity-pinned to
4
+ // the store's permission services; the store stays the authority (every verb
5
+ // re-checks server-side), this mirror only decides what clients offer.
6
+ // Effective permissions = ladder base UNION the grants of every held named
7
+ // role (one role's grants[] may mix both vocabularies). Pure and browser-safe.
8
+ /** Channel-scope actions, byte-stable in store order. */
9
+ export const OM_ROOM_PERMISSION_ACTIONS = [
10
+ "post",
11
+ "edit-own",
12
+ "delete-any",
13
+ "pin",
14
+ "invite",
15
+ "remove-member",
16
+ "set-read-only",
17
+ "manage-roles",
18
+ "mute",
19
+ "kick",
20
+ "ban",
21
+ "unban",
22
+ "view-audit",
23
+ "manage-room",
24
+ "mass-mention",
25
+ ];
26
+ /** Space-scope actions, byte-stable in store order. */
27
+ export const OM_SPACE_PERMISSION_ACTIONS = [
28
+ // create channels in this server
29
+ "create-channel",
30
+ // rename server, icon
31
+ "manage-server",
32
+ // create/edit/delete/assign named roles within reach
33
+ "manage-server-roles",
34
+ // mint a server invite
35
+ "create-invite",
36
+ // list/revoke invites, set/rotate/clear join code
37
+ "manage-invites",
38
+ // add and remove server members
39
+ "manage-members",
40
+ // change other members' nicknames
41
+ "manage-nicknames",
42
+ // change own nickname
43
+ "change-nickname",
44
+ // every action in both scopes, admin rank envelope; never the owner-only
45
+ // verbs (archive/purge space, ownership transfers)
46
+ "administrator",
47
+ ];
48
+ /** Channel ladder base: what each channel tier natively holds. */
49
+ export const OM_ROOM_PERMISSION_MATRIX = {
50
+ owner: new Set(OM_ROOM_PERMISSION_ACTIONS),
51
+ admin: new Set(OM_ROOM_PERMISSION_ACTIONS),
52
+ mod: new Set([
53
+ "post",
54
+ "edit-own",
55
+ "delete-any",
56
+ "pin",
57
+ "invite",
58
+ "remove-member",
59
+ "mute",
60
+ "kick",
61
+ "view-audit",
62
+ "mass-mention",
63
+ ]),
64
+ member: new Set(["post", "edit-own"]),
65
+ };
66
+ /** Space ladder base: preserves the pre-grants behavior exactly (managers
67
+ * hold everything, everyone may change their own nickname). */
68
+ export const OM_SPACE_PERMISSION_MATRIX = {
69
+ owner: new Set(OM_SPACE_PERMISSION_ACTIONS),
70
+ admin: new Set(OM_SPACE_PERMISSION_ACTIONS),
71
+ librarian: new Set(["change-nickname"]),
72
+ member: new Set(["change-nickname"]),
73
+ };
@@ -106,6 +106,7 @@ export declare const RoomServerMessageType: {
106
106
  readonly TOPIC_UPDATED: "TOPIC_UPDATED";
107
107
  readonly DOC_CHANGED: "DOC_CHANGED";
108
108
  readonly FRIEND_UPDATED: "FRIEND_UPDATED";
109
+ readonly SPACE_ROLES_UPDATED: "SPACE_ROLES_UPDATED";
109
110
  };
110
111
  export declare function addBounded<T>(set: Set<T>, value: T, max: number): void;
111
112
  export type RoomServerMessageType = (typeof RoomServerMessageType)[keyof typeof RoomServerMessageType];
@@ -175,6 +176,9 @@ export interface RoomMeta {
175
176
  viewerRole?: RoomRole | null | undefined;
176
177
  /** Head event sequence; diff against the read cursor for unread badges. */
177
178
  headSeq?: number | undefined;
179
+ /** Epoch milliseconds of the newest visible message or sealed message.
180
+ * Unlike room metadata timestamps, this changes only with chat content. */
181
+ lastMessageAt?: number | undefined;
178
182
  /** Topics dial for this channel. Absent reads as "allowed" since the
179
183
  * 2026-07-12 dial-default flip (post-flip relays always advertise the
180
184
  * stored value, "off" included); explicit "off" means topics are inert. */
@@ -836,6 +840,15 @@ export interface RoomSpace {
836
840
  createdAt: string | number | Date;
837
841
  joined?: boolean | undefined;
838
842
  iconUrl?: string | null | undefined;
843
+ /** Archived channels in this server (sidebar Archive row; absent on
844
+ * stores that predate the count). */
845
+ archivedRoomCount?: number | undefined;
846
+ /** Archived docs twin: keeps the Archive row reachable when only docs
847
+ * are archived. */
848
+ archivedDocCount?: number | undefined;
849
+ /** Space permission model revision; delegate-facing UI gates on >= 3.
850
+ * Absent on stores that predate space permissions. */
851
+ permissionsVersion?: number | undefined;
839
852
  }
840
853
  export interface RoomSpacesPayload {
841
854
  requestId?: string;
@@ -876,6 +889,9 @@ export interface RoomUpdatedPayload {
876
889
  room: string;
877
890
  meta?: RoomMeta | undefined;
878
891
  archived?: boolean | undefined;
892
+ /** Rides WITH archived on hard deletion: old clients evict on archived,
893
+ * new clients word the eviction honestly. */
894
+ deleted?: boolean | undefined;
879
895
  }
880
896
  /** Server settings changed (rename / icon / archive). */
881
897
  export interface SpaceUpdatedPayload {
@@ -883,6 +899,14 @@ export interface SpaceUpdatedPayload {
883
899
  name?: string | undefined;
884
900
  iconUrl?: string | null | undefined;
885
901
  archived?: boolean | undefined;
902
+ /** Rides WITH archived on hard deletion (see RoomUpdatedPayload). */
903
+ deleted?: boolean | undefined;
904
+ }
905
+ /** Space roles changed (role CRUD, member role assignment, @everyone
906
+ * grants): a poke, not a diff. Clients invalidate their space-roles cache
907
+ * and refetch lazily. */
908
+ export interface SpaceRolesUpdatedPayload {
909
+ spaceId: string;
886
910
  }
887
911
  export interface RoomSpaceJoinedPayload {
888
912
  requestId?: string;
@@ -1417,6 +1441,10 @@ export type RoomServerMessage = {
1417
1441
  type: typeof RoomServerMessageType.SPACE_UPDATED;
1418
1442
  payload: SpaceUpdatedPayload;
1419
1443
  timestamp?: number;
1444
+ } | {
1445
+ type: typeof RoomServerMessageType.SPACE_ROLES_UPDATED;
1446
+ payload: SpaceRolesUpdatedPayload;
1447
+ timestamp?: number;
1420
1448
  } | {
1421
1449
  type: typeof RoomServerMessageType.SPACE_INVITE;
1422
1450
  payload: SpaceInvitePayload;
@@ -105,6 +105,7 @@ export const RoomServerMessageType = {
105
105
  TOPIC_UPDATED: "TOPIC_UPDATED",
106
106
  DOC_CHANGED: "DOC_CHANGED",
107
107
  FRIEND_UPDATED: "FRIEND_UPDATED",
108
+ SPACE_ROLES_UPDATED: "SPACE_ROLES_UPDATED",
108
109
  };
109
110
  export function addBounded(set, value, max) {
110
111
  set.add(value);
@@ -1,4 +1,4 @@
1
- import type { RoomTopicAttention } from "./shared/rooms-protocol.js";
1
+ import type { RoomRole, RoomTopicAttention } from "./shared/rooms-protocol.js";
2
2
  import type { RoomBookmarkSaved, RoomBookmarksResult, RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomNotificationInboxResult, RoomPollVotersResult, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomRestEvent, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest, RoomUserBlock, RoomUserBlocksResult } from "./social-types.js";
3
3
  export type { RoomAttachment, RoomBookmark, RoomBookmarkSaved, RoomBookmarksResult, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomNotificationInboxItem, RoomNotificationInboxResult, RoomNotificationKind, RoomNotificationMessageData, RoomNotifyQuietHours, RoomPollOptionVoters, RoomPollVotersResult, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomRestEvent, RoomSocialConfig, RoomSpaceNotificationOverride, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, RoomsNotificationSettings, RoomUserBlock, RoomUserBlocksResult, } from "./social-types.js";
4
4
  /** Largest per-file size any tier accepts (plus tier). The server enforces
@@ -171,6 +171,16 @@ export interface RoomSpaceSummary {
171
171
  iconUrl?: string | null;
172
172
  /** Creation time — the stable server-rail sort key. */
173
173
  createdAt?: string | number | null;
174
+ /** Archived channels in this server; with archivedDocCount it drives
175
+ * the sidebar's Archive row (rendered only when the sum > 0). Absent
176
+ * on stores that predate the counts. */
177
+ archivedRoomCount?: number | undefined;
178
+ /** Archived docs in this server: the row must stay reachable when only
179
+ * docs are archived, since they have no other retrieval surface. */
180
+ archivedDocCount?: number | undefined;
181
+ /** Space permission model revision; delegate-facing UI gates on >= 3.
182
+ * Absent on stores that predate space permissions. */
183
+ permissionsVersion?: number | undefined;
174
184
  }
175
185
  export declare function listRoomSpacesViaChat(config: RoomSocialConfig): Promise<RoomSpaceSummary[]>;
176
186
  export interface RoomSpaceMember {
@@ -197,6 +207,13 @@ export interface RoomSpaceRole {
197
207
  color: string | null;
198
208
  hoist: boolean;
199
209
  position: number;
210
+ /** Permission actions this role grants, mixing the channel and space
211
+ * vocabularies (see shared/om-permissions). Absent on stores that
212
+ * predate roles-v2 grants. */
213
+ grants?: string[];
214
+ /** Discord's "Allow anyone to @mention this role". Absent on stores
215
+ * that predate it. */
216
+ mentionable?: boolean;
200
217
  }
201
218
  export declare function listSpaceRoles(config: RoomSocialConfig, spaceId: string): Promise<RoomSpaceRole[]>;
202
219
  export declare function createSpaceRole(config: RoomSocialConfig, spaceId: string, input: {
@@ -208,7 +225,12 @@ export declare function updateSpaceRole(config: RoomSocialConfig, spaceId: strin
208
225
  name?: string;
209
226
  color?: string | null;
210
227
  hoist?: boolean;
228
+ /** Discord's "Allow anyone to @mention this role". Only send it to
229
+ * stores that emit the field (strict schemas 400 on unknown keys). */
230
+ mentionable?: boolean;
211
231
  position?: number;
232
+ /** Full replace of the role's grants. */
233
+ grants?: string[];
212
234
  }): Promise<RoomSpaceRole>;
213
235
  export declare function deleteSpaceRole(config: RoomSocialConfig, spaceId: string, roleId: string): Promise<void>;
214
236
  /** A channel webhook: the Discord-compatible ingest capability. The
@@ -340,6 +362,35 @@ export declare function updateRoomChannel(config: RoomSocialConfig, room: string
340
362
  export declare function resolveRoomChannel(config: RoomSocialConfig, room: string): Promise<Record<string, unknown> & {
341
363
  topicsPolicy?: "off" | "allowed" | "required";
342
364
  }>;
365
+ /** Set or clear a space channel's role guest list. Null (or empty) clears:
366
+ * the channel opens back up to every space member. */
367
+ export declare function setRoomAllowedRoles(config: RoomSocialConfig, room: string, allowedRoleIds: string[] | null,
368
+ /** Per-member access rules (union with roles). ABSENT = leave the stored
369
+ * list untouched (and let a legacy conversion seed from current members);
370
+ * null or [] = clear; array = set. */
371
+ allowedUserIds?: string[] | null, reason?: string): Promise<Record<string, unknown>>;
372
+ export interface RoomAccessMeta {
373
+ /** Active role guest list; null = open to every space member. */
374
+ allowedRoleIds: string[] | null;
375
+ /** Per-member access rules (union with the roles); null = none. */
376
+ allowedUserIds: string[] | null;
377
+ /** Tiers allowed to post; the store default is all four. */
378
+ postingRoles: RoomRole[];
379
+ access: string | null;
380
+ spaceId: string | null;
381
+ kind: string | null;
382
+ /** The caller's resolved channel role (owner fallback = creator). */
383
+ viewerRole: RoomRole | null;
384
+ }
385
+ /** Authoritative access meta for the settings surface: the room's guest
386
+ * list, posting tiers and the caller's resolved role. Rides
387
+ * resolveRoomChannel (the relay's route ordering), which is also where the
388
+ * store places viewerRole (existence-hiding 404 preserved as a throw). */
389
+ export declare function resolveRoomAccess(config: RoomSocialConfig, room: string): Promise<RoomAccessMeta>;
390
+ /** Owner-only. The old owner becomes a channel admin; audited server-side. */
391
+ export declare function transferRoomOwnership(config: RoomSocialConfig, room: string, targetUserId: string, reason?: string): Promise<void>;
392
+ /** Lift a channel ban (no WS frame exists for unban; REST is the path). */
393
+ export declare function unbanRoomMember(config: RoomSocialConfig, room: string, targetUserId: string, reason?: string): Promise<void>;
343
394
  /** Archive a channel (owner/admin): drops from listings, refuses posts. */
344
395
  export declare function archiveRoomChannel(config: RoomSocialConfig, room: string): Promise<{
345
396
  roomId?: string;
@@ -351,10 +402,39 @@ export declare function updateRoomSpace(config: RoomSocialConfig, spaceId: strin
351
402
  access?: "open" | "invite";
352
403
  iconUrl?: string | null;
353
404
  }): Promise<RoomSpaceSummary>;
405
+ /** Owner-only. The old owner becomes a space admin; audited server-side. */
406
+ export declare function transferSpaceOwnership(config: RoomSocialConfig, spaceId: string, targetUserId: string, reason?: string): Promise<void>;
354
407
  /** Archive a server (owner only). */
355
408
  export declare function archiveRoomSpace(config: RoomSocialConfig, spaceId: string): Promise<{
356
409
  archived?: boolean;
357
410
  }>;
411
+ /** Un-archive a channel back onto the active sidebar (manage-room). The
412
+ * wire verb is /restore, the store-wide grammar shared with docs; the
413
+ * receipt carries `restored`, not the archive shape's `archived`. */
414
+ export declare function unarchiveRoomChannel(config: RoomSocialConfig, room: string): Promise<{
415
+ roomId?: string;
416
+ restored?: boolean;
417
+ }>;
418
+ /** PERMANENTLY delete a channel and every row keyed to it (manage-room).
419
+ * The bare DELETE verb on /rooms/:id is the legacy ARCHIVE shape; purge is
420
+ * the explicit destruction verb and there is no undo. */
421
+ export declare function purgeRoomChannel(config: RoomSocialConfig, room: string): Promise<{
422
+ roomId?: string;
423
+ deleted?: boolean;
424
+ }>;
425
+ /** PERMANENTLY delete a server: every channel, message, member, role,
426
+ * invite, and the entire docs family (owner only; no undo). */
427
+ export declare function purgeRoomSpace(config: RoomSocialConfig, spaceId: string): Promise<{
428
+ spaceId?: string;
429
+ deleted?: boolean;
430
+ }>;
431
+ /** A server's archived channels, for the Archive panel. */
432
+ export declare function listArchivedRoomChannels(config: RoomSocialConfig, spaceId: string): Promise<Array<{
433
+ roomId: string;
434
+ name?: string;
435
+ title?: string;
436
+ archivedAt?: string;
437
+ }>>;
358
438
  /** Full channel roster (paged): role + lastSeen for every membership row. */
359
439
  export declare function listRoomChannelMembers(config: RoomSocialConfig, roomId: string, options?: {
360
440
  offset?: number;
@@ -552,6 +552,62 @@ export async function resolveRoomChannel(config, room) {
552
552
  const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/resolve`, { method: "GET" });
553
553
  return result.room;
554
554
  }
555
+ /** Set or clear a space channel's role guest list. Null (or empty) clears:
556
+ * the channel opens back up to every space member. */
557
+ export async function setRoomAllowedRoles(config, room, allowedRoleIds,
558
+ /** Per-member access rules (union with roles). ABSENT = leave the stored
559
+ * list untouched (and let a legacy conversion seed from current members);
560
+ * null or [] = clear; array = set. */
561
+ allowedUserIds, reason) {
562
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/allowed-roles`, {
563
+ method: "PATCH",
564
+ body: {
565
+ allowedRoleIds,
566
+ ...(allowedUserIds !== undefined ? { allowedUserIds } : {}),
567
+ ...(reason ? { reason } : {}),
568
+ },
569
+ });
570
+ return result.room ?? {};
571
+ }
572
+ /** Authoritative access meta for the settings surface: the room's guest
573
+ * list, posting tiers and the caller's resolved role. Rides
574
+ * resolveRoomChannel (the relay's route ordering), which is also where the
575
+ * store places viewerRole (existence-hiding 404 preserved as a throw). */
576
+ export async function resolveRoomAccess(config, room) {
577
+ const meta = (await resolveRoomChannel(config, room)) ?? {};
578
+ const allowed = Array.isArray(meta.allowedRoleIds)
579
+ ? meta.allowedRoleIds.filter((value) => typeof value === "string")
580
+ : null;
581
+ const allowedUsers = Array.isArray(meta.allowedUserIds)
582
+ ? meta.allowedUserIds.filter((value) => typeof value === "string")
583
+ : null;
584
+ const posting = Array.isArray(meta.postingRoles)
585
+ ? meta.postingRoles.filter((value) => value === "owner" || value === "admin" || value === "mod" || value === "member")
586
+ : [];
587
+ const viewer = typeof meta.viewerRole === "string" ? meta.viewerRole : null;
588
+ return {
589
+ allowedRoleIds: allowed && allowed.length > 0 ? allowed : null,
590
+ allowedUserIds: allowedUsers && allowedUsers.length > 0 ? allowedUsers : null,
591
+ postingRoles: posting.length > 0 ? posting : ["owner", "admin", "mod", "member"],
592
+ access: typeof meta.access === "string" ? meta.access : null,
593
+ spaceId: typeof meta.spaceId === "string" ? meta.spaceId : null,
594
+ kind: typeof meta.kind === "string" ? meta.kind : null,
595
+ viewerRole: viewer === "owner" || viewer === "admin" || viewer === "mod" || viewer === "member"
596
+ ? viewer
597
+ : null,
598
+ };
599
+ }
600
+ /** Owner-only. The old owner becomes a channel admin; audited server-side. */
601
+ export async function transferRoomOwnership(config, room, targetUserId, reason) {
602
+ await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/ownership/transfer`, { method: "POST", body: { targetUserId, ...(reason ? { reason } : {}) } });
603
+ }
604
+ /** Lift a channel ban (no WS frame exists for unban; REST is the path). */
605
+ export async function unbanRoomMember(config, room, targetUserId, reason) {
606
+ await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/mod/unban`, {
607
+ method: "POST",
608
+ body: { targetUserId, ...(reason ? { reason } : {}) },
609
+ });
610
+ }
555
611
  /** Archive a channel (owner/admin): drops from listings, refuses posts. */
556
612
  export async function archiveRoomChannel(config, room) {
557
613
  return socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}`, {
@@ -563,10 +619,45 @@ export async function updateRoomSpace(config, spaceId, input) {
563
619
  const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}`, { method: "PATCH", body: input });
564
620
  return result.space;
565
621
  }
622
+ /** Owner-only. The old owner becomes a space admin; audited server-side. */
623
+ export async function transferSpaceOwnership(config, spaceId, targetUserId, reason) {
624
+ await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/ownership/transfer`, {
625
+ method: "POST",
626
+ body: { targetUserId, ...(reason ? { reason } : {}) },
627
+ });
628
+ }
566
629
  /** Archive a server (owner only). */
567
630
  export async function archiveRoomSpace(config, spaceId) {
568
631
  return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}`, { method: "DELETE" });
569
632
  }
633
+ /** Un-archive a channel back onto the active sidebar (manage-room). The
634
+ * wire verb is /restore, the store-wide grammar shared with docs; the
635
+ * receipt carries `restored`, not the archive shape's `archived`. */
636
+ export async function unarchiveRoomChannel(config, room) {
637
+ return socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/restore`, {
638
+ method: "POST",
639
+ });
640
+ }
641
+ /** PERMANENTLY delete a channel and every row keyed to it (manage-room).
642
+ * The bare DELETE verb on /rooms/:id is the legacy ARCHIVE shape; purge is
643
+ * the explicit destruction verb and there is no undo. */
644
+ export async function purgeRoomChannel(config, room) {
645
+ return socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/purge`, {
646
+ method: "POST",
647
+ });
648
+ }
649
+ /** PERMANENTLY delete a server: every channel, message, member, role,
650
+ * invite, and the entire docs family (owner only; no undo). */
651
+ export async function purgeRoomSpace(config, spaceId) {
652
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/purge`, {
653
+ method: "POST",
654
+ });
655
+ }
656
+ /** A server's archived channels, for the Archive panel. */
657
+ export async function listArchivedRoomChannels(config, spaceId) {
658
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/rooms/archived`, { method: "GET" });
659
+ return result.rooms ?? [];
660
+ }
570
661
  /** Full channel roster (paged): role + lastSeen for every membership row. */
571
662
  export async function listRoomChannelMembers(config, roomId, options = {}) {
572
663
  const params = new URLSearchParams();
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.9.0";
2
- export declare const RUNNER_VERSION = "0.9.0";
1
+ export declare const VERSION = "0.11.0";
2
+ export declare const RUNNER_VERSION = "0.11.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.9.0";
2
- export const RUNNER_VERSION = "0.9.0";
1
+ export const VERSION = "0.11.0";
2
+ export const RUNNER_VERSION = "0.11.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -137,11 +137,6 @@
137
137
  "types": "./dist/agent/topic-view-batcher.d.ts",
138
138
  "default": "./dist/agent/topic-view-batcher.js"
139
139
  },
140
- "./agent/clients/common": {
141
- "bun": "./src/agent/clients/common.ts",
142
- "types": "./dist/agent/clients/common.d.ts",
143
- "default": "./dist/agent/clients/common.js"
144
- },
145
140
  "./shared/rooms-protocol": {
146
141
  "bun": "./src/shared/rooms-protocol.ts",
147
142
  "types": "./dist/shared/rooms-protocol.d.ts",
@@ -157,6 +152,11 @@
157
152
  "types": "./dist/shared/emoji-data.d.ts",
158
153
  "default": "./dist/shared/emoji-data.js"
159
154
  },
155
+ "./shared/om-permissions": {
156
+ "bun": "./src/shared/om-permissions.ts",
157
+ "types": "./dist/shared/om-permissions.d.ts",
158
+ "default": "./dist/shared/om-permissions.js"
159
+ },
160
160
  "./version": {
161
161
  "bun": "./src/version.ts",
162
162
  "types": "./dist/version.d.ts",
@@ -171,7 +171,6 @@
171
171
  "typecheck": "tsc --noEmit -p tsconfig.json"
172
172
  },
173
173
  "dependencies": {
174
- "@earendil-works/pi-ai": "0.78.1",
175
174
  "picocolors": "1.1.1"
176
175
  },
177
176
  "devDependencies": {
@@ -362,26 +362,13 @@ export function recordAutonomousPost(state: ArmedRoomState, now = Date.now()): v
362
362
  state.postsThisSession += 1;
363
363
  }
364
364
 
365
- /** Record token spend from an armed turn against the session cost guard. Reads
366
- * `totalTokens` from a turn's `usage` (pi-ai shape) when present; a no-op when
367
- * the provider reported nothing (the post counter is the fallback guard). */
368
- export function recordArmedTurnUsage(
369
- state: ArmedRoomState,
370
- usage: Record<string, unknown> | undefined,
371
- ): void {
372
- const total = readTotalTokens(usage);
373
- if (total > 0) state.tokensThisSession += total;
374
- }
375
-
376
- function readTotalTokens(usage: Record<string, unknown> | undefined): number {
377
- if (!usage) return 0;
378
- const direct = usage.totalTokens;
379
- if (typeof direct === "number" && Number.isFinite(direct) && direct > 0) return direct;
380
- // Fall back to summing input + output if a client reports those instead.
381
- const input = typeof usage.input === "number" ? usage.input : 0;
382
- const output = typeof usage.output === "number" ? usage.output : 0;
383
- const sum = input + output;
384
- return Number.isFinite(sum) && sum > 0 ? sum : 0;
365
+ /** Record token spend from an armed turn against the session cost guard. The
366
+ * caller owns provider usage shapes; armed mode needs only the canonical
367
+ * total. A missing or invalid value is a no-op because the post counter is
368
+ * the fallback guard. */
369
+ export function recordArmedTurnUsage(state: ArmedRoomState, totalTokens: number | undefined): void {
370
+ if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens <= 0) return;
371
+ state.tokensThisSession += totalTokens;
385
372
  }
386
373
 
387
374
  /** A human (non-agent) POST that is NOT our own resets the consecutive-chain
@@ -0,0 +1,93 @@
1
+ // The permission vocabulary shared by the store, relay, and clients: the
2
+ // channel-scope actions (per-room moderation) and the space-scope actions
3
+ // (server management), plus the fixed ladder base matrices. Parity-pinned to
4
+ // the store's permission services; the store stays the authority (every verb
5
+ // re-checks server-side), this mirror only decides what clients offer.
6
+ // Effective permissions = ladder base UNION the grants of every held named
7
+ // role (one role's grants[] may mix both vocabularies). Pure and browser-safe.
8
+
9
+ import type { RoomRole } from "./rooms-protocol.js";
10
+
11
+ /** Channel-scope actions, byte-stable in store order. */
12
+ export const OM_ROOM_PERMISSION_ACTIONS = [
13
+ "post",
14
+ "edit-own",
15
+ "delete-any",
16
+ "pin",
17
+ "invite",
18
+ "remove-member",
19
+ "set-read-only",
20
+ "manage-roles",
21
+ "mute",
22
+ "kick",
23
+ "ban",
24
+ "unban",
25
+ "view-audit",
26
+ "manage-room",
27
+ "mass-mention",
28
+ ] as const;
29
+
30
+ export type OmRoomPermissionAction = (typeof OM_ROOM_PERMISSION_ACTIONS)[number];
31
+
32
+ /** Space-scope actions, byte-stable in store order. */
33
+ export const OM_SPACE_PERMISSION_ACTIONS = [
34
+ // create channels in this server
35
+ "create-channel",
36
+ // rename server, icon
37
+ "manage-server",
38
+ // create/edit/delete/assign named roles within reach
39
+ "manage-server-roles",
40
+ // mint a server invite
41
+ "create-invite",
42
+ // list/revoke invites, set/rotate/clear join code
43
+ "manage-invites",
44
+ // add and remove server members
45
+ "manage-members",
46
+ // change other members' nicknames
47
+ "manage-nicknames",
48
+ // change own nickname
49
+ "change-nickname",
50
+ // every action in both scopes, admin rank envelope; never the owner-only
51
+ // verbs (archive/purge space, ownership transfers)
52
+ "administrator",
53
+ ] as const;
54
+
55
+ export type OmSpacePermissionAction = (typeof OM_SPACE_PERMISSION_ACTIONS)[number];
56
+
57
+ /** Any grantable action. The two vocabularies are disjoint; a role's
58
+ * grants[] may hold values from both. */
59
+ export type OmPermissionAction = OmRoomPermissionAction | OmSpacePermissionAction;
60
+
61
+ /** The space ladder (RoomSpaceMember.role). Channel tiers are RoomRole. */
62
+ export type OmSpaceLadderRole = "owner" | "admin" | "librarian" | "member";
63
+
64
+ /** Channel ladder base: what each channel tier natively holds. */
65
+ export const OM_ROOM_PERMISSION_MATRIX: Record<RoomRole, ReadonlySet<OmRoomPermissionAction>> = {
66
+ owner: new Set<OmRoomPermissionAction>(OM_ROOM_PERMISSION_ACTIONS),
67
+ admin: new Set<OmRoomPermissionAction>(OM_ROOM_PERMISSION_ACTIONS),
68
+ mod: new Set<OmRoomPermissionAction>([
69
+ "post",
70
+ "edit-own",
71
+ "delete-any",
72
+ "pin",
73
+ "invite",
74
+ "remove-member",
75
+ "mute",
76
+ "kick",
77
+ "view-audit",
78
+ "mass-mention",
79
+ ]),
80
+ member: new Set<OmRoomPermissionAction>(["post", "edit-own"]),
81
+ };
82
+
83
+ /** Space ladder base: preserves the pre-grants behavior exactly (managers
84
+ * hold everything, everyone may change their own nickname). */
85
+ export const OM_SPACE_PERMISSION_MATRIX: Record<
86
+ OmSpaceLadderRole,
87
+ ReadonlySet<OmSpacePermissionAction>
88
+ > = {
89
+ owner: new Set<OmSpacePermissionAction>(OM_SPACE_PERMISSION_ACTIONS),
90
+ admin: new Set<OmSpacePermissionAction>(OM_SPACE_PERMISSION_ACTIONS),
91
+ librarian: new Set<OmSpacePermissionAction>(["change-nickname"]),
92
+ member: new Set<OmSpacePermissionAction>(["change-nickname"]),
93
+ };
@@ -109,6 +109,7 @@ export const RoomServerMessageType = {
109
109
  TOPIC_UPDATED: "TOPIC_UPDATED",
110
110
  DOC_CHANGED: "DOC_CHANGED",
111
111
  FRIEND_UPDATED: "FRIEND_UPDATED",
112
+ SPACE_ROLES_UPDATED: "SPACE_ROLES_UPDATED",
112
113
  } as const;
113
114
 
114
115
  export function addBounded<T>(set: Set<T>, value: T, max: number): void {
@@ -202,6 +203,9 @@ export interface RoomMeta {
202
203
  viewerRole?: RoomRole | null | undefined;
203
204
  /** Head event sequence; diff against the read cursor for unread badges. */
204
205
  headSeq?: number | undefined;
206
+ /** Epoch milliseconds of the newest visible message or sealed message.
207
+ * Unlike room metadata timestamps, this changes only with chat content. */
208
+ lastMessageAt?: number | undefined;
205
209
  /** Topics dial for this channel. Absent reads as "allowed" since the
206
210
  * 2026-07-12 dial-default flip (post-flip relays always advertise the
207
211
  * stored value, "off" included); explicit "off" means topics are inert. */
@@ -961,6 +965,15 @@ export interface RoomSpace {
961
965
  createdAt: string | number | Date;
962
966
  joined?: boolean | undefined;
963
967
  iconUrl?: string | null | undefined;
968
+ /** Archived channels in this server (sidebar Archive row; absent on
969
+ * stores that predate the count). */
970
+ archivedRoomCount?: number | undefined;
971
+ /** Archived docs twin: keeps the Archive row reachable when only docs
972
+ * are archived. */
973
+ archivedDocCount?: number | undefined;
974
+ /** Space permission model revision; delegate-facing UI gates on >= 3.
975
+ * Absent on stores that predate space permissions. */
976
+ permissionsVersion?: number | undefined;
964
977
  }
965
978
 
966
979
  export interface RoomSpacesPayload {
@@ -1004,6 +1017,9 @@ export interface RoomUpdatedPayload {
1004
1017
  room: string;
1005
1018
  meta?: RoomMeta | undefined;
1006
1019
  archived?: boolean | undefined;
1020
+ /** Rides WITH archived on hard deletion: old clients evict on archived,
1021
+ * new clients word the eviction honestly. */
1022
+ deleted?: boolean | undefined;
1007
1023
  }
1008
1024
 
1009
1025
  /** Server settings changed (rename / icon / archive). */
@@ -1012,6 +1028,15 @@ export interface SpaceUpdatedPayload {
1012
1028
  name?: string | undefined;
1013
1029
  iconUrl?: string | null | undefined;
1014
1030
  archived?: boolean | undefined;
1031
+ /** Rides WITH archived on hard deletion (see RoomUpdatedPayload). */
1032
+ deleted?: boolean | undefined;
1033
+ }
1034
+
1035
+ /** Space roles changed (role CRUD, member role assignment, @everyone
1036
+ * grants): a poke, not a diff. Clients invalidate their space-roles cache
1037
+ * and refetch lazily. */
1038
+ export interface SpaceRolesUpdatedPayload {
1039
+ spaceId: string;
1015
1040
  }
1016
1041
 
1017
1042
  export interface RoomSpaceJoinedPayload {
@@ -1589,6 +1614,11 @@ export type RoomServerMessage =
1589
1614
  payload: SpaceUpdatedPayload;
1590
1615
  timestamp?: number;
1591
1616
  }
1617
+ | {
1618
+ type: typeof RoomServerMessageType.SPACE_ROLES_UPDATED;
1619
+ payload: SpaceRolesUpdatedPayload;
1620
+ timestamp?: number;
1621
+ }
1592
1622
  | {
1593
1623
  type: typeof RoomServerMessageType.SPACE_INVITE;
1594
1624
  payload: SpaceInvitePayload;
@@ -1,6 +1,6 @@
1
1
  import { resolveRoomsWsUrl } from "./client.js";
2
2
  import { envString, ROOM_CHAT_API_URL } from "./endpoints.js";
3
- import type { RoomTopicAttention } from "./shared/rooms-protocol.js";
3
+ import type { RoomRole, RoomTopicAttention } from "./shared/rooms-protocol.js";
4
4
  import type {
5
5
  RoomBookmarkSaved,
6
6
  RoomBookmarksResult,
@@ -671,6 +671,16 @@ export interface RoomSpaceSummary {
671
671
  iconUrl?: string | null;
672
672
  /** Creation time — the stable server-rail sort key. */
673
673
  createdAt?: string | number | null;
674
+ /** Archived channels in this server; with archivedDocCount it drives
675
+ * the sidebar's Archive row (rendered only when the sum > 0). Absent
676
+ * on stores that predate the counts. */
677
+ archivedRoomCount?: number | undefined;
678
+ /** Archived docs in this server: the row must stay reachable when only
679
+ * docs are archived, since they have no other retrieval surface. */
680
+ archivedDocCount?: number | undefined;
681
+ /** Space permission model revision; delegate-facing UI gates on >= 3.
682
+ * Absent on stores that predate space permissions. */
683
+ permissionsVersion?: number | undefined;
674
684
  }
675
685
 
676
686
  export async function listRoomSpacesViaChat(config: RoomSocialConfig): Promise<RoomSpaceSummary[]> {
@@ -713,6 +723,13 @@ export interface RoomSpaceRole {
713
723
  color: string | null;
714
724
  hoist: boolean;
715
725
  position: number;
726
+ /** Permission actions this role grants, mixing the channel and space
727
+ * vocabularies (see shared/om-permissions). Absent on stores that
728
+ * predate roles-v2 grants. */
729
+ grants?: string[];
730
+ /** Discord's "Allow anyone to @mention this role". Absent on stores
731
+ * that predate it. */
732
+ mentionable?: boolean;
716
733
  }
717
734
 
718
735
  export async function listSpaceRoles(
@@ -744,7 +761,17 @@ export async function updateSpaceRole(
744
761
  config: RoomSocialConfig,
745
762
  spaceId: string,
746
763
  roleId: string,
747
- patch: { name?: string; color?: string | null; hoist?: boolean; position?: number },
764
+ patch: {
765
+ name?: string;
766
+ color?: string | null;
767
+ hoist?: boolean;
768
+ /** Discord's "Allow anyone to @mention this role". Only send it to
769
+ * stores that emit the field (strict schemas 400 on unknown keys). */
770
+ mentionable?: boolean;
771
+ position?: number;
772
+ /** Full replace of the role's grants. */
773
+ grants?: string[];
774
+ },
748
775
  ): Promise<RoomSpaceRole> {
749
776
  const result = await socialRequest<{ role?: RoomSpaceRole }>(
750
777
  config,
@@ -1147,6 +1174,114 @@ export async function resolveRoomChannel(
1147
1174
  };
1148
1175
  }
1149
1176
 
1177
+ /** Set or clear a space channel's role guest list. Null (or empty) clears:
1178
+ * the channel opens back up to every space member. */
1179
+ export async function setRoomAllowedRoles(
1180
+ config: RoomSocialConfig,
1181
+ room: string,
1182
+ allowedRoleIds: string[] | null,
1183
+ /** Per-member access rules (union with roles). ABSENT = leave the stored
1184
+ * list untouched (and let a legacy conversion seed from current members);
1185
+ * null or [] = clear; array = set. */
1186
+ allowedUserIds?: string[] | null,
1187
+ reason?: string,
1188
+ ): Promise<Record<string, unknown>> {
1189
+ const result = await socialRequest<{ room?: Record<string, unknown> }>(
1190
+ config,
1191
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/allowed-roles`,
1192
+ {
1193
+ method: "PATCH",
1194
+ body: {
1195
+ allowedRoleIds,
1196
+ ...(allowedUserIds !== undefined ? { allowedUserIds } : {}),
1197
+ ...(reason ? { reason } : {}),
1198
+ },
1199
+ },
1200
+ );
1201
+ return result.room ?? {};
1202
+ }
1203
+
1204
+ export interface RoomAccessMeta {
1205
+ /** Active role guest list; null = open to every space member. */
1206
+ allowedRoleIds: string[] | null;
1207
+ /** Per-member access rules (union with the roles); null = none. */
1208
+ allowedUserIds: string[] | null;
1209
+ /** Tiers allowed to post; the store default is all four. */
1210
+ postingRoles: RoomRole[];
1211
+ access: string | null;
1212
+ spaceId: string | null;
1213
+ kind: string | null;
1214
+ /** The caller's resolved channel role (owner fallback = creator). */
1215
+ viewerRole: RoomRole | null;
1216
+ }
1217
+
1218
+ /** Authoritative access meta for the settings surface: the room's guest
1219
+ * list, posting tiers and the caller's resolved role. Rides
1220
+ * resolveRoomChannel (the relay's route ordering), which is also where the
1221
+ * store places viewerRole (existence-hiding 404 preserved as a throw). */
1222
+ export async function resolveRoomAccess(
1223
+ config: RoomSocialConfig,
1224
+ room: string,
1225
+ ): Promise<RoomAccessMeta> {
1226
+ const meta: Record<string, unknown> = (await resolveRoomChannel(config, room)) ?? {};
1227
+ const allowed = Array.isArray(meta.allowedRoleIds)
1228
+ ? (meta.allowedRoleIds as unknown[]).filter(
1229
+ (value): value is string => typeof value === "string",
1230
+ )
1231
+ : null;
1232
+ const allowedUsers = Array.isArray(meta.allowedUserIds)
1233
+ ? (meta.allowedUserIds as unknown[]).filter(
1234
+ (value): value is string => typeof value === "string",
1235
+ )
1236
+ : null;
1237
+ const posting = Array.isArray(meta.postingRoles)
1238
+ ? (meta.postingRoles as unknown[]).filter(
1239
+ (value): value is RoomRole =>
1240
+ value === "owner" || value === "admin" || value === "mod" || value === "member",
1241
+ )
1242
+ : [];
1243
+ const viewer = typeof meta.viewerRole === "string" ? meta.viewerRole : null;
1244
+ return {
1245
+ allowedRoleIds: allowed && allowed.length > 0 ? allowed : null,
1246
+ allowedUserIds: allowedUsers && allowedUsers.length > 0 ? allowedUsers : null,
1247
+ postingRoles: posting.length > 0 ? posting : ["owner", "admin", "mod", "member"],
1248
+ access: typeof meta.access === "string" ? meta.access : null,
1249
+ spaceId: typeof meta.spaceId === "string" ? meta.spaceId : null,
1250
+ kind: typeof meta.kind === "string" ? meta.kind : null,
1251
+ viewerRole:
1252
+ viewer === "owner" || viewer === "admin" || viewer === "mod" || viewer === "member"
1253
+ ? viewer
1254
+ : null,
1255
+ };
1256
+ }
1257
+
1258
+ /** Owner-only. The old owner becomes a channel admin; audited server-side. */
1259
+ export async function transferRoomOwnership(
1260
+ config: RoomSocialConfig,
1261
+ room: string,
1262
+ targetUserId: string,
1263
+ reason?: string,
1264
+ ): Promise<void> {
1265
+ await socialRequest(
1266
+ config,
1267
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/ownership/transfer`,
1268
+ { method: "POST", body: { targetUserId, ...(reason ? { reason } : {}) } },
1269
+ );
1270
+ }
1271
+
1272
+ /** Lift a channel ban (no WS frame exists for unban; REST is the path). */
1273
+ export async function unbanRoomMember(
1274
+ config: RoomSocialConfig,
1275
+ room: string,
1276
+ targetUserId: string,
1277
+ reason?: string,
1278
+ ): Promise<void> {
1279
+ await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/mod/unban`, {
1280
+ method: "POST",
1281
+ body: { targetUserId, ...(reason ? { reason } : {}) },
1282
+ });
1283
+ }
1284
+
1150
1285
  /** Archive a channel (owner/admin): drops from listings, refuses posts. */
1151
1286
  export async function archiveRoomChannel(
1152
1287
  config: RoomSocialConfig,
@@ -1171,6 +1306,19 @@ export async function updateRoomSpace(
1171
1306
  return result.space;
1172
1307
  }
1173
1308
 
1309
+ /** Owner-only. The old owner becomes a space admin; audited server-side. */
1310
+ export async function transferSpaceOwnership(
1311
+ config: RoomSocialConfig,
1312
+ spaceId: string,
1313
+ targetUserId: string,
1314
+ reason?: string,
1315
+ ): Promise<void> {
1316
+ await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/ownership/transfer`, {
1317
+ method: "POST",
1318
+ body: { targetUserId, ...(reason ? { reason } : {}) },
1319
+ });
1320
+ }
1321
+
1174
1322
  /** Archive a server (owner only). */
1175
1323
  export async function archiveRoomSpace(
1176
1324
  config: RoomSocialConfig,
@@ -1179,6 +1327,52 @@ export async function archiveRoomSpace(
1179
1327
  return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}`, { method: "DELETE" });
1180
1328
  }
1181
1329
 
1330
+ /** Un-archive a channel back onto the active sidebar (manage-room). The
1331
+ * wire verb is /restore, the store-wide grammar shared with docs; the
1332
+ * receipt carries `restored`, not the archive shape's `archived`. */
1333
+ export async function unarchiveRoomChannel(
1334
+ config: RoomSocialConfig,
1335
+ room: string,
1336
+ ): Promise<{ roomId?: string; restored?: boolean }> {
1337
+ return socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/restore`, {
1338
+ method: "POST",
1339
+ });
1340
+ }
1341
+
1342
+ /** PERMANENTLY delete a channel and every row keyed to it (manage-room).
1343
+ * The bare DELETE verb on /rooms/:id is the legacy ARCHIVE shape; purge is
1344
+ * the explicit destruction verb and there is no undo. */
1345
+ export async function purgeRoomChannel(
1346
+ config: RoomSocialConfig,
1347
+ room: string,
1348
+ ): Promise<{ roomId?: string; deleted?: boolean }> {
1349
+ return socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/purge`, {
1350
+ method: "POST",
1351
+ });
1352
+ }
1353
+
1354
+ /** PERMANENTLY delete a server: every channel, message, member, role,
1355
+ * invite, and the entire docs family (owner only; no undo). */
1356
+ export async function purgeRoomSpace(
1357
+ config: RoomSocialConfig,
1358
+ spaceId: string,
1359
+ ): Promise<{ spaceId?: string; deleted?: boolean }> {
1360
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/purge`, {
1361
+ method: "POST",
1362
+ });
1363
+ }
1364
+
1365
+ /** A server's archived channels, for the Archive panel. */
1366
+ export async function listArchivedRoomChannels(
1367
+ config: RoomSocialConfig,
1368
+ spaceId: string,
1369
+ ): Promise<Array<{ roomId: string; name?: string; title?: string; archivedAt?: string }>> {
1370
+ const result = await socialRequest<{
1371
+ rooms?: Array<{ roomId: string; name?: string; title?: string; archivedAt?: string }>;
1372
+ }>(config, `/spaces/${encodeURIComponent(spaceId)}/rooms/archived`, { method: "GET" });
1373
+ return result.rooms ?? [];
1374
+ }
1375
+
1182
1376
  /** Full channel roster (paged): role + lastSeen for every membership row. */
1183
1377
  export async function listRoomChannelMembers(
1184
1378
  config: RoomSocialConfig,
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.9.0";
2
- export const RUNNER_VERSION = "0.9.0";
1
+ export const VERSION = "0.11.0";
2
+ export const RUNNER_VERSION = "0.11.0";
@@ -1,82 +0,0 @@
1
- import { type KnownProvider } 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 declare const SURFACE_IDS: readonly ["cli", "telegram", "discord", "slack", "webui"];
9
- export type SurfaceId = (typeof SURFACE_IDS)[number];
10
- export interface AgentToolCall {
11
- id: string;
12
- name: string;
13
- args: unknown;
14
- }
15
- export type AgentMessage = {
16
- role: "user";
17
- content: string;
18
- } | {
19
- role: "assistant";
20
- content: string;
21
- toolCalls?: AgentToolCall[];
22
- } | {
23
- role: "tool";
24
- content: string;
25
- toolCallId: string;
26
- name: string;
27
- };
28
- export interface ToolDispatchResult {
29
- isError?: true;
30
- content: string;
31
- /** Full-fidelity render payload for in-process SURFACES (e.g. the TUI's
32
- * backtest card). Present only on the streamed copy channels consume —
33
- * the agent runtime strips it before the result reaches the model's
34
- * messages or the conversation store, so it never costs context. */
35
- artifact?: unknown;
36
- }
37
- export interface LlmTool {
38
- name: string;
39
- description: string;
40
- inputSchema: Record<string, unknown>;
41
- handler: (args: unknown) => Promise<ToolDispatchResult>;
42
- }
43
- /** OM-level provider for any OpenAI-compatible /v1/chat/completions endpoint
44
- * (Ollama, vLLM, LM Studio, llama.cpp, custom proxies). Not a pi-ai
45
- * KnownProvider: the model object is constructed at turn time from the
46
- * credential's stored base URL instead of pi-ai's catalog. */
47
- export declare const OPENAI_COMPATIBLE_PROVIDER: "openai-compatible";
48
- export type LlmProvider = KnownProvider | typeof OPENAI_COMPATIBLE_PROVIDER;
49
- export declare const PRIMARY_LLM_PROVIDERS: readonly LlmProvider[];
50
- export declare function isLlmProvider(value: string): value is LlmProvider;
51
- export type TurnEvent = {
52
- kind: "assistant.text";
53
- delta: string;
54
- } | {
55
- kind: "tool.call";
56
- id: string;
57
- name: string;
58
- args: unknown;
59
- } | {
60
- kind: "tool.result";
61
- id: string;
62
- name: string;
63
- result: ToolDispatchResult;
64
- } | {
65
- kind: "turn.done";
66
- usage?: Record<string, unknown>;
67
- } | {
68
- kind: "turn.error";
69
- code?: string;
70
- message: string;
71
- };
72
- export interface RunLlmTurnInput {
73
- apiKey: string;
74
- system: string;
75
- messages: AgentMessage[];
76
- tools: LlmTool[];
77
- model: string;
78
- signal?: AbortSignal | undefined;
79
- }
80
- export type RunLlmTurn = (input: RunLlmTurnInput) => AsyncIterable<TurnEvent>;
81
- export declare function stringifyToolContent(value: unknown): string;
82
- export declare function parseToolArguments(raw: unknown): unknown;
@@ -1,56 +0,0 @@
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"];
9
- /** OM-level provider for any OpenAI-compatible /v1/chat/completions endpoint
10
- * (Ollama, vLLM, LM Studio, llama.cpp, custom proxies). Not a pi-ai
11
- * KnownProvider: the model object is constructed at turn time from the
12
- * credential's stored base URL instead of pi-ai's catalog. */
13
- export const OPENAI_COMPATIBLE_PROVIDER = "openai-compatible";
14
- // Provider-resolution precedence (authProviderCandidates): these are tried
15
- // before any store.order / generic provider. Invariant: an active profile for
16
- // an earlier provider here shadows a later-configured non-primary one (groq,
17
- // openai-compatible, …). The headless/env path short-circuits this, but an
18
- // interactive switch via `om init` to a non-primary provider only takes effect
19
- // once the shadowing primary profile is removed. Changing that is a product
20
- // decision (explicit active-provider selection), not a per-provider concern.
21
- export const PRIMARY_LLM_PROVIDERS = [
22
- "anthropic",
23
- "openai-codex",
24
- "openai",
25
- "google",
26
- "openrouter",
27
- ];
28
- let knownProviderSet = null;
29
- export function isLlmProvider(value) {
30
- if (value === OPENAI_COMPATIBLE_PROVIDER)
31
- return true;
32
- if (knownProviderSet === null) {
33
- knownProviderSet = new Set(getProviders());
34
- }
35
- return knownProviderSet.has(value);
36
- }
37
- export function stringifyToolContent(value) {
38
- if (value === undefined)
39
- return "";
40
- if (typeof value === "string")
41
- return value;
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);
46
- }
47
- export function parseToolArguments(raw) {
48
- if (raw === undefined || raw === null)
49
- return {};
50
- if (typeof raw !== "string")
51
- return raw;
52
- const trimmed = raw.trim();
53
- if (!trimmed)
54
- return {};
55
- return JSON.parse(trimmed);
56
- }
@@ -1,107 +0,0 @@
1
- import { getProviders, type KnownProvider } from "@earendil-works/pi-ai";
2
-
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];
12
-
13
- export interface AgentToolCall {
14
- id: string;
15
- name: string;
16
- args: unknown;
17
- }
18
-
19
- export type AgentMessage =
20
- | { role: "user"; content: string }
21
- | { role: "assistant"; content: string; toolCalls?: AgentToolCall[] }
22
- | { role: "tool"; content: string; toolCallId: string; name: string };
23
-
24
- export interface ToolDispatchResult {
25
- isError?: true;
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;
32
- }
33
-
34
- export interface LlmTool {
35
- name: string;
36
- description: string;
37
- inputSchema: Record<string, unknown>;
38
- handler: (args: unknown) => Promise<ToolDispatchResult>;
39
- }
40
-
41
- /** OM-level provider for any OpenAI-compatible /v1/chat/completions endpoint
42
- * (Ollama, vLLM, LM Studio, llama.cpp, custom proxies). Not a pi-ai
43
- * KnownProvider: the model object is constructed at turn time from the
44
- * credential's stored base URL instead of pi-ai's catalog. */
45
- export const OPENAI_COMPATIBLE_PROVIDER = "openai-compatible" as const;
46
-
47
- export type LlmProvider = KnownProvider | typeof OPENAI_COMPATIBLE_PROVIDER;
48
-
49
- // Provider-resolution precedence (authProviderCandidates): these are tried
50
- // before any store.order / generic provider. Invariant: an active profile for
51
- // an earlier provider here shadows a later-configured non-primary one (groq,
52
- // openai-compatible, …). The headless/env path short-circuits this, but an
53
- // interactive switch via `om init` to a non-primary provider only takes effect
54
- // once the shadowing primary profile is removed. Changing that is a product
55
- // decision (explicit active-provider selection), not a per-provider concern.
56
- export const PRIMARY_LLM_PROVIDERS: readonly LlmProvider[] = [
57
- "anthropic",
58
- "openai-codex",
59
- "openai",
60
- "google",
61
- "openrouter",
62
- ];
63
-
64
- let knownProviderSet: Set<string> | null = null;
65
-
66
- export function isLlmProvider(value: string): value is LlmProvider {
67
- if (value === OPENAI_COMPATIBLE_PROVIDER) return true;
68
- if (knownProviderSet === null) {
69
- knownProviderSet = new Set(getProviders());
70
- }
71
- return knownProviderSet.has(value);
72
- }
73
-
74
- export type TurnEvent =
75
- | { kind: "assistant.text"; delta: string }
76
- | { kind: "tool.call"; id: string; name: string; args: unknown }
77
- | { kind: "tool.result"; id: string; name: string; result: ToolDispatchResult }
78
- | { kind: "turn.done"; usage?: Record<string, unknown> }
79
- | { kind: "turn.error"; code?: string; message: string };
80
-
81
- export interface RunLlmTurnInput {
82
- apiKey: string;
83
- system: string;
84
- messages: AgentMessage[];
85
- tools: LlmTool[];
86
- model: string;
87
- signal?: AbortSignal | undefined;
88
- }
89
-
90
- export type RunLlmTurn = (input: RunLlmTurnInput) => AsyncIterable<TurnEvent>;
91
-
92
- export function stringifyToolContent(value: unknown): string {
93
- if (value === undefined) return "";
94
- if (typeof value === "string") return value;
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);
99
- }
100
-
101
- export function parseToolArguments(raw: unknown): unknown {
102
- if (raw === undefined || raw === null) return {};
103
- if (typeof raw !== "string") return raw;
104
- const trimmed = raw.trim();
105
- if (!trimmed) return {};
106
- return JSON.parse(trimmed) as unknown;
107
- }