@agent-team-foundation/first-tree-hub 0.12.7 → 0.12.8

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.
@@ -2,10 +2,10 @@ import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw
2
2
  import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-BcW9HgkG.mjs";
3
3
  import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-BCZC1ki6.mjs";
4
4
  import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
5
- import { A as delegateFeishuUserSchema, B as inboxPollQuerySchema, C as createAgentSchema, D as createOrgFromMeSchema, E as createMemberSchema, F as githubDevCallbackQuerySchema, G as listMeChatsQuerySchema, H as isRedactedEnvValue, I as githubStartQuerySchema, J as notificationQuerySchema, K as loginSchema, L as imageInlineContentSchema, N as githubAppInstallationClaimBodySchema, P as githubCallbackQuerySchema, R as inboxAckFrameSchema, S as createAdapterMappingSchema, St as wsAuthFrameSchema, T as createMeChatSchema, U as isReservedAgentName$1, V as isOrgSettingNamespace, W as joinByInvitationSchema, X as paginationQuerySchema, Y as onboardingEventSchema, Z as patchOnboardingSchema, _t as updateAgentSchema, a as AGENT_VISIBILITY, at as selfServiceFeishuBotSchema, b as contextTreeSnapshotSchema, bt as updateMemberSchema, c as ORG_SETTINGS_NAMESPACES$1, ct as sessionCompletionMessageSchema, d as addParticipantSchema, dt as sessionReconcileRequestSchema, et as rebindAgentSchema, f as agentBindRequestSchema, ft as sessionStateMessageSchema, g as chatMetadataSchema$1, gt as updateAgentRuntimeConfigSchema, h as agentTypeSchema$1, ht as updateAdapterConfigSchema, i as AGENT_STATUSES, j as dryRunAgentRuntimeConfigSchema, k as defaultRuntimeConfigPayload, l as WS_AUTH_FRAME_TIMEOUT_MS, lt as sessionEventMessageSchema, m as agentRuntimeConfigPayloadSchema$1, mt as submitQuestionAnswerSchema, n as AGENT_NAME_REGEX$1, nt as runtimeStateMessageSchema, ot as sendMessageSchema, p as agentPinnedMessageSchema$1, pt as stripCode, q as messageSourceSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as safeRedirectPath, s as MENTION_REGEX, st as sendToAgentSchema, t as AGENT_BIND_REJECT_REASONS, tt as refreshTokenSchema, u as addMeChatParticipantsSchema, ut as sessionEventSchema$1, v as clientRegisterSchema, vt as updateChatSchema, w as createChatSchema, x as createAdapterConfigSchema, xt as updateOrganizationSchema, y as connectTokenExchangeSchema, yt as updateClientCapabilitiesSchema, z as inboxDeliverFrameSchema$1 } from "./dist-CTkhS6p5.mjs";
5
+ import { $ as patchOnboardingSchema, A as defaultRuntimeConfigPayload, B as inboxDeliverFrameSchema$1, C as createAdapterMappingSchema, Ct as updateOrganizationSchema, D as createMemberSchema, E as createMeChatSchema, F as githubCallbackQuerySchema, G as joinByInvitationSchema, H as isOrgSettingNamespace, I as githubDevCallbackQuerySchema, J as messageSourceSchema$1, K as listMeChatsQuerySchema, L as githubStartQuerySchema, M as dryRunAgentRuntimeConfigSchema, O as createOrgFromMeSchema, P as githubAppInstallationClaimBodySchema, Q as patchChatEngagementSchema, R as imageInlineContentSchema, S as createAdapterConfigSchema, St as updateMemberSchema, T as createChatSchema, U as isRedactedEnvValue, V as inboxPollQuerySchema, W as isReservedAgentName$1, X as onboardingEventSchema, Y as notificationQuerySchema, Z as paginationQuerySchema, _ as chatMetadataSchema$1, _t as updateAdapterConfigSchema, a as AGENT_VISIBILITY, at as safeRedirectPath, b as connectTokenExchangeSchema, bt as updateChatSchema, c as MENTION_REGEX, ct as sendMessageSchema, d as addMeChatParticipantsSchema, dt as sessionEventMessageSchema, f as addParticipantSchema, ft as sessionEventSchema$1, g as agentTypeSchema$1, gt as submitQuestionAnswerSchema, h as agentRuntimeConfigPayloadSchema$1, ht as stripCode, i as AGENT_STATUSES, it as runtimeStateMessageSchema, j as delegateFeishuUserSchema, l as ORG_SETTINGS_NAMESPACES$1, lt as sendToAgentSchema, m as agentPinnedMessageSchema$1, mt as sessionStateMessageSchema, n as AGENT_NAME_REGEX$1, nt as rebindAgentSchema, o as CHAT_ENGAGEMENT_STATUSES, p as agentBindRequestSchema, pt as sessionReconcileRequestSchema, q as loginSchema, r as AGENT_SELECTOR_HEADER$1, rt as refreshTokenSchema, st as selfServiceFeishuBotSchema, t as AGENT_BIND_REJECT_REASONS, u as WS_AUTH_FRAME_TIMEOUT_MS, ut as sessionCompletionMessageSchema, vt as updateAgentRuntimeConfigSchema, w as createAgentSchema, wt as wsAuthFrameSchema, x as contextTreeSnapshotSchema, xt as updateClientCapabilitiesSchema, y as clientRegisterSchema, yt as updateAgentSchema, z as inboxAckFrameSchema } from "./dist-CnjqakXS.mjs";
6
6
  import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
