@ai-matrx/meet 0.7.7 → 0.7.9

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/CHANGELOG.md CHANGED
@@ -1,8 +1,59 @@
1
1
  # Changelog — `@ai-matrx/meet`
2
2
 
3
+ ## 0.7.9
4
+ ### (next patch)
5
+
6
+ - **Fix — a database refusal reads as a sentence for a person** (verifier, 2026-09-27, the
7
+ class behind raw toast text): `normalizeMeetError`'s `message` was
8
+ "meet.x: denied by the database (42501: …)" with a remedy about RLS and service-role
9
+ clients, and hosts render `message` + `remedy` in toasts. Now our RPC's own sentence
10
+ (`RAISE EXCEPTION 'Only the host …'`) is the message; Postgres wording, SQLSTATEs and class
11
+ names become a plain sentence ("You don't have access to do that in this meeting.", "This
12
+ meeting changed a moment ago…", "The server could not be reached."); the raw error stays on
13
+ `cause`. `SERVER_FAILED_SENTENCE` / `isRawServerText` now live in `core/errors.ts`
14
+ (still re-exported from the root).
15
+ - **Consumer action:** none.
16
+
17
+ ## 0.7.8
18
+ ### (next patch)
19
+
20
+ - **Fix — a refusal never reaches a person as a Python class name** (verifier, 2026-09-27): a
21
+ host's toast read "Internal server error: UnsupportedClientFeatureError". `readServerRefusal`
22
+ now skips raw server text (`Internal server error: …`, a bare `…Error` class, a snake_case
23
+ code) and falls through to the body's `user_message`, or to `SERVER_FAILED_SENTENCE`. The
24
+ three hand-copied refusal builders (`in-room.ts`, `management.ts`, `after.ts`) collapse into
25
+ ONE exported `refusalFromResponse(response, operation, fallbackRemedy)`, whose `message` is
26
+ the server's sentence — no `meet.x:` operation prefix — and whose `remedy` is the server's.
27
+ - **Fix — a refused Remove / Make host keeps its pane open WITH the sentence** : the confirm
28
+ pane now shows the server's refusal inline (`role="alert"`) with its buttons live; before it
29
+ stayed up silently.
30
+ - **Fix — every per-person menu offers "Message privately"**: a tile's menu (Room stage and a
31
+ host layout's strip, e.g. the Board) now opens the private chat through the store
32
+ (`snapshot.privateChatRequest`, answered by the one `ControlBar`); before only the People
33
+ panel could, so two menus for one person disagreed.
34
+ - **Fix — attendance is per PERSON, never per join**: an export listed the same attendee twice
35
+ under "attended". `attendanceReport` merges a person's joins (account = one person across
36
+ devices and rejoins; an accountless guest's rejoin, which gets a fresh `guest:<random>`
37
+ identity, is matched by the typed name) into one row — first arrival, last departure, total
38
+ time with overlaps counted once, and a new `joins` count. `notesDocument`'s "Attended" line
39
+ and the record view's "Who attended" use the new exported `uniqueAttendees` / `attendeeKey`.
40
+ - **Consumer action:** none (a consumer counting `bundle.attendees` itself should count
41
+ `uniqueAttendees(...)` instead).
42
+
3
43
  ## 0.7.7
4
44
  ### Meet wave 3: after the meeting
5
45
 
46
+ - **Fix — the room header on a phone** (2026-09-27, 390px): the header wraps into rows and
47
+ each host control stays one line; before, the title read "Hollo…" and "End meeting for
48
+ everyone" stacked into four underlined words.
49
+ - **Fix — a removed participant reads the server's sentence, not "invalid token: revoked"**
50
+ (2026-09-27, live room): a server-side disconnect now invalidates the cached room token, so
51
+ the next Join mints again and shows "A host removed you from this meeting."
52
+ - **Fix — a host's per-person menu opens beside its button** (2026-09-27, live room): the
53
+ popover was hidden but IN FLOW on its first frame, so the anchor it measured was button +
54
+ menu and it opened a full menu-height lower — Make host, Report and Remove from meeting were
55
+ below the fold. It is now fixed-positioned from the first frame, re-placed when the layout
56
+ moves, and pinned + scrollable when neither side of the button holds it.
6
57
  - **Fix — the waiting room for people from another organization and for denied guests**
