@openmarket/rooms-client 0.1.0 → 0.2.1

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.
@@ -89,18 +89,35 @@ export interface ArmedRoomState {
89
89
  }
90
90
  /** A fresh armed state for a newly armed room. */
91
91
  export declare function makeArmedRoomState(roomId: string, lastSeq?: number): ArmedRoomState;
92
+ /**
93
+ * A fresh armed state that CARRIES the cost/rate counters from a prior armed
94
+ * session of the same room. Disarm/rearm must not zero the cooldown, hourly
95
+ * window, or session budget: otherwise toggling /disarm + /arm bypasses every
96
+ * cost guard. Error and chain counters DO reset (rearming is an explicit
97
+ * operator action; a fresh process still starts with nothing retained).
98
+ */
99
+ export declare function rearmedRoomState(roomId: string, retired: ArmedRoomState | null, lastSeq?: number): ArmedRoomState;
100
+ /** Seqs of the user's own posts in a window of entries; the reply-trigger
101
+ * check tests `inReplyTo` against this set. Pure. */
102
+ export declare function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: string): Set<number>;
92
103
  /**
93
104
  * Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
94
- * the room that @mentions the user's own handle. Agent posts, system entries,
95
- * the user's own posts, and non-mentions never trigger. Pure.
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.
96
110
  */
97
- export declare function isTriggeringEntry(entry: RoomLedgerEntry, ownHandle: string, ownUserId: string): boolean;
111
+ export declare function isTriggeringEntry(entry: RoomLedgerEntry, ownHandle: string, ownUserId: string, ownSeqs?: ReadonlySet<number>): boolean;
98
112
  /**
99
113
  * The newest qualifying trigger among entries strictly newer than
100
114
  * `afterSeq`, or null if none. Used by the loop to decide whether the latest
101
- * batch of room messages contains a reason to act. Pure.
115
+ * batch of room messages contains a reason to act. Reply triggers resolve
116
+ * against the user's own seqs within `entries` unless a wider `ownSeqs`
117
+ * window is supplied (the live single-entry path passes the room cache).
118
+ * Pure.
102
119
  */
103
- export declare function findTrigger(entries: ReadonlyArray<RoomLedgerEntry>, ownHandle: string, ownUserId: string, afterSeq: number): RoomLedgerEntry | null;
120
+ export declare function findTrigger(entries: ReadonlyArray<RoomLedgerEntry>, ownHandle: string, ownUserId: string, afterSeq: number, ownSeqs?: ReadonlySet<number>): RoomLedgerEntry | null;
104
121
  /** Autonomous posts inside the rolling hourly window as of `now`. */
105
122
  export declare function postsInWindow(state: ArmedRoomState, now?: number): number;
106
123
  /** Remaining cooldown (ms) before the next autonomous post is allowed; 0 when
@@ -83,12 +83,41 @@ export function makeArmedRoomState(roomId, lastSeq = 0) {
83
83
  lastEvaluatedSeq: lastSeq,
84
84
  };
85
85
  }
86
+ /**
87
+ * A fresh armed state that CARRIES the cost/rate counters from a prior armed
88
+ * session of the same room. Disarm/rearm must not zero the cooldown, hourly
89
+ * window, or session budget: otherwise toggling /disarm + /arm bypasses every
90
+ * cost guard. Error and chain counters DO reset (rearming is an explicit
91
+ * operator action; a fresh process still starts with nothing retained).
92
+ */
93
+ export function rearmedRoomState(roomId, retired, lastSeq = 0) {
94
+ const state = makeArmedRoomState(roomId, lastSeq);
95
+ if (!retired || retired.roomId !== roomId)
96
+ return state;
97
+ state.postTimes = [...retired.postTimes];
98
+ state.postsThisSession = retired.postsThisSession;
99
+ state.tokensThisSession = retired.tokensThisSession;
100
+ return state;
101
+ }
102
+ /** Seqs of the user's own posts in a window of entries; the reply-trigger
103
+ * check tests `inReplyTo` against this set. Pure. */
104
+ export function ownSeqsOf(entries, ownUserId) {
105
+ const seqs = new Set();
106
+ for (const entry of entries) {
107
+ if (entry.userId === ownUserId)
108
+ seqs.add(entry.seq);
109
+ }
110
+ return seqs;
111
+ }
86
112
  /**
87
113
  * Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
88
- * the room that @mentions the user's own handle. Agent posts, system entries,
89
- * the user's own posts, and non-mentions never trigger. Pure.
114
+ * the room that @mentions the user's own handle, OR (when `ownSeqs` is given)
115
+ * directly replies to one of the user's own messages. Reply semantics match a
116
+ * mention (replying pings the author), so armed mode treats both as addressed
117
+ * to the user. Agent posts, system entries, the user's own posts, and
118
+ * non-mentions never trigger. Pure.
90
119
  */