7
7
  import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
8
- import { $ as messages, A as getOnlineCount, B as listActiveAgentsPinnedToClient, C as ensureCanJoin, D as getCachedAudience, E as getActivityOverview, F as invalidateChatAudience, G as listChatsForMember, H as listAgentsWithRuntime, I as joinAsParticipant, J as listMessages, K as listClients, L as joinChat, M as heartbeatClient, N as heartbeatInstance, O as getChatDetail, P as inboxEntries, Q as members, R as leaveAsParticipant, S as editMessage, T as findOrCreateDirectChat, U as listChatParticipantsWithNames, V as listAgentsManagedByUser, W as listChats, X as markStaleAgents, Z as markSupersededByChat, _ as clients, _t as touchAgent, a as agentVisibilityCondition, at as registerChatMessageDispatcher, b as deriveAuthState, bt as upsertSessionState, c as assertParticipant, ct as resetActivity, d as chatParticipants, dt as sendMessage, et as notifyRecipients, f as chatSubscriptions, ft as sendToAgent$1, g as cleanupStalePresence, gt as submitAnswer, h as cleanupStaleClients, ht as setRuntimeState, i as agentPresence, it as recomputeWatchersForMember, j as getPresence, k as getClient, l as bindAgent, lt as resolveChatMembership, m as claimClient, mt as setOffline, n as addParticipant, nt as recomputeChatWatchers, o as agents, ot as registerClient, p as chats, pt as serverInstances, q as listClientsForOrgAdmin, r as agentChatSessions, rt as recomputeWatchersForAgent, s as assertClientOwner, st as removeParticipant, t as addChatParticipants, tt as pendingQuestions, u as changeChatType, ut as retireClient, v as createChat, vt as unbindAgent, w as ensureParticipant$1, x as disconnectClient, y as createNotifier, yt as updateClientCapabilities, z as leaveChat } from "./client-h5l7mi0m-OEX7MOBg.mjs";
8
+ import { $ as notifyRecipients, A as getPresence, B as listAgentsManagedByUser, C as ensureParticipant, D as getChatDetail, E as getCachedAudience, F as joinAsParticipant, G as listClients, H as listChatParticipantsWithNames, I as joinChat, K as listClientsForOrgAdmin, L as leaveAsParticipant, M as heartbeatInstance, N as inboxEntries, O as getClient, P as invalidateChatAudience, Q as messages, R as leaveChat, S as ensureCanJoin, T as getActivityOverview, U as listChats, V as listAgentsWithRuntime, W as listChatsForMember, X as markSupersededByChat, Y as markStaleAgents, Z as members, _ as createChat, _t as unbindAgent, a as agentVisibilityCondition, at as registerClient, b as disconnectClient, c as assertParticipant, ct as resolveChatMembership, d as chatMembership, dt as sendToAgent$1, et as pendingQuestions, f as chats, ft as serverInstances, g as clients, gt as touchAgent, h as cleanupStalePresence, ht as submitAnswer, i as agentPresence, it as registerChatMessageDispatcher, j as heartbeatClient, k as getOnlineCount, l as bindAgent, lt as retireClient, m as cleanupStaleClients, mt as setRuntimeState, n as addParticipant, nt as recomputeWatchersForAgent, o as agents, ot as removeParticipant, p as claimClient, pt as setOffline, q as listMessages, r as agentChatSessions, rt as recomputeWatchersForMember, s as assertClientOwner, st as resetActivity, t as addChatParticipants, tt as recomputeChatWatchers, u as changeChatType, ut as sendMessage, v as createNotifier, vt as updateClientCapabilities, w as findOrCreateDirectChat, x as editMessage, y as deriveAuthState, yt as upsertSessionState, z as listActiveAgentsPinnedToClient } from "./client-bR8nwHaV-OxnjyKOk.mjs";
9
9
  import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
10
10
  import { createRequire } from "node:module";
11
11
  import { ZodError, z } from "zod";
@@ -764,6 +764,12 @@ const chatTypeSchema = z.enum([
764
764
  "group",
765
765
  "thread"
766
766
  ]);