7
58
  (2026-09-27, verified in a real room): a signed-in link-follower now knocks as `user:<id>`, the
8
59
  identity the server's lobby row carries — before, the host saw two entries and admitting one
package/dist/index.cjs CHANGED
@@ -84,6 +84,7 @@ __export(src_exports, {
84
84
  attendanceCsv: () => attendanceCsv,
85
85
  attendanceReport: () => attendanceReport,
86
86
  attendanceTotals: () => attendanceTotals,
87
+ attendeeKey: () => attendeeKey,
87
88
  callInviteAction: () => callInviteAction,
88
89
  callInviteText: () => callInviteText,
89
90
  callStateLabel: () => callStateLabel,
@@ -187,6 +188,7 @@ __export(src_exports, {
187
188
  summarizeMeetingRecordRestrictions: () => summarizeMeetingRecordRestrictions,
188
189
  transcriptDocument: () => transcriptDocument,
189
190
  unavailableFrom: () => unavailableFrom,
191
+ uniqueAttendees: () => uniqueAttendees,
190
192
  userTopic: () => userTopic,
191
193
  wasRefused: () => wasRefused,
192
194
  wrapUpStatus: () => wrapUpStatus
@@ -218,6 +220,20 @@ var MeetError = class extends Error {
218
220
  return this.code === "permission-denied" || this.code === "device-not-found" || this.code === "device-in-use" || this.code === "insecure-context";
219
221
  }
220
222
  };
223
+ var SERVER_FAILED_SENTENCE = "The server hit an unexpected problem and nothing was changed.";
224
+ function isRawServerText(value) {
225
+ const text3 = value.trim();
226
+ return /^internal server error\b/i.test(text3) || /^[A-Z][A-Za-z0-9]*(Error|Exception)$/.test(text3) || /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(text3);
227
+ }
228
+ function humanDatabaseSentence(message) {
229
+ const text3 = message.trim();
230
+ if (text3.length === 0 || isRawServerText(text3)) return null;
231
+ if (/permission denied for|violates|relation "|does not exist|syntax error|column "|jwt|pgrst|duplicate key|null value in/i.test(
232
+ text3
233
+ ))
234
+ return null;
235
+ return text3;
236
+ }
221
237
  var FORBIDDEN_CODES = /* @__PURE__ */ new Set(["42501", "PGRST301", "PGRST302"]);
222
238
  var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["PGRST116", "PGRST205"]);
223
239
  var CONFLICT_CODES = /* @__PURE__ */ new Set(["23505", "40001", "55000"]);
@@ -245,8 +261,8 @@ function normalizeMeetError(error, operation) {
245
261
  if (code !== void 0 && FORBIDDEN_CODES.has(code)) {
246
262
  return new MeetError(
247
263
  "forbidden",
248
- `${operation}: denied by the database (${code}: ${message})`,
249
- "Authorization is RLS plus auth-checked RPCs (R5). Fix the policy or the caller's membership \u2014 never route around it with a service-role client in a browser.",
264
+ humanDatabaseSentence(message) ?? "You don't have access to do that in this meeting.",
265
+ "Ask the meeting's host to do it, or to give you access.",
250
266
  error
251
267
  );
252
268
  }
@@ -261,15 +277,16 @@ function normalizeMeetError(error, operation) {
261
277
  if (code !== void 0 && CONFLICT_CODES.has(code)) {
262
278
  return new MeetError(
263
279
  "conflict",
264
- `${operation}: unique violation (${code}: ${message})`,
265
- "A concurrent writer won. Re-read the meeting rather than retrying the insert \u2014 for a call invite this is the idempotency key doing its job.",
280
+ humanDatabaseSentence(message) ?? "This meeting changed a moment ago, so nothing was saved.",
281
+ "Reload to see the latest, then try again.",
266
282
  error
267
283
  );
268
284
  }
285
+ const hint = error?.hint;
269
286
  return new MeetError(
270
287
  "transport",
271
- `${operation}: ${message}`,
272
- "Transient transport failure. Reads retry once; a join failure surfaces to the room phase as `failed` with this message rather than hanging on a spinner.",
288
+ /failed to fetch|networkerror|load failed|network request failed/i.test(message) ? "The server could not be reached." : humanDatabaseSentence(message) ?? SERVER_FAILED_SENTENCE,
289
+ typeof hint === "string" && humanDatabaseSentence(hint) !== null ? hint.trim() : "Nothing was changed \u2014 retry in a moment; if it keeps happening, reload the page.",
273
290
  error
274
291
  );
275
292
  }
@@ -677,6 +694,24 @@ function readText(data) {
677
694
  }
678
695
  return "";
679
696
  }
697
+ async function refusalFromResponse(response, operation, fallbackRemedy) {
698
+ let body = null;
699
+ try {
700
+ body = await response.json();
701
+ } catch {
702
+ body = null;
703
+ }
704
+ const said = readServerRefusal(body);
705
+ const code = response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport";
706
+ const error = new MeetError(
707
+ code,
708
+ said?.reason ?? SERVER_FAILED_SENTENCE,
709
+ said !== null && said.remedy.length > 0 ? said.remedy : fallbackRemedy
710
+ );
711
+ error.operation = operation;
712
+ error.status = response.status;
713
+ return error;
714
+ }
680
715
  function readServerRefusal(body) {
681
716
  if (typeof body === "string") {
682
717
  return body.trim().length === 0 ? null : { reason: body.trim(), remedy: "" };
@@ -695,7 +730,8 @@ function readServerRefusal(body) {
695
730
  return messages.length === 0 ? null : { reason: messages.join("; "), remedy: "" };
696
731
  }
697
732
  if (typeof detail === "object" && detail !== null) return readServerRefusal(detail);
698
- const reason = typeof row["reason"] === "string" ? row["reason"] : typeof row["message"] === "string" ? row["message"] : typeof detail === "string" ? detail : typeof row["error"] === "string" ? row["error"] : null;
733
+ const humanOrNull = (value) => typeof value === "string" && value.trim().length > 0 && !isRawServerText(value) ? value.trim() : null;
734
+ const reason = humanOrNull(row["reason"]) ?? humanOrNull(row["message"]) ?? humanOrNull(row["user_message"]) ?? (typeof detail === "string" ? humanOrNull(detail) : null) ?? (typeof row["message"] === "string" || typeof row["error"] === "string" || typeof detail === "string" ? SERVER_FAILED_SENTENCE : null);
699
735
  if (reason === null) return null;
700
736
  const remedy = typeof row["remedy"] === "string" ? row["remedy"] : "";
701
737
  if (remedy.length > 0) return { reason, remedy };
@@ -3378,6 +3414,7 @@ var EMPTY = {
3378
3414
  spotlight: null,
3379
3415
  moderationNotice: null,
3380
3416
  actionNotice: null,
3417
+ privateChatRequest: null,
3381
3418
  effects: DEFAULT_EFFECTS,
3382
3419
  transcript: [],
3383
3420
  notes: [],
@@ -4068,9 +4105,55 @@ function ms(iso) {
4068
4105
  const at = new Date(iso).getTime();
4069
4106
  return Number.isNaN(at) ? null : at;
4070
4107
  }
4108
+ function attendeeKey(person, names = {}) {
4109
+ if (person.userId !== null) return `user:${person.userId}`;
4110
+ if (person.isAgent) return `agent:${person.identity}`;
4111
+ const name = displayNameFor(names, person.identity, person.displayName).trim().toLowerCase();
4112
+ return name.length > 0 ? `guest-name:${name}` : `identity:${person.identity}`;
4113
+ }
4114
+ function uniqueAttendees(attendees, names = {}) {
4115
+ const byKey = /* @__PURE__ */ new Map();
4116
+ for (const person of attendees) {
4117
+ const key = attendeeKey(person, names);
4118
+ const kept = byKey.get(key);
4119
+ if (kept === void 0) {
4120
+ byKey.set(key, person);
4121
+ continue;
4122
+ }
4123
+ const keptJoin = ms(kept.joinedAt);
4124
+ const join = ms(person.joinedAt);
4125
+ if (join !== null && (keptJoin === null || join < keptJoin)) byKey.set(key, person);
4126
+ }
4127
+ return [...byKey.values()];
4128
+ }
4129
+ var KIND_RANK = {
4130
+ host: 0,
4131
+ cohost: 1,
4132
+ member: 2,
4133
+ "signed-in guest": 3,
4134
+ guest: 4,
4135
+ assistant: 5
4136
+ };
4137
+ function coveredMs(intervals) {
4138
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
4139
+ let total = 0;
4140
+ let from = -Infinity;
4141
+ let to = -Infinity;
4142
+ for (const [start, end] of sorted) {
4143
+ if (start > to) {
4144
+ if (to > from) total += to - from;
4145
+ from = start;
4146
+ to = end;
4147
+ } else if (end > to) {
4148
+ to = end;
4149
+ }
4150
+ }
4151
+ if (to > from) total += to - from;
4152
+ return total;
4153
+ }
4071
4154
  function attendanceReport(meeting, attendees, names = {}) {
4072
4155
  const end = ms(meeting.endedAt);
4073
- const rows = attendees.map((person) => {
4156
+ const perJoin = attendees.map((person) => {
4074
4157
  const joined = ms(person.joinedAt);
4075
4158
  const left = ms(person.leftAt);
4076
4159
  const until = left ?? end;
@@ -4085,17 +4168,45 @@ function attendanceReport(meeting, attendees, names = {}) {
4085
4168
  leftAt: person.leftAt,
4086
4169
  durationMs: duration,
4087
4170
  durationToEnd: duration !== null && left === null,
4088
- admission: person.admissionState
4171
+ admission: person.admissionState,
4172
+ joins: 1
4089
4173
  };
4090
4174
  });
4091
- const rank = {
4092
- host: 0,
4093
- cohost: 1,
4094
- member: 2,
4095
- "signed-in guest": 3,
4096
- guest: 4,
4097
- assistant: 5
4098
- };
4175
+ const groups = /* @__PURE__ */ new Map();
4176
+ attendees.forEach((person, index) => {
4177
+ const key = attendeeKey(person, names);
4178
+ const group = groups.get(key) ?? { rows: [], people: [] };
4179
+ group.rows.push(perJoin[index]);
4180
+ group.people.push(person);
4181
+ groups.set(key, group);
4182
+ });
4183
+ const rows = [...groups.values()].map(({ rows: joins }) => {
4184
+ if (joins.length === 1) return joins[0];
4185
+ const came = joins.filter((row) => row.joinedAt !== null);
4186
+ const first = [...came].sort((a, b) => (ms(a.joinedAt) ?? 0) - (ms(b.joinedAt) ?? 0))[0] ?? joins[0];
4187
+ const kind = joins.reduce(
4188
+ (best, row) => KIND_RANK[row.kind] < KIND_RANK[best] ? row.kind : best,
4189
+ first.kind
4190
+ );
4191
+ const open = came.some((row) => row.leftAt === null);
4192
+ const lastLeave = came.reduce((latest, row) => {
4193
+ const left = ms(row.leftAt);
4194
+ return left !== null && (latest === null || left > latest) ? left : latest;
4195
+ }, null);
4196
+ const intervals = came.flatMap((row) => {
4197
+ const joined = ms(row.joinedAt);
4198
+ return joined !== null && row.durationMs !== null ? [[joined, joined + row.durationMs]] : [];
4199
+ });
4200
+ return {
4201
+ ...first,
4202
+ kind,
4203
+ leftAt: open ? null : lastLeave === null ? null : new Date(lastLeave).toISOString(),
4204
+ durationMs: intervals.length === 0 ? null : coveredMs(intervals),
4205
+ durationToEnd: came.some((row) => row.durationToEnd),
4206
+ joins: came.length === 0 ? joins.length : came.length
4207
+ };
4208
+ });
4209
+ const rank = KIND_RANK;
4099
4210
  return rows.sort((a, b) => {
4100
4211
  if (rank[a.kind] !== rank[b.kind]) return rank[a.kind] - rank[b.kind];
4101
4212
  return (ms(a.joinedAt) ?? Number.MAX_SAFE_INTEGER) - (ms(b.joinedAt) ?? Number.MAX_SAFE_INTEGER);
@@ -4253,7 +4364,10 @@ function bullet(note) {
4253
4364
  function notesDocument(bundle, options = {}) {
4254
4365
  const out = [`# ${bundle.meeting.title}`, ""];
4255
4366
  if (options.when) out.push(`_${options.when}_`, "");
4256
- const people = bundle.attendees.filter((person) => !person.isAgent && person.joinedAt !== null).map((person) => displayNameFor(bundle.names, person.identity, person.displayName));
4367
+ const people = uniqueAttendees(
4368
+ bundle.attendees.filter((person) => !person.isAgent && person.joinedAt !== null),
4369
+ bundle.names
4370
+ ).map((person) => displayNameFor(bundle.names, person.identity, person.displayName));
4257
4371
  if (people.length > 0) out.push(`**Attended:** ${people.join(", ")}`, "");
4258
4372
  out.push("## Summary", "", bundle.summary?.text.trim() || "No summary was written.", "");
4259
4373
  out.push("## Decisions", "");
@@ -4317,19 +4431,8 @@ async function loadMeetingChat(api, meetingId, roomToken = null) {
4317
4431
  refusal: null
4318
4432
  };
4319
4433
  }
4320
- async function refusalOf(response, operation) {
4321
- let body = null;
4322
- try {
4323
- body = await response.json();
4324
- } catch {
4325
- body = null;
4326
- }
4327
- const said = readServerRefusal(body);
4328
- return new MeetError(
4329
- response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport",
4330
- `${operation}: ${said?.reason ?? `the server answered ${response.status}`}`,
4331
- said?.remedy || "Nothing was sent. Try again."
4332
- );
4434
+ function refusalOf(response, operation) {
4435
+ return refusalFromResponse(response, operation, "Nothing was sent. Try again.");
4333
4436
  }
4334
4437
  function asRecipient(row) {
4335
4438
  const sources = Array.isArray(row["sources"]) ? row["sources"] : [];
@@ -4390,18 +4493,11 @@ async function sendFollowUp(api, meetingId, input) {
4390
4493
  }
4391
4494
 
4392
4495
  // src/core/management.ts
4393
- async function refusal(response, operation) {
4394
- let body = null;
4395
- try {
4396
- body = await response.json();
4397
- } catch {
4398
- body = null;
4399
- }
4400
- const said = readServerRefusal(body);
4401
- return new MeetError(
4402
- response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport",
4403
- `${operation}: ${said?.reason ?? `the server answered ${response.status}`}`,
4404
- said?.remedy || "The meeting itself is saved \u2014 only the telling failed. Try again; the call is idempotent."
4496
+ function refusal(response, operation) {
4497
+ return refusalFromResponse(
4498
+ response,
4499
+ operation,
4500
+ "The meeting itself is saved \u2014 only the telling failed. Try again; the call is idempotent."
4405
4501
  );
4406
4502
  }
4407
4503
  function count(body, key) {
@@ -4727,6 +4823,10 @@ function createRoomEngine(options) {
4727
4823
  }
4728
4824
  },
4729
4825
  disconnected(reason) {
4826
+ if (current !== null) {
4827
+ tokens.invalidate(current.roomName);
4828
+ current = null;
4829
+ }
4730
4830
  store.patch({ phase: "left" });
4731
4831
  options.onLeft?.(reason);
4732
4832
  }
@@ -5029,19 +5129,8 @@ function createInRoomClient(options) {
5029
5129
  const token = options.resolveRoomToken();
5030
5130
  return token === null ? void 0 : { [ROOM_TOKEN_HEADER]: token };
5031
5131
  }
5032
- async function refusal2(response, operation, remedy) {
5033
- let body = null;
5034
- try {
5035
- body = await response.json();
5036
- } catch {
5037
- body = null;
5038
- }
5039
- const said = readServerRefusal(body);
5040
- return new MeetError(
5041
- response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport",
5042
- said?.reason ?? `${operation}: the server answered ${response.status}`,
5043
- said?.remedy !== void 0 && said.remedy.length > 0 ? said.remedy : remedy
5044
- );
5132
+ function refusal2(response, operation, remedy) {
5133
+ return refusalFromResponse(response, operation, remedy);
5045
5134
  }
5046
5135
  return {
5047
5136
  async moderate(request) {