91
- export function isTriggeringEntry(entry, ownHandle, ownUserId) {
120
+ export function isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs) {
92
121
  if (entry.type !== RoomLedgerEntryType.POST)
93
122
  return false;
94
123
  if (entry.isAgent)
@@ -102,6 +131,10 @@ export function isTriggeringEntry(entry, ownHandle, ownUserId) {
102
131
  return false;
103
132
  if (me.length > 0 && fromHandle === me)
104
133
  return false;
134
+ // A direct reply to one of our messages is addressed to us. The set only
135
+ // spans the cached window, so replies to long-scrolled-out posts stay inert.
136
+ if (entry.inReplyTo !== undefined && ownSeqs?.has(entry.inReplyTo))
137
+ return true;
105
138
  // Must EXPLICITLY @mention the user's own handle. A bare-substring match (the
106
139
  // old mentionsAgent) fired on ordinary words for short handles like "may" /
107
140
  // "btc"; armed posting has no human review, so it triggers ONLY on an `@handle`
@@ -111,14 +144,17 @@ export function isTriggeringEntry(entry, ownHandle, ownUserId) {
111
144
  /**
112
145
  * The newest qualifying trigger among entries strictly newer than
113
146
  * `afterSeq`, or null if none. Used by the loop to decide whether the latest
114
- * batch of room messages contains a reason to act. Pure.
147
+ * batch of room messages contains a reason to act. Reply triggers resolve
148
+ * against the user's own seqs within `entries` unless a wider `ownSeqs`
149
+ * window is supplied (the live single-entry path passes the room cache).
150
+ * Pure.
115
151
  */
116
- export function findTrigger(entries, ownHandle, ownUserId, afterSeq) {
152
+ export function findTrigger(entries, ownHandle, ownUserId, afterSeq, ownSeqs = ownSeqsOf(entries, ownUserId)) {
117
153
  let found = null;
118
154
  for (const entry of entries) {
119
155
  if (entry.seq <= afterSeq)
120
156
  continue;
121
- if (isTriggeringEntry(entry, ownHandle, ownUserId))
157
+ if (isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs))
122
158
  found = entry;
123
159
  }
124
160
  return found;
package/dist/client.d.ts CHANGED
@@ -208,6 +208,10 @@ export interface RoomDmSendRequest {
208
208
  conversationId?: string;
209
209
  content: string;
210
210
  clientMsgId?: string;
211
+ /** A private DM-attachment key (dm-attachments/…) or a legacy public image
212
+ * URL. Stored on the message's imageUrl; the client signs keys on render.
213
+ * Additive — omitted for text-only sends. */
214
+ imageUrl?: string;
211
215
  }
212
216
  export interface RoomThreadRequest {
213
217
  room: string;
@@ -251,6 +255,28 @@ export declare function requestRoomFriends(client: RoomWsClient): Promise<RoomFr
251
255
  export declare function openRoomDmOnClient(client: RoomWsClient, request: RoomDmOpenRequest, timeoutMs?: number): Promise<RoomDmOpenedPayload>;
252
256
  export declare function requestRoomDmHistoryOnClient(client: RoomWsClient, request: RoomDmHistoryRequest, timeoutMs?: number): Promise<RoomDmHistoryResultPayload>;
253
257
  export declare function sendRoomDmOnClient(client: RoomWsClient, request: RoomDmSendRequest, timeoutMs?: number): Promise<RoomDmMessagePayload>;
258
+ /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
259
+ * DM_MESSAGE_EDITED to both participants. */
260
+ export declare function editRoomDmOnClient(client: RoomWsClient, request: {
261
+ conversationId?: string;
262
+ username?: string;
263
+ messageId: string;
264
+ content: string;
265
+ }): void;
266
+ /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
267
+ export declare function deleteRoomDmOnClient(client: RoomWsClient, request: {
268
+ conversationId?: string;
269
+ username?: string;
270
+ messageId: string;
271
+ }): void;
272
+ /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
273
+ export declare function reactRoomDmOnClient(client: RoomWsClient, request: {
274
+ conversationId?: string;
275
+ username?: string;
276
+ messageId: string;
277
+ emoji: string;
278
+ op: "add" | "remove";
279
+ }): void;
254
280
  export declare function peekRoom(config: RoomClientConfig, options: RoomPeekOptions): Promise<RoomBackfillPayload>;
255
281
  export declare function sayRoom(config: RoomClientConfig, options: RoomSayOptions): Promise<RoomSayResult>;
256
282
  export declare function joinRoom(config: RoomClientConfig, options: RoomJoinOptions): Promise<RoomJoinedSession>;
package/dist/client.js CHANGED
@@ -465,6 +465,7 @@ export async function sendRoomDmOnClient(client, request, timeoutMs) {
465
465
  clientMsgId,
466
466
  ...(request.username ? { username: request.username } : {}),
467
467
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
468
+ ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
468
469
  },
469
470
  timestamp: Date.now(),
470
471
  });
@@ -473,6 +474,46 @@ export async function sendRoomDmOnClient(client, request, timeoutMs) {
473
474
  msg.payload.message.clientMsgId === clientMsgId, timeoutMs, requestId);
474
475
  return sent.payload;
475
476
  }
477
+ /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
478
+ * DM_MESSAGE_EDITED to both participants. */
479
+ export function editRoomDmOnClient(client, request) {
480
+ client.send({
481
+ type: RoomClientMessageType.DM_EDIT,
482
+ payload: {
483
+ messageId: request.messageId,
484
+ content: request.content,
485
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
486
+ ...(request.username ? { username: request.username } : {}),
487
+ },
488
+ timestamp: Date.now(),
489
+ });
490
+ }
491
+ /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
492
+ export function deleteRoomDmOnClient(client, request) {
493
+ client.send({
494
+ type: RoomClientMessageType.DM_DELETE,
495
+ payload: {
496
+ messageId: request.messageId,
497
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
498
+ ...(request.username ? { username: request.username } : {}),
499
+ },
500
+ timestamp: Date.now(),
501
+ });
502
+ }
503
+ /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
504
+ export function reactRoomDmOnClient(client, request) {
505
+ client.send({
506
+ type: RoomClientMessageType.DM_REACT,
507
+ payload: {
508
+ messageId: request.messageId,
509
+ emoji: request.emoji,
510
+ op: request.op,
511
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
512
+ ...(request.username ? { username: request.username } : {}),
513
+ },
514
+ timestamp: Date.now(),
515
+ });
516
+ }
476
517
  export async function peekRoom(config, options) {
477
518
  if (!config.webSocketCtor) {
478
519
  try {
@@ -38,6 +38,9 @@ export declare const RoomClientMessageType: {
38
38
  readonly DM_HISTORY: "DM_HISTORY";
39
39
  readonly DM_SEND: "DM_SEND";
40
40
  readonly DM_READ: "DM_READ";
41
+ readonly DM_EDIT: "DM_EDIT";
42
+ readonly DM_DELETE: "DM_DELETE";
43
+ readonly DM_REACT: "DM_REACT";
41
44
  readonly TOPIC_CREATE: "TOPIC_CREATE";
42
45
  readonly TOPIC_RENAME: "TOPIC_RENAME";
43
46
  readonly TOPIC_MOVE: "TOPIC_MOVE";
@@ -87,6 +90,9 @@ export declare const RoomServerMessageType: {
87
90
  readonly DM_HISTORY: "DM_HISTORY";
88
91
  readonly DM_MESSAGE: "DM_MESSAGE";
89
92
  readonly DM_INBOX_UPDATED: "DM_INBOX_UPDATED";
93
+ readonly DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED";
94
+ readonly DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED";
95
+ readonly DM_REACTION_UPDATED: "DM_REACTION_UPDATED";
90
96
  readonly TOPIC: "TOPIC";
91
97
  readonly TOPICS: "TOPICS";
92
98
  readonly TOPIC_UPDATED: "TOPIC_UPDATED";
@@ -438,6 +444,11 @@ export interface RoomDmMessage {
438
444
  /** Sealed DM: content is '' and the opaque envelope rides here. */
439
445
  secret?: RoomLedgerSecretPayload | null | undefined;
440
446
  metadata?: Record<string, unknown> | undefined;
447
+ /** Legacy public chat-image URL (renders as-is, no signing). */
448
+ imageUrl?: string | null | undefined;
449
+ /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
450
+ * Additive: old messages carry none and render unchanged. */
451
+ attachments?: RoomAttachment[] | undefined;
441
452
  }
442
453
  export interface RoomDmOpenPayload {
443
454
  requestId?: string;
@@ -459,6 +470,58 @@ export interface RoomDmSendPayload {
459
470
  conversationId?: string;
460
471
  content: string;
461
472
  clientMsgId?: string;
473
+ /** DM-attachment key or legacy public image URL; stored on the message's
474
+ * imageUrl. Additive — text-only sends omit it. */
475
+ imageUrl?: string;
476
+ }
477
+ /** Client → server: edit one of my DM messages (sealed messages are rejected). */
478
+ export interface RoomDmEditPayload {
479
+ requestId?: string;
480
+ conversationId?: string;
481
+ username?: string;
482
+ messageId: string;
483
+ content: string;
484
+ }
485
+ /** Client → server: soft-delete one of my DM messages (tombstone in place). */
486
+ export interface RoomDmDeletePayload {
487
+ requestId?: string;
488
+ conversationId?: string;
489
+ username?: string;
490
+ messageId: string;
491
+ }
492
+ /** Server → both participants: a DM message was edited. */
493
+ export interface RoomDmMessageEditedPayload {
494
+ conversationId: string;
495
+ messageId: string;
496
+ content: string;
497
+ editedAt: string | number;
498
+ participant: RoomDmParticipant;
499
+ }
500
+ /** Server → both participants: a DM message was soft-deleted (tombstone). */
501
+ export interface RoomDmMessageDeletedPayload {
502
+ conversationId: string;
503
+ messageId: string;
504
+ deletedAt: string | number;
505
+ participant: RoomDmParticipant;
506
+ }
507
+ /** Client → server: toggle a reaction on a DM message. */
508
+ export interface RoomDmReactPayload {
509
+ requestId?: string;
510
+ conversationId?: string;
511
+ username?: string;
512
+ messageId: string;
513
+ emoji: string;
514
+ op: "add" | "remove";
515
+ }
516
+ /** Server → both participants: a DM message's aggregated reactions changed. */
517
+ export interface RoomDmReactionUpdatedPayload {
518
+ conversationId: string;
519
+ messageId: string;
520
+ reactions: Array<{
521
+ emoji: string;
522
+ users: string[];
523
+ }>;
524
+ participant: RoomDmParticipant;
462
525
  }
463
526
  export interface RoomDmReadPayload {
464
527
  requestId?: string;
@@ -975,6 +1038,18 @@ export type RoomClientMessage = {
975
1038
  type: typeof RoomClientMessageType.DM_READ;
976
1039
  payload: RoomDmReadPayload;
977
1040
  timestamp?: number;
1041
+ } | {
1042
+ type: typeof RoomClientMessageType.DM_EDIT;
1043
+ payload: RoomDmEditPayload;
1044
+ timestamp?: number;
1045
+ } | {
1046
+ type: typeof RoomClientMessageType.DM_DELETE;
1047
+ payload: RoomDmDeletePayload;
1048
+ timestamp?: number;
1049
+ } | {
1050
+ type: typeof RoomClientMessageType.DM_REACT;
1051
+ payload: RoomDmReactPayload;
1052
+ timestamp?: number;
978
1053
  } | {
979
1054
  type: typeof RoomClientMessageType.TOPIC_CREATE;
980
1055
  payload: RoomTopicCreatePayload;
@@ -1165,6 +1240,18 @@ export type RoomServerMessage = {
1165
1240
  type: typeof RoomServerMessageType.DM_INBOX_UPDATED;
1166
1241
  payload: RoomDmInboxUpdatedPayload;
1167
1242
  timestamp: number;
1243
+ } | {
1244
+ type: typeof RoomServerMessageType.DM_MESSAGE_EDITED;
1245
+ payload: RoomDmMessageEditedPayload;
1246
+ timestamp: number;
1247
+ } | {
1248
+ type: typeof RoomServerMessageType.DM_MESSAGE_DELETED;
1249
+ payload: RoomDmMessageDeletedPayload;
1250
+ timestamp: number;
1251
+ } | {
1252
+ type: typeof RoomServerMessageType.DM_REACTION_UPDATED;
1253
+ payload: RoomDmReactionUpdatedPayload;
1254
+ timestamp: number;
1168
1255
  } | {
1169
1256
  type: typeof RoomServerMessageType.TOPIC;
1170
1257
  payload: RoomTopicResultPayload;
@@ -38,6 +38,9 @@ export const RoomClientMessageType = {
38
38
  DM_HISTORY: "DM_HISTORY",
39
39
  DM_SEND: "DM_SEND",
40
40
  DM_READ: "DM_READ",
41
+ DM_EDIT: "DM_EDIT",
42
+ DM_DELETE: "DM_DELETE",
43
+ DM_REACT: "DM_REACT",
41
44
  TOPIC_CREATE: "TOPIC_CREATE",
42
45
  TOPIC_RENAME: "TOPIC_RENAME",
43
46
  TOPIC_MOVE: "TOPIC_MOVE",
@@ -86,6 +89,9 @@ export const RoomServerMessageType = {
86
89
  DM_HISTORY: "DM_HISTORY",
87
90
  DM_MESSAGE: "DM_MESSAGE",
88
91
  DM_INBOX_UPDATED: "DM_INBOX_UPDATED",
92
+ DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED",
93
+ DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED",
94
+ DM_REACTION_UPDATED: "DM_REACTION_UPDATED",
89
95
  TOPIC: "TOPIC",
90
96
  TOPICS: "TOPICS",
91
97
  TOPIC_UPDATED: "TOPIC_UPDATED",
@@ -175,6 +175,12 @@ export interface RoomSpaceMember {
175
175
  /** Named identity roles (RoomSpaceRole.roleId). */
176
176
  roleIds?: string[];
177
177
  joinedAt?: string;
178
+ /** The member's handle/username, resolved server-side from their room
179
+ * memberships. Absent when the store couldn't resolve a real name (never
180
+ * the raw userId). */
181
+ handle?: string;
182
+ /** Per-space display name; null or absent means the account username. */
183
+ nickname?: string | null;
178
184
  }
179
185
  export declare function getUserPrefs(config: RoomSocialConfig): Promise<Record<string, unknown>>;
180
186
  /** Whole-blob replace (16KB cap server-side; last writer wins). */
@@ -321,6 +327,8 @@ export declare function removeSpaceMember(config: RoomSocialConfig, spaceId: str
321
327
  removed?: boolean;
322
328
  }>;
323
329
  export declare function changeSpaceMemberRole(config: RoomSocialConfig, spaceId: string, targetUserId: string, role: "admin" | "member"): Promise<RoomSpaceMember>;
330
+ /** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
331
+ export declare function setSpaceMemberNickname(config: RoomSocialConfig, spaceId: string, targetUserId: string, nickname: string | null): Promise<RoomSpaceMember>;
324
332
  export declare function createRoomSpace(config: RoomSocialConfig, input: {
325
333
  name: string;
326
334
  access?: "open" | "invite";
@@ -501,6 +501,11 @@ export async function changeSpaceMemberRole(config, spaceId, targetUserId, role)
501
501
  const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/role`, { method: "PATCH", body: { role } });
502
502
  return result.member;
503
503
  }
504
+ /** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
505
+ export async function setSpaceMemberNickname(config, spaceId, targetUserId, nickname) {
506
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/nickname`, { method: "PATCH", body: { nickname } });
507
+ return result.member;
508
+ }
504
509
  export async function createRoomSpace(config, input) {
505
510
  const result = await socialRequest(config, "/spaces", {
506
511
  method: "POST",
@@ -112,6 +112,14 @@ export interface RoomDmMessage {
112
112
  /** Sealed DM: content is '' and the opaque envelope rides here. */
113
113
  secret?: RoomLedgerSecretPayload | null;
114
114
  metadata?: Record<string, unknown>;
115
+ /** DM image: a private dm-attachments/ key (signed on render) or a legacy
116
+ * public URL. Additive — old messages carry none. */
117
+ imageUrl?: string | null;
118
+ /** Aggregated reactions (per-emoji userId lists). Additive. */
119
+ reactions?: Array<{
120
+ emoji: string;
121
+ users: string[];
122
+ }>;
115
123
  }
116
124
  export interface RoomDmHistory {
117
125
  conversationId: string;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.5.0";
2
- export declare const RUNNER_VERSION = "0.5.0";
1
+ export declare const VERSION = "0.5.1";
2
+ export declare const RUNNER_VERSION = "0.5.1";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.5.0";
2
- export const RUNNER_VERSION = "0.5.0";
1
+ export const VERSION = "0.5.1";
2
+ export const RUNNER_VERSION = "0.5.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",
@@ -126,15 +126,49 @@ export function makeArmedRoomState(roomId: string, lastSeq = 0): ArmedRoomState
126
126
  };
127
127
  }
128
128
 
129
+ /**
130
+ * A fresh armed state that CARRIES the cost/rate counters from a prior armed
131
+ * session of the same room. Disarm/rearm must not zero the cooldown, hourly
132
+ * window, or session budget: otherwise toggling /disarm + /arm bypasses every
133
+ * cost guard. Error and chain counters DO reset (rearming is an explicit
134
+ * operator action; a fresh process still starts with nothing retained).
135
+ */
136
+ export function rearmedRoomState(
137
+ roomId: string,
138
+ retired: ArmedRoomState | null,
139
+ lastSeq = 0,
140
+ ): ArmedRoomState {
141
+ const state = makeArmedRoomState(roomId, lastSeq);
142
+ if (!retired || retired.roomId !== roomId) return state;
143
+ state.postTimes = [...retired.postTimes];
144
+ state.postsThisSession = retired.postsThisSession;
145
+ state.tokensThisSession = retired.tokensThisSession;
146
+ return state;
147
+ }
148
+
149
+ /** Seqs of the user's own posts in a window of entries; the reply-trigger
150
+ * check tests `inReplyTo` against this set. Pure. */
151
+ export function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: string): Set<number> {
152
+ const seqs = new Set<number>();
153
+ for (const entry of entries) {
154
+ if (entry.userId === ownUserId) seqs.add(entry.seq);
155
+ }
156
+ return seqs;
157
+ }
158
+
129
159
  /**
130
160
  * Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
131
- * the room that @mentions the user's own handle. Agent posts, system entries,
132
- * the user's own posts, and non-mentions never trigger. Pure.
161
+ * the room that @mentions the user's own handle, OR (when `ownSeqs` is given)
162
+ * directly replies to one of the user's own messages. Reply semantics match a
163
+ * mention (replying pings the author), so armed mode treats both as addressed
164
+ * to the user. Agent posts, system entries, the user's own posts, and
165
+ * non-mentions never trigger. Pure.
133
166
  */
134
167
  export function isTriggeringEntry(
135
168
  entry: RoomLedgerEntry,
136
169
  ownHandle: string,
137
170
  ownUserId: string,
171
+ ownSeqs?: ReadonlySet<number>,
138
172
  ): boolean {
139
173
  if (entry.type !== RoomLedgerEntryType.POST) return false;
140
174
  if (entry.isAgent) return false;
@@ -144,6 +178,9 @@ export function isTriggeringEntry(
144
178
  const me = normalizeHandle(ownHandle).toLowerCase();
145
179
  if (entry.userId === ownUserId) return false;
146
180
  if (me.length > 0 && fromHandle === me) return false;
181
+ // A direct reply to one of our messages is addressed to us. The set only
182
+ // spans the cached window, so replies to long-scrolled-out posts stay inert.
183
+ if (entry.inReplyTo !== undefined && ownSeqs?.has(entry.inReplyTo)) return true;
147
184
  // Must EXPLICITLY @mention the user's own handle. A bare-substring match (the
148
185
  // old mentionsAgent) fired on ordinary words for short handles like "may" /
149
186
  // "btc"; armed posting has no human review, so it triggers ONLY on an `@handle`
@@ -154,18 +191,22 @@ export function isTriggeringEntry(
154
191
  /**
155
192
  * The newest qualifying trigger among entries strictly newer than
156
193
  * `afterSeq`, or null if none. Used by the loop to decide whether the latest
157
- * batch of room messages contains a reason to act. Pure.
194
+ * batch of room messages contains a reason to act. Reply triggers resolve
195
+ * against the user's own seqs within `entries` unless a wider `ownSeqs`
196
+ * window is supplied (the live single-entry path passes the room cache).
197
+ * Pure.
158
198
  */
159
199
  export function findTrigger(
160
200
  entries: ReadonlyArray<RoomLedgerEntry>,
161
201
  ownHandle: string,
162
202
  ownUserId: string,
163
203
  afterSeq: number,
204
+ ownSeqs: ReadonlySet<number> = ownSeqsOf(entries, ownUserId),
164
205
  ): RoomLedgerEntry | null {
165
206
  let found: RoomLedgerEntry | null = null;
166
207
  for (const entry of entries) {
167
208
  if (entry.seq <= afterSeq) continue;
168
- if (isTriggeringEntry(entry, ownHandle, ownUserId)) found = entry;
209
+ if (isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs)) found = entry;
169
210
  }
170
211
  return found;
171
212
  }
package/src/client.ts CHANGED
@@ -284,6 +284,10 @@ export interface RoomDmSendRequest {
284
284
  conversationId?: string;
285
285
  content: string;
286
286
  clientMsgId?: string;
287
+ /** A private DM-attachment key (dm-attachments/…) or a legacy public image
288
+ * URL. Stored on the message's imageUrl; the client signs keys on render.
289
+ * Additive — omitted for text-only sends. */
290
+ imageUrl?: string;
287
291
  }
288
292
 
289
293
  export interface RoomThreadRequest {
@@ -931,6 +935,7 @@ export async function sendRoomDmOnClient(
931
935
  clientMsgId,
932
936
  ...(request.username ? { username: request.username } : {}),
933
937
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
938
+ ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
934
939
  },
935
940
  timestamp: Date.now(),
936
941
  });
@@ -946,6 +951,64 @@ export async function sendRoomDmOnClient(
946
951
  return sent.payload;
947
952
  }
948
953
 
954
+ /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
955
+ * DM_MESSAGE_EDITED to both participants. */
956
+ export function editRoomDmOnClient(
957
+ client: RoomWsClient,
958
+ request: { conversationId?: string; username?: string; messageId: string; content: string },
959
+ ): void {
960
+ client.send({
961
+ type: RoomClientMessageType.DM_EDIT,
962
+ payload: {
963
+ messageId: request.messageId,
964
+ content: request.content,
965
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
966
+ ...(request.username ? { username: request.username } : {}),
967
+ },
968
+ timestamp: Date.now(),
969
+ });
970
+ }
971
+
972
+ /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
973
+ export function deleteRoomDmOnClient(
974
+ client: RoomWsClient,
975
+ request: { conversationId?: string; username?: string; messageId: string },
976
+ ): void {
977
+ client.send({
978
+ type: RoomClientMessageType.DM_DELETE,
979
+ payload: {
980
+ messageId: request.messageId,
981
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
982
+ ...(request.username ? { username: request.username } : {}),
983
+ },
984
+ timestamp: Date.now(),
985
+ });
986
+ }
987
+
988
+ /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
989
+ export function reactRoomDmOnClient(
990
+ client: RoomWsClient,
991
+ request: {
992
+ conversationId?: string;
993
+ username?: string;
994
+ messageId: string;
995
+ emoji: string;
996
+ op: "add" | "remove";
997
+ },
998
+ ): void {
999
+ client.send({
1000
+ type: RoomClientMessageType.DM_REACT,
1001
+ payload: {
1002
+ messageId: request.messageId,
1003
+ emoji: request.emoji,
1004
+ op: request.op,
1005
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
1006
+ ...(request.username ? { username: request.username } : {}),
1007
+ },
1008
+ timestamp: Date.now(),
1009
+ });
1010
+ }
1011
+
949
1012
  export async function peekRoom(
950
1013
  config: RoomClientConfig,
951
1014
  options: RoomPeekOptions,
@@ -38,6 +38,9 @@ export const RoomClientMessageType = {
38
38
  DM_HISTORY: "DM_HISTORY",
39
39
  DM_SEND: "DM_SEND",
40
40
  DM_READ: "DM_READ",
41
+ DM_EDIT: "DM_EDIT",
42
+ DM_DELETE: "DM_DELETE",
43
+ DM_REACT: "DM_REACT",
41
44
  TOPIC_CREATE: "TOPIC_CREATE",
42
45
  TOPIC_RENAME: "TOPIC_RENAME",
43
46
  TOPIC_MOVE: "TOPIC_MOVE",
@@ -90,6 +93,9 @@ export const RoomServerMessageType = {
90
93
  DM_HISTORY: "DM_HISTORY",
91
94
  DM_MESSAGE: "DM_MESSAGE",
92
95
  DM_INBOX_UPDATED: "DM_INBOX_UPDATED",
96
+ DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED",
97
+ DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED",
98
+ DM_REACTION_UPDATED: "DM_REACTION_UPDATED",
93
99
  TOPIC: "TOPIC",
94
100
  TOPICS: "TOPICS",
95
101
  TOPIC_UPDATED: "TOPIC_UPDATED",
@@ -527,6 +533,11 @@ export interface RoomDmMessage {
527
533
  /** Sealed DM: content is '' and the opaque envelope rides here. */
528
534
  secret?: RoomLedgerSecretPayload | null | undefined;
529
535
  metadata?: Record<string, unknown> | undefined;
536
+ /** Legacy public chat-image URL (renders as-is, no signing). */
537
+ imageUrl?: string | null | undefined;
538
+ /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
539
+ * Additive: old messages carry none and render unchanged. */
540
+ attachments?: RoomAttachment[] | undefined;
530
541
  }
531
542
 
532
543
  export interface RoomDmOpenPayload {
@@ -551,6 +562,61 @@ export interface RoomDmSendPayload {
551
562
  conversationId?: string;
552
563
  content: string;
553
564
  clientMsgId?: string;
565
+ /** DM-attachment key or legacy public image URL; stored on the message's
566
+ * imageUrl. Additive — text-only sends omit it. */
567
+ imageUrl?: string;
568
+ }
569
+
570
+ /** Client → server: edit one of my DM messages (sealed messages are rejected). */
571
+ export interface RoomDmEditPayload {
572
+ requestId?: string;
573
+ conversationId?: string;
574
+ username?: string;
575
+ messageId: string;
576
+ content: string;
577
+ }
578
+
579
+ /** Client → server: soft-delete one of my DM messages (tombstone in place). */
580
+ export interface RoomDmDeletePayload {
581
+ requestId?: string;
582
+ conversationId?: string;
583
+ username?: string;
584
+ messageId: string;
585
+ }
586
+
587
+ /** Server → both participants: a DM message was edited. */
588
+ export interface RoomDmMessageEditedPayload {
589
+ conversationId: string;
590
+ messageId: string;
591
+ content: string;
592
+ editedAt: string | number;
593
+ participant: RoomDmParticipant;
594
+ }
595
+
596
+ /** Server → both participants: a DM message was soft-deleted (tombstone). */
597
+ export interface RoomDmMessageDeletedPayload {
598
+ conversationId: string;
599
+ messageId: string;
600
+ deletedAt: string | number;
601
+ participant: RoomDmParticipant;
602
+ }
603
+
604
+ /** Client → server: toggle a reaction on a DM message. */
605
+ export interface RoomDmReactPayload {
606
+ requestId?: string;
607
+ conversationId?: string;
608
+ username?: string;
609
+ messageId: string;
610
+ emoji: string;
611
+ op: "add" | "remove";
612
+ }
613
+
614
+ /** Server → both participants: a DM message's aggregated reactions changed. */
615
+ export interface RoomDmReactionUpdatedPayload {
616
+ conversationId: string;
617
+ messageId: string;
618
+ reactions: Array<{ emoji: string; users: string[] }>;
619
+ participant: RoomDmParticipant;
554
620
  }
555
621
 
556
622
  export interface RoomDmReadPayload {
@@ -1085,6 +1151,21 @@ export type RoomClientMessage =
1085
1151
  payload: RoomDmReadPayload;
1086
1152
  timestamp?: number;
1087
1153
  }
1154
+ | {
1155
+ type: typeof RoomClientMessageType.DM_EDIT;
1156
+ payload: RoomDmEditPayload;
1157
+ timestamp?: number;
1158
+ }
1159
+ | {
1160
+ type: typeof RoomClientMessageType.DM_DELETE;
1161
+ payload: RoomDmDeletePayload;
1162
+ timestamp?: number;
1163
+ }
1164
+ | {
1165
+ type: typeof RoomClientMessageType.DM_REACT;
1166
+ payload: RoomDmReactPayload;
1167
+ timestamp?: number;
1168
+ }
1088
1169
  | {
1089
1170
  type: typeof RoomClientMessageType.TOPIC_CREATE;
1090
1171
  payload: RoomTopicCreatePayload;
@@ -1317,6 +1398,21 @@ export type RoomServerMessage =
1317
1398
  payload: RoomDmInboxUpdatedPayload;
1318
1399
  timestamp: number;
1319
1400
  }
1401
+ | {
1402
+ type: typeof RoomServerMessageType.DM_MESSAGE_EDITED;
1403
+ payload: RoomDmMessageEditedPayload;
1404
+ timestamp: number;
1405
+ }
1406
+ | {
1407
+ type: typeof RoomServerMessageType.DM_MESSAGE_DELETED;
1408
+ payload: RoomDmMessageDeletedPayload;
1409
+ timestamp: number;
1410
+ }
1411
+ | {
1412
+ type: typeof RoomServerMessageType.DM_REACTION_UPDATED;
1413
+ payload: RoomDmReactionUpdatedPayload;
1414
+ timestamp: number;
1415
+ }
1320
1416
  | {
1321
1417
  type: typeof RoomServerMessageType.TOPIC;
1322
1418
  payload: RoomTopicResultPayload;
@@ -658,6 +658,12 @@ export interface RoomSpaceMember {
658
658
  /** Named identity roles (RoomSpaceRole.roleId). */
659
659
  roleIds?: string[];
660
660
  joinedAt?: string;
661
+ /** The member's handle/username, resolved server-side from their room
662
+ * memberships. Absent when the store couldn't resolve a real name (never
663
+ * the raw userId). */
664
+ handle?: string;
665
+ /** Per-space display name; null or absent means the account username. */
666
+ nickname?: string | null;
661
667
  }
662
668
 
663
669
  export async function getUserPrefs(config: RoomSocialConfig): Promise<Record<string, unknown>> {
@@ -1048,6 +1054,21 @@ export async function changeSpaceMemberRole(
1048
1054
  return result.member;
1049
1055
  }
1050
1056
 
1057
+ /** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
1058
+ export async function setSpaceMemberNickname(
1059
+ config: RoomSocialConfig,
1060
+ spaceId: string,
1061
+ targetUserId: string,
1062
+ nickname: string | null,
1063
+ ): Promise<RoomSpaceMember> {
1064
+ const result = await socialRequest<{ member: RoomSpaceMember }>(
1065
+ config,
1066
+ `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/nickname`,
1067
+ { method: "PATCH", body: { nickname } },
1068
+ );
1069
+ return result.member;
1070
+ }
1071
+
1051
1072
  export async function createRoomSpace(
1052
1073
  config: RoomSocialConfig,
1053
1074
  input: { name: string; access?: "open" | "invite" },
@@ -126,6 +126,11 @@ export interface RoomDmMessage {
126
126
  /** Sealed DM: content is '' and the opaque envelope rides here. */
127
127
  secret?: RoomLedgerSecretPayload | null;
128
128
  metadata?: Record<string, unknown>;
129
+ /** DM image: a private dm-attachments/ key (signed on render) or a legacy
130
+ * public URL. Additive — old messages carry none. */
131
+ imageUrl?: string | null;
132
+ /** Aggregated reactions (per-emoji userId lists). Additive. */
133
+ reactions?: Array<{ emoji: string; users: string[] }>;
129
134
  }
130
135
 
131
136
  export interface RoomDmHistory {
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.5.0";
2
- export const RUNNER_VERSION = "0.5.0";
1
+ export const VERSION = "0.5.1";
2
+ export const RUNNER_VERSION = "0.5.1";