767
+ const chatEngagementStatusSchema = z.enum([
768
+ "active",
769
+ "archived",
770
+ "deleted"
771
+ ]);
772
+ z.object({ status: chatEngagementStatusSchema });
767
773
  z.object({
768
774
  type: chatTypeSchema,
769
775
  topic: z.string().max(500).optional(),
@@ -793,7 +799,8 @@ z.object({
793
799
  }).extend({
794
800
  participants: z.array(chatParticipantSchema),
795
801
  title: z.string(),
796
- firstMessagePreview: z.string().nullable()
802
+ firstMessagePreview: z.string().nullable(),
803
+ engagementStatus: chatEngagementStatusSchema
797
804
  });
798
805
  z.object({ topic: z.string().trim().max(500).nullable() });
799
806
  z.object({ agentId: z.string().min(1) });
@@ -1242,10 +1249,16 @@ const meChatFilterSchema = z.enum([
1242
1249
  "watching"
1243
1250
  ]);
1244
1251
  const meChatMembershipKindSchema = z.enum(["participant", "watching"]);
1252
+ const chatEngagementViewSchema = z.enum([
1253
+ "active",
1254
+ "archived",
1255
+ "all"
1256
+ ]);
1245
1257
  z.object({
1246
1258
  cursor: z.string().optional(),
1247
1259
  limit: z.coerce.number().int().min(1).max(200).default(50),
1248
- filter: meChatFilterSchema.default("all")
1260
+ filter: meChatFilterSchema.default("all"),
1261
+ engagement: chatEngagementViewSchema.default("active")
1249
1262
  });
1250
1263
  const meChatParticipantSchema = z.object({
1251
1264
  agentId: z.string(),
@@ -1263,7 +1276,8 @@ const meChatRowSchema = z.object({
1263
1276
  lastMessageAt: z.string().nullable(),
1264
1277
  lastMessagePreview: z.string().nullable(),
1265
1278
  unreadMentionCount: z.number().int(),
1266
- canReply: z.boolean()
1279
+ canReply: z.boolean(),
1280
+ engagementStatus: chatEngagementStatusSchema
1267
1281
  });
1268
1282
  z.object({
1269
1283
  rows: z.array(meChatRowSchema),
@@ -9422,7 +9436,7 @@ async function onboardCreate(args) {
9422
9436
  }
9423
9437
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9424
9438
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9425
- const { bindFeishuBot } = await import("./feishu-DJm0EaZP.mjs").then((n) => n.r);
9439
+ const { bindFeishuBot } = await import("./feishu-DrnBbl8T.mjs").then((n) => n.r);
9426
9440
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9427
9441
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9428
9442
  else {
@@ -10635,7 +10649,7 @@ function createFeedbackHandler(config) {
10635
10649
  return { handle };
10636
10650
  }
10637
10651
  //#endregion
10638
- //#region ../server/dist/app-BGjMcVXo.mjs
10652
+ //#region ../server/dist/app-mkBHfGPl.mjs
10639
10653
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10640
10654
  init_esm();
10641
10655
  var __defProp = Object.defineProperty;
@@ -10742,7 +10756,7 @@ async function requireChatAccess(request, db) {
10742
10756
  role: caller.role,
10743
10757
  humanAgentId: caller.humanAgentId
10744
10758
  };
10745
- const [direct] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, caller.humanAgentId))).limit(1);
10759
+ const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
10746
10760
  if (direct) {
10747
10761
  stampOrgScope(request, scope);
10748
10762
  stampChatResource(request, chat);
@@ -10751,7 +10765,7 @@ async function requireChatAccess(request, db) {
10751
10765
  scope
10752
10766
  };
10753
10767
  }
10754
- const participantRows = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10768
+ const participantRows = await db.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
10755
10769
  if (participantRows.length === 0) throw new NotFoundError(`Chat "${chatId}" not found`);
10756
10770
  const participantIds = participantRows.map((p) => p.agentId);
10757
10771
  const [managed] = await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantIds), eq(agents.managerId, caller.memberId))).limit(1);
@@ -11843,19 +11857,6 @@ async function findOrCreateChatForChannel(db, data) {
11843
11857
  return chatId;
11844
11858
  });
11845
11859
  }
11846
- /**
11847
- * Ensure an agent is a participant of a chat (no-op if already). Mode is
11848
- * derived via the canonical entrypoint — pre-fix this also wrote `mode:`
11849
- * implicitly via schema default `'full'`, which is wrong for non-human
11850
- * agents in a group chat (the bug §1.1 of the Phase 1 design doc fixes).
11851
- */
11852
- async function ensureParticipant(db, chatId, agentId) {
11853
- const [exists] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
11854
- if (!exists) await addChatParticipants(db, chatId, [{
11855
- agentId,
11856
- role: "member"
11857
- }], { onConflictDoNothing: true });
11858
- }
11859
11860
  /** Store a cross-reference between internal and external message. */
11860
11861
  async function createMessageReference(db, data) {
11861
11862
  await db.insert(adapterMessageReferences).values({
@@ -11938,9 +11939,9 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
11938
11939
  const modeByChat = /* @__PURE__ */ new Map();
11939
11940
  if (chatIds.length > 0) {
11940
11941
  const rows = await db.select({
11941
- chatId: chatParticipants.chatId,
11942
- mode: chatParticipants.mode
11943
- }).from(chatParticipants).where(and(eq(chatParticipants.agentId, agentId), inArray(chatParticipants.chatId, chatIds)));
11942
+ chatId: chatMembership.chatId,
11943
+ mode: chatMembership.mode
11944
+ }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
11944
11945
  for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
11945
11946
  }
11946
11947
  const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
@@ -12341,7 +12342,7 @@ async function prepareImageOutbound(db, notifier, chatId, data) {
12341
12342
  * chat reply (see `services/message.ts` replyTo routing).
12342
12343
  */
12343
12344
  async function collectTargetInboxes(db, chatId, inReplyTo) {
12344
- const participants = await db.select({ inboxId: agents.inboxId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
12345
+ const participants = await db.select({ inboxId: agents.inboxId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
12345
12346
  const set = new Set(participants.map((p) => p.inboxId));
12346
12347
  if (inReplyTo) {
12347
12348
  const [original] = await db.select({ replyToInbox: messages.replyToInbox }).from(messages).where(eq(messages.id, inReplyTo)).limit(1);
@@ -15267,6 +15268,47 @@ async function bootstrapConfigRoutes(_app) {
15267
15268
  return { allowedOrg: null };
15268
15269
  });
15269
15270
  }
15271
+ /**
15272
+ * Per-(chat, agent) user state — independent from membership structure.
15273
+ *
15274
+ * This is the third layer of the chat data model: while `chats` owns
15275
+ * the entity and `chat_membership` owns the structural relation
15276
+ * (who can speak, who watches), this table owns the user's private
15277
+ * state about a chat. The reason it lives apart: structural changes
15278
+ * (speaker ↔ watcher, manager rebind, recompute) must never overwrite
15279
+ * user-private state — physical separation makes that an invariant
15280
+ * rather than a service-layer discipline.
15281
+ *
15282
+ * Columns evolve incrementally as new per-user state is needed.
15283
+ * Currently:
15284
+ * - `last_read_at`, `unread_mention_count` — seeded by PR-A from
15285
+ * the legacy `chat_participants` / `chat_subscriptions` columns.
15286
+ * - `engagement_status` — added in 0040; per-(chat, user) view
15287
+ * state (active / archived / deleted). Auto-revives archived →
15288
+ * active on new message; deleted is sticky (only the user can
15289
+ * restore from the chat detail page).
15290
+ *
15291
+ * Future fields slated for this table: pinned, mute_until, draft,
15292
+ * custom_title, last_seen_at — each as a separate change.
15293
+ *
15294
+ * Rows are lazy-upserted on first user write (markRead / mention
15295
+ * counter bump / engagement transition). Reads use COALESCE for
15296
+ * defaults so callers see `'active'` etc. even when no row exists.
15297
+ * Service-layer integrity (no FK / CHECK / trigger).
15298
+ *
15299
+ * See proposals/chat-data-model-restructure.20260512.md §8.6.
15300
+ */
15301
+ const chatUserState = pgTable("chat_user_state", {
15302
+ chatId: text("chat_id").notNull(),
15303
+ agentId: text("agent_id").notNull(),
15304
+ lastReadAt: timestamp("last_read_at", { withTimezone: true }),
15305
+ unreadMentionCount: integer("unread_mention_count").notNull().default(0),
15306
+ engagementStatus: text("engagement_status").notNull().default("active")
15307
+ }, (table) => [
15308
+ primaryKey({ columns: [table.chatId, table.agentId] }),
15309
+ index("idx_user_state_agent").on(table.agentId),
15310
+ index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
15311
+ ]);
15270
15312
  /** Extract a plain-text summary from a message's JSONB content field.
15271
15313
  * Used as the auto-title fallback in chat list rendering — see
15272
15314
  * `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
@@ -15462,7 +15504,7 @@ async function transitionSessionState(db, agentId, chatId, target, from, organiz
15462
15504
  async function filterSessionsByParticipant(db, sessions, participantAgentId) {
15463
15505
  if (sessions.length === 0) return [];
15464
15506
  const chatIds = sessions.map((s) => s.chatId);
15465
- const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
15507
+ const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
15466
15508
  const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
15467
15509
  return sessions.filter((s) => allowedChatIds.has(s.chatId));
15468
15510
  }
@@ -15471,16 +15513,17 @@ async function filterSessionsByParticipant(db, sessions, participantAgentId) {
15471
15513
  * workspace).
15472
15514
  *
15473
15515
  * Responsibilities:
15474
- * - Cursor-paginated conversation list across participant + watcher rows
15475
- * for the caller's human agent.
15516
+ * - Cursor-paginated conversation list (single-stream JOIN over the
15517
+ * unified `chat_membership` + `chat_user_state` tables).
15476
15518
  * - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
15477
- * - Add participants (idempotent, runs `recomputeChatWatchers` after).
15478
- * - Mark-read (touches whichever of the two tables holds the user's row).
15479
- * - Join → state-carry watcher → speaker (delegates to `watcher.ts`).
15480
- * - Leavestate-carry speaker → watcher (delegates to `watcher.ts`).
15519
+ * - Add participants (idempotent, UPSERT into `chat_membership`,
15520
+ * runs `recomputeChatWatchers` after).
15521
+ * - Mark-read (UPSERT into `chat_user_state`).
15522
+ * - Joinwatcher to speaker (delegates to `watcher.ts`).
15523
+ * - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
15481
15524
  *
15482
- * See docs/chat-first-workspace-product-design.md "API Contract" + "Data
15483
- * Model".
15525
+ * See proposals/chat-data-model-restructure.20260512.md §8 (schema)
15526
+ * and §11.1 (per-route mapping).
15484
15527
  */
15485
15528
  function encodeCursor(lastMessageAt, chatId) {
15486
15529
  const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
@@ -15504,17 +15547,61 @@ function decodeCursor(cursor) {
15504
15547
  return null;
15505
15548
  }
15506
15549
  }
15550
+ const { ACTIVE, ARCHIVED, DELETED } = CHAT_ENGAGEMENT_STATUSES;
15551
+ /**
15552
+ * SQL predicate for each engagement view tab. `deleted` is never a valid view
15553
+ * value — deleted rows are reachable only through `GET /chats/:chatId` + the
15554
+ * Restore banner on the chat detail page.
15555
+ */
15556
+ const ENGAGEMENT_VIEW_PREDICATE = {
15557
+ active: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ACTIVE}`,
15558
+ archived: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ARCHIVED}`,
15559
+ all: sql`COALESCE(cus.engagement_status, ${ACTIVE}) IN (${ACTIVE}, ${ARCHIVED})`
15560
+ };
15561
+ /**
15562
+ * Write the caller's engagement state for this chat. UPSERT into
15563
+ * `chat_user_state` — the row may not yet exist (the user might not have
15564
+ * marked-read or been @-mentioned), so an INSERT with the engagement value
15565
+ * is the first write; subsequent transitions are UPDATEs.
15566
+ *
15567
+ * Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
15568
+ */
15569
+ async function setChatEngagement(db, chatId, agentId, status) {
15570
+ await db.insert(chatUserState).values({
15571
+ chatId,
15572
+ agentId,
15573
+ unreadMentionCount: 0,
15574
+ engagementStatus: status
15575
+ }).onConflictDoUpdate({
15576
+ target: [chatUserState.chatId, chatUserState.agentId],
15577
+ set: { engagementStatus: status }
15578
+ });
15579
+ }
15580
+ /**
15581
+ * Read the caller's engagement state. Returns `'active'` when no
15582
+ * `chat_user_state` row exists yet (lazy-materialised; matches the SQL
15583
+ * `COALESCE(..., 'active')` used elsewhere).
15584
+ */
15585
+ async function getCallerEngagement(db, chatId, agentId) {
15586
+ const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
15587
+ return row?.engagementStatus ?? ACTIVE;
15588
+ }
15507
15589
  /**
15508
15590
  * GET /me/chats — cursor-paginated conversation list.
15509
15591
  *
15510
15592
  * SQL strategy:
15511
- * - One query that UNIONs participant rows and subscription rows for the
15512
- * caller's human agent, joined to chats. The UNION+coalesce keeps both
15513
- * `unread_mention_count` and `membership_kind` per row.
15514
- * - Filter `parent_chat_id IS NULL` (threads are excluded in v1).
15593
+ * - Single-stream query: `chats JOIN chat_membership LEFT JOIN
15594
+ * chat_user_state`. The membership row carries access_mode
15595
+ * (speaker "participant" / watcher → "watching"); the user
15596
+ * state row supplies the unread counter (COALESCE 0 when
15597
+ * row is missing).
15598
+ * - Filter `parent_chat_id IS NULL` (threads excluded in v1).
15599
+ * - Filter `c.organization_id = ?` to defend against historical
15600
+ * cross-org pollution rows that may still reference the caller
15601
+ * (see fix/cross-org-direct-chat-pollution).
15515
15602
  * - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
15516
- * - Cursor narrows the result to rows STRICTLY before `(cursor.ts, cursor.id)`.
15517
- * - Followed by a small participant-list lookup for the page only.
15603
+ * - Cursor narrows the result to rows STRICTLY before the cursor.
15604
+ * - Followed by a participants-list lookup for the page only.
15518
15605
  */
15519
15606
  async function listMeChats(db, humanAgentId, organizationId, query) {
15520
15607
  const limit = query.limit;
@@ -15522,28 +15609,12 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15522
15609
  if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
15523
15610
  const filterUnreadOnly = query.filter === "unread";
15524
15611
  const filterWatchingOnly = query.filter === "watching";
15612
+ const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
15525
15613
  const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
15526
15614
  const cursorPredicate = !cursor ? sql`TRUE` : cursor.lastMessageAt === null ? sql`(c.last_message_at IS NULL AND c.id < ${cursor.chatId})` : sql`(c.last_message_at IS NULL
15527
15615
  OR c.last_message_at < ${cursorTsIso}::timestamptz
15528
15616
  OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
15529
15617
  const rawRows = await db.execute(sql`
15530
- WITH membership AS (
15531
- SELECT chat_id, 'participant'::text AS membership_kind, unread_mention_count
15532
- FROM chat_participants
15533
- WHERE agent_id = ${humanAgentId}
15534
- UNION ALL
15535
- SELECT chat_id, 'watching'::text AS membership_kind, unread_mention_count
15536
- FROM chat_subscriptions
15537
- WHERE agent_id = ${humanAgentId}
15538
- ),
15539
- /* Resolve duplicates (should not happen post-invariant-1, but cheap) by
15540
- preferring the participant row. */
15541
- deduped AS (
15542
- SELECT DISTINCT ON (chat_id)
15543
- chat_id, membership_kind, unread_mention_count
15544
- FROM membership
15545
- ORDER BY chat_id, CASE WHEN membership_kind = 'participant' THEN 0 ELSE 1 END
15546
- )
15547
15618
  SELECT
15548
15619
  c.id AS chat_id,
15549
15620
  c.type AS type,
@@ -15551,20 +15622,26 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15551
15622
  c.parent_chat_id AS parent_chat_id,
15552
15623
  c.last_message_at AS last_message_at,
15553
15624
  c.last_message_preview AS last_message_preview,
15554
- (SELECT count(*) FROM chat_participants WHERE chat_id = c.id) AS participant_count,
15555
- d.membership_kind AS membership_kind,
15556
- d.unread_mention_count AS unread_mention_count
15625
+ (SELECT count(*) FROM chat_membership
15626
+ WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
15627
+ cm.access_mode AS access_mode,
15628
+ COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
15629
+ COALESCE(cus.engagement_status, ${ACTIVE}) AS engagement_status
15557
15630
  FROM chats c
15558
- JOIN deduped d ON d.chat_id = c.id
15631
+ JOIN chat_membership cm
15632
+ ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
15633
+ LEFT JOIN chat_user_state cus
15634
+ ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
15559
15635
  WHERE c.parent_chat_id IS NULL
15560
- /* Scope to the caller's org. Without this, cross-org dirty chats
15561
- whose chat_participants still reference the caller's human agent
15562
- (historical pollution — see fix/cross-org-direct-chat-pollution)
15563
- would leak into the list and 404 on click via requireChatAccess. */
15636
+ /* Scope to the caller's org. Without this, cross-org dirty
15637
+ chats whose chat_membership still references the caller's
15638
+ human agent (historical pollution — see
15639
+ fix/cross-org-direct-chat-pollution) would leak into the
15640
+ list and 404 on click via requireChatAccess. */
15564
15641
  AND c.organization_id = ${organizationId}
15565
- /* Filter: unread / watching */
15566
- AND (${!filterUnreadOnly}::bool OR d.unread_mention_count > 0)
15567
- AND (${!filterWatchingOnly}::bool OR d.membership_kind = 'watching')
15642
+ AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
15643
+ AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
15644
+ AND ${engagementPredicate}
15568
15645
  AND ${cursorPredicate}
15569
15646
  ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
15570
15647
  LIMIT ${limit + 1}
@@ -15583,11 +15660,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15583
15660
  };
15584
15661
  const chatIds = pageRaw.map((r) => r.chat_id);
15585
15662
  const participantRows = await db.select({
15586
- chatId: chatParticipants.chatId,
15587
- agentId: chatParticipants.agentId,
15663
+ chatId: chatMembership.chatId,
15664
+ agentId: chatMembership.agentId,
15588
15665
  displayName: agents.displayName,
15589
15666
  type: agents.type
15590
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(inArray(chatParticipants.chatId, chatIds));
15667
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
15591
15668
  const participantsByChat = /* @__PURE__ */ new Map();
15592
15669
  for (const p of participantRows) {
15593
15670
  const list = participantsByChat.get(p.chatId) ?? [];
@@ -15611,10 +15688,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15611
15688
  rows: pageRaw.map((r) => {
15612
15689
  const participants = participantsByChat.get(r.chat_id) ?? [];
15613
15690
  const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
15691
+ const isSpeaker = r.access_mode === "speaker";
15614
15692
  return {
15615
15693
  chatId: r.chat_id,
15616
15694
  type: r.type,
15617
- membershipKind: r.membership_kind,
15695
+ membershipKind: isSpeaker ? "participant" : "watching",
15618
15696
  title,
15619
15697
  topic: r.topic,
15620
15698
  participants,
@@ -15622,7 +15700,8 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15622
15700
  lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
15623
15701
  lastMessagePreview: r.last_message_preview,
15624
15702
  unreadMentionCount: r.unread_mention_count,
15625
- canReply: r.membership_kind === "participant"
15703
+ canReply: isSpeaker,
15704
+ engagementStatus: r.engagement_status
15626
15705
  };
15627
15706
  }),
15628
15707
  nextCursor
@@ -15634,11 +15713,6 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15634
15713
  * 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
15635
15714
  * 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
15636
15715
  * 3. Participant join (fallback when chat has no messages yet)
15637
- *
15638
- * The first-message fallback is the chat-first equivalent of how
15639
- * ChatGPT / Claude.ai name conversations from the user's opening
15640
- * prompt — gives same-agent multi-chats distinct identities and
15641
- * removes the "title duplicates participants chip row" anti-pattern.
15642
15716
  */
15643
15717
  function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
15644
15718
  if (topic && topic.length > 0) return topic;
@@ -15692,7 +15766,7 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
15692
15766
  }).from(chats).where(eq(chats.id, chatId)).limit(1);
15693
15767
  if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
15694
15768
  if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
15695
- const [callerRow] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, callerHumanAgentId))).limit(1);
15769
+ const [callerRow] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, callerHumanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
15696
15770
  if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
15697
15771
  const found = await db.select({
15698
15772
  uuid: agents.uuid,
@@ -15706,45 +15780,36 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
15706
15780
  const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
15707
15781
  if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
15708
15782
  await db.transaction(async (tx) => {
15709
- const existing = await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
15710
- const existingSet = new Set(existing.map((e) => e.agentId));
15711
- const toInsert = distinct.filter((id) => !existingSet.has(id));
15712
- if (toInsert.length === 0) {
15783
+ const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
15784
+ const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
15785
+ const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
15786
+ if (toUpsert.length === 0) {
15713
15787
  await recomputeChatWatchers(tx, chatId);
15714
15788
  return;
15715
15789
  }
15716
- if (existing.length + toInsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
15717
- const carriedRows = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, toInsert))).returning({
15718
- agentId: chatSubscriptions.agentId,
15719
- lastReadAt: chatSubscriptions.lastReadAt,
15720
- unreadMentionCount: chatSubscriptions.unreadMentionCount
15721
- });
15722
- const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
15723
- await addChatParticipants(tx, chatId, toInsert.map((agentId) => {
15724
- const carried = carriedByAgent.get(agentId);
15725
- return {
15726
- agentId,
15727
- role: "member",
15728
- carriedReadState: carried ? {
15729
- lastReadAt: carried.lastReadAt,
15730
- unreadMentionCount: carried.unreadMentionCount
15731
- } : void 0
15732
- };
15733
- }), { onConflictDoNothing: true });
15790
+ if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
15791
+ await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
15792
+ agentId,
15793
+ role: "member"
15794
+ })), { upgradeWatcherToSpeaker: true });
15734
15795
  await recomputeChatWatchers(tx, chatId);
15735
15796
  });
15736
15797
  invalidateChatAudience(chatId);
15737
15798
  }
15738
15799
  async function markMeChatRead(db, chatId, humanAgentId) {
15739
15800
  const now = /* @__PURE__ */ new Date();
15740
- await db.update(chatParticipants).set({
15741
- lastReadAt: now,
15742
- unreadMentionCount: 0
15743
- }).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId)));
15744
- await db.update(chatSubscriptions).set({
15801
+ await db.insert(chatUserState).values({
15802
+ chatId,
15803
+ agentId: humanAgentId,
15745
15804
  lastReadAt: now,
15746
15805
  unreadMentionCount: 0
15747
- }).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId)));
15806
+ }).onConflictDoUpdate({
15807
+ target: [chatUserState.chatId, chatUserState.agentId],
15808
+ set: {
15809
+ lastReadAt: now,
15810
+ unreadMentionCount: 0
15811
+ }
15812
+ });
15748
15813
  return {
15749
15814
  chatId,
15750
15815
  lastReadAt: now.toISOString(),
@@ -15770,7 +15835,7 @@ async function leaveMeChat(db, chatId, humanAgentId) {
15770
15835
  async function chatRoutes(app) {
15771
15836
  app.get("/:chatId", async (request) => {
15772
15837
  const { chat, scope } = await requireChatAccess(request, app.db);
15773
- const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chat.id));
15838
+ const participants = await app.db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chat.id), eq(chatMembership.accessMode, "speaker")));
15774
15839
  const firstMsgRows = await app.db.execute(sql`
15775
15840
  SELECT content FROM messages
15776
15841
  WHERE chat_id = ${chat.id}
@@ -15794,10 +15859,12 @@ async function chatRoutes(app) {
15794
15859
  };
15795
15860
  });
15796
15861
  const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
15862
+ const engagementStatus = await getCallerEngagement(app.db, chat.id, scope.humanAgentId);
15797
15863
  return {
15798
15864
  ...chat,
15799
15865
  title,
15800
15866
  firstMessagePreview,
15867
+ engagementStatus,
15801
15868
  createdAt: chat.createdAt.toISOString(),
15802
15869
  updatedAt: chat.updatedAt.toISOString(),
15803
15870
  participants: participants.map((p) => ({
@@ -15808,6 +15875,15 @@ async function chatRoutes(app) {
15808
15875
  }))
15809
15876
  };
15810
15877
  });
15878
+ app.post("/:chatId/engagement", { config: { otelRecordBody: true } }, async (request, reply) => {
15879
+ const { scope } = await requireChatAccess(request, app.db);
15880
+ const body = patchChatEngagementSchema.parse(request.body);
15881
+ await setChatEngagement(app.db, request.params.chatId, scope.humanAgentId, body.status);
15882
+ return reply.status(200).send({
15883
+ chatId: request.params.chatId,
15884
+ engagementStatus: body.status
15885
+ });
15886
+ });
15811
15887
  app.patch("/:chatId", { config: { otelRecordBody: true } }, async (request) => {
15812
15888
  await requireChatAccess(request, app.db);
15813
15889
  const body = updateChatSchema.parse(request.body);
@@ -15900,7 +15976,7 @@ async function chatRoutes(app) {
15900
15976
  app.post("/:chatId/messages", async (request, reply) => {
15901
15977
  const { scope } = await requireChatAccess(request, app.db);
15902
15978
  const body = sendMessageSchema.parse(request.body);
15903
- await ensureParticipant$1(app.db, request.params.chatId, scope.humanAgentId);
15979
+ await ensureParticipant(app.db, request.params.chatId, scope.humanAgentId);
15904
15980
  const prepared = await prepareImageOutbound(app.db, app.notifier, request.params.chatId, {
15905
15981
  ...body,
15906
15982
  source: "hub_ui"
@@ -15927,7 +16003,7 @@ async function chatRoutes(app) {
15927
16003
  app.post("/:chatId/questions/:correlationId/answer", { config: { otelRecordBody: false } }, async (request, reply) => {
15928
16004
  const { scope } = await requireChatAccess(request, app.db);
15929
16005
  const body = submitQuestionAnswerSchema.parse(request.body);
15930
- await ensureParticipant$1(app.db, request.params.chatId, scope.humanAgentId);
16006
+ await ensureParticipant(app.db, request.params.chatId, scope.humanAgentId);
15931
16007
  const result = await submitAnswer(app.db, app.notifier, {
15932
16008
  correlationId: request.params.correlationId,
15933
16009
  chatId: request.params.chatId,
@@ -17324,7 +17400,7 @@ async function healthzRoutes(app) {
17324
17400
  * `api/orgs/invitations.ts` (Class B, admin-gated).
17325
17401
  */
17326
17402
  async function publicInvitationRoutes(app) {
17327
- const { previewInvitation } = await import("./invitation-C299fxkP-jQiGR5fl.mjs");
17403
+ const { previewInvitation } = await import("./invitation-C299fxkP-KKslbta2.mjs");
17328
17404
  app.get("/:token/preview", async (request, reply) => {
17329
17405
  if (!request.params.token) throw new UnauthorizedError("Token required");
17330
17406
  const preview = await previewInvitation(app.db, request.params.token);
@@ -17590,7 +17666,7 @@ async function meRoutes(app) {
17590
17666
  */
17591
17667
  app.get("/me/pinned-agents", async (request) => {
17592
17668
  const { userId } = requireUser(request);
17593
- const { listMyPinnedAgents } = await import("./client-93HZWg84-MIPzQD9A.mjs");
17669
+ const { listMyPinnedAgents } = await import("./client-DNEtPEBu-BtHkUya2.mjs");
17594
17670
  return listMyPinnedAgents(app.db, { userId });
17595
17671
  });
17596
17672
  /**
@@ -18000,7 +18076,7 @@ async function orgChatRoutes(app) {
18000
18076
  createdAt: chats.createdAt,
18001
18077
  updatedAt: chats.updatedAt,
18002
18078
  participantCount: sql`(
18003
- SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id}
18079
+ SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker'
18004
18080
  )`
18005
18081
  }).from(chats).where(and(...conditions)).orderBy(desc(chats.createdAt)).limit(query.limit + 1);
18006
18082
  const hasMore = rows.length > query.limit;
@@ -19526,8 +19602,8 @@ var schema_exports = /* @__PURE__ */ __exportAll({
19526
19602
  agentPresence: () => agentPresence,
19527
19603
  agents: () => agents,
19528
19604
  authIdentities: () => authIdentities,
19529
- chatParticipants: () => chatParticipants,
19530
- chatSubscriptions: () => chatSubscriptions,
19605
+ chatMembership: () => chatMembership,
19606
+ chatUserState: () => chatUserState,
19531
19607
  chats: () => chats,
19532
19608
  clients: () => clients,
19533
19609
  githubAppInstallations: () => githubAppInstallations,
@@ -1,4 +1,4 @@
1
- import{c as r,j as e,U as o,L as s,A as i,F as a,r as l}from"./index-BNM-YSSu.js";/**
1
+ import{c as r,j as e,U as o,L as s,A as i,F as a,r as l}from"./index-ntmzuk5X.js";/**
2
2
  * @license lucide-react v0.577.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.