@ours.network/cowork 1.1.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -11625,6 +11625,9 @@ var init_ours_runtime = __esm({
11625
11625
  });
11626
11626
 
11627
11627
  // src/command-names.ts
11628
+ function commandGrantMatches(pattern, command) {
11629
+ return pattern === command || pattern === "*" || pattern.endsWith(".*") && command.startsWith(pattern.slice(0, -1)) && command.length > pattern.length - 1;
11630
+ }
11628
11631
  var SHARED_ROOM_COMMANDS, RUNTIME_COMMAND_NAMES;
11629
11632
  var init_command_names = __esm({
11630
11633
  "src/command-names.ts"() {
@@ -11656,6 +11659,7 @@ var init_command_names = __esm({
11656
11659
  "room.role.rest.remove"
11657
11660
  ];
11658
11661
  RUNTIME_COMMAND_NAMES = [
11662
+ "start_thread",
11659
11663
  "list-members",
11660
11664
  "remove-member",
11661
11665
  ...SHARED_ROOM_COMMANDS
@@ -11663,6 +11667,93 @@ var init_command_names = __esm({
11663
11667
  }
11664
11668
  });
11665
11669
 
11670
+ // src/thread-contracts.ts
11671
+ var ParticipantIdSchema, ContainerIdSchema, TopicTextSchema, InputTopicSchema, StoredTopicSchema, IdempotencyKeySchema, ThreadMemberSchema, ThreadRootSchema, ThreadScopeSchema, StartThreadInputSchema, ThreadFailure;
11672
+ var init_thread_contracts = __esm({
11673
+ "src/thread-contracts.ts"() {
11674
+ "use strict";
11675
+ init_zod();
11676
+ ParticipantIdSchema = external_exports.string().regex(
11677
+ /^[0-7][0-9a-hjkmnp-tv-z]{25}$/,
11678
+ "must be a 26-character lowercase Crockford ULID"
11679
+ );
11680
+ ContainerIdSchema = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
11681
+ TopicTextSchema = external_exports.string().refine(
11682
+ (value) => Array.from(value).length >= 1 && Array.from(value).length <= 120 && value.trim().length > 0 && !/[\p{Cc}\p{Cf}]/u.test(value),
11683
+ "topic must contain 1-120 Unicode characters without control or format characters"
11684
+ );
11685
+ InputTopicSchema = TopicTextSchema.transform((value) => value.trim());
11686
+ StoredTopicSchema = TopicTextSchema.refine(
11687
+ (value) => value === value.trim(),
11688
+ "stored topic must already be trimmed"
11689
+ );
11690
+ IdempotencyKeySchema = external_exports.string().regex(
11691
+ /^[A-Za-z0-9._:-]{1,128}$/,
11692
+ "must contain 1-128 portable idempotency-key characters"
11693
+ );
11694
+ ThreadMemberSchema = external_exports.object({
11695
+ participant_id: ParticipantIdSchema,
11696
+ identity: ContainerIdSchema
11697
+ }).strict();
11698
+ ThreadRootSchema = external_exports.object({
11699
+ schema_version: external_exports.literal(1),
11700
+ thread_id: ParticipantIdSchema,
11701
+ topic: StoredTopicSchema,
11702
+ creator_participant_id: ParticipantIdSchema,
11703
+ members: external_exports.array(ThreadMemberSchema).min(1),
11704
+ idempotency_key: IdempotencyKeySchema,
11705
+ fingerprint: external_exports.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase SHA-256 digest")
11706
+ }).strict().superRefine((root, context) => {
11707
+ const participantIds = /* @__PURE__ */ new Set();
11708
+ const identities = /* @__PURE__ */ new Set();
11709
+ for (const [index, member] of root.members.entries()) {
11710
+ if (participantIds.has(member.participant_id)) {
11711
+ context.addIssue({
11712
+ code: external_exports.ZodIssueCode.custom,
11713
+ path: ["members", index, "participant_id"],
11714
+ message: "thread member participant IDs must be unique"
11715
+ });
11716
+ }
11717
+ participantIds.add(member.participant_id);
11718
+ if (identities.has(member.identity)) {
11719
+ context.addIssue({
11720
+ code: external_exports.ZodIssueCode.custom,
11721
+ path: ["members", index, "identity"],
11722
+ message: "thread member identities must be unique"
11723
+ });
11724
+ }
11725
+ identities.add(member.identity);
11726
+ }
11727
+ if (!participantIds.has(root.creator_participant_id)) {
11728
+ context.addIssue({
11729
+ code: external_exports.ZodIssueCode.custom,
11730
+ path: ["creator_participant_id"],
11731
+ message: "thread creator must be a member"
11732
+ });
11733
+ }
11734
+ });
11735
+ ThreadScopeSchema = external_exports.object({
11736
+ thread_id: ParticipantIdSchema,
11737
+ parent_key: external_exports.string().regex(
11738
+ /^(?:message|file):[0-7][0-9a-hjkmnp-tv-z]{25}$/,
11739
+ "must identify an immediate message or file parent"
11740
+ ).optional()
11741
+ }).strict();
11742
+ StartThreadInputSchema = external_exports.object({
11743
+ topic: InputTopicSchema,
11744
+ participant_ids: external_exports.array(ParticipantIdSchema).min(1),
11745
+ idempotency_key: IdempotencyKeySchema
11746
+ }).strict();
11747
+ ThreadFailure = class extends Error {
11748
+ constructor(code) {
11749
+ super(code);
11750
+ this.code = code;
11751
+ this.name = "ThreadFailure";
11752
+ }
11753
+ };
11754
+ }
11755
+ });
11756
+
11666
11757
  // src/contracts.ts
11667
11758
  import { createHash } from "node:crypto";
11668
11759
  function utf8Bounded(label, maximumBytes) {
@@ -11804,6 +11895,18 @@ function refineRelaySubject(record, context) {
11804
11895
  });
11805
11896
  }
11806
11897
  }
11898
+ function refineIntakeRejection(record, context) {
11899
+ if (record.kind !== "intake_rejection") return;
11900
+ const hasMessage = record.source_msg_id !== void 0;
11901
+ const hasFile = record.source_file_id !== void 0;
11902
+ if (hasMessage === hasFile || (record.source_kind === "message" ? !hasMessage : !hasFile)) {
11903
+ context.addIssue({
11904
+ code: external_exports.ZodIssueCode.custom,
11905
+ path: ["source_kind"],
11906
+ message: "intake rejections require exactly one numeric source field matching source_kind"
11907
+ });
11908
+ }
11909
+ }
11807
11910
  function refineFileRecord(record, context) {
11808
11911
  if (record.kind !== "file" || record.data_base64 === void 0) return;
11809
11912
  const bytes = Buffer.from(record.data_base64, "base64");
@@ -11854,13 +11957,110 @@ function refineMessageCategory(message, context) {
11854
11957
  }
11855
11958
  }
11856
11959
  }
11857
- var MAX_TEXT_BYTES, MAX_FILE_BYTES, MAX_HISTORY_PAGE_BYTES, MAX_MANAGEMENT_RESPONSE_BYTES, MAX_EXTERNAL_INVITE_BYTES, MAX_FILE_NAME_BYTES, MAX_MIME_BYTES, MAX_ROLE_BYTES, MAX_ROOM_NAME_CHARACTERS, MAX_ROOM_IDENTITY_NAME_CHARACTERS, NonEmptyStringSchema, PositiveSafeIntegerSchema, LowerCrockfordUlidSchema, ContainerIdSchema, RuntimeCommandNameSchema, RuntimeCommandGrantSchema, Rfc3339Schema, RoomNameSchema, ROOM_IDENTITY_PREFIX, MAX_ROOM_IDENTITY_TITLE_CHARACTERS, SDK_IDENTITY_NAME_FORBIDDEN, SDK_IDENTITY_NAME_RESERVED, CoworkIdentityNameError, RoleSchema, ROOM_ROLE, RuntimeRoleCommandGrantSchema, MissionTextSchema, MessageTextSchema, FileNameSchema, FileMimeSchema, RoomStateSchema, SeatStateSchema, InviteModeSchema, DEFAULT_ROLE, InviteStateSchema, RelayStatusSchema, SeatV1Schema, SeatSchema, RoomInviteSchema, MissionV1Schema, MissionSchema, RoleBriefingSchema, RoomCommonShape, RoomV1Schema, CurrentRoomSchema, RoomSchema, CreateRoomInputSchema, UpdateRoomInputSchema, RoleBriefingSetInputSchema, RoleBriefingDeleteInputSchema, PostMessageInputSchema, PostAsRoleInputSchema, RestRoleInputSchema, AcceptExternalInviteInputSchema, ListMembersCommandInputSchema, CommandIdempotencyKeySchema, RemoveMemberCommandInputSchema, RuntimeCommandGrantInputSchema, RuntimeRoleCommandGrantInputSchema, RuntimeCommandAuditSchema, AuthorSnapshotSchema, RecordCommonShape, AppendCommonShape, MembershipNoticeSchema, AuthorAliasSchema, ReplyReferenceSchema, MessageShape, RelayIntentShape, RelayResultStatusSchema, RelayResultShape, FileShape, MembershipIntentShape, MembershipResultShape, CloseNoticeIntentShape, CloseNoticeResultShape, MessageRecordSchema, FileRecordSchema, RelayIntentRecordSchema, RelayResultRecordSchema, MembershipIntentRecordSchema, MembershipResultRecordSchema, CloseNoticeIntentRecordSchema, CloseNoticeResultRecordSchema, RawCommunicationRecordSchema, CommunicationRecordSchema, AppendRecordSchema;
11960
+ function refineMessageThread(message, context) {
11961
+ if (message.thread_root !== void 0) {
11962
+ if (message.scope === void 0) {
11963
+ context.addIssue({
11964
+ code: external_exports.ZodIssueCode.custom,
11965
+ path: ["scope"],
11966
+ message: "thread root messages require scope"
11967
+ });
11968
+ return;
11969
+ }
11970
+ if (message.scope.thread_id !== message.message_id || message.thread_root.thread_id !== message.message_id) {
11971
+ context.addIssue({
11972
+ code: external_exports.ZodIssueCode.custom,
11973
+ path: ["thread_root", "thread_id"],
11974
+ message: "thread root thread_id and scope thread_id must equal message_id"
11975
+ });
11976
+ }
11977
+ if (message.scope.parent_key !== void 0) {
11978
+ context.addIssue({
11979
+ code: external_exports.ZodIssueCode.custom,
11980
+ path: ["scope", "parent_key"],
11981
+ message: "parent_key is forbidden on thread root messages"
11982
+ });
11983
+ }
11984
+ if (message.category !== "chat") {
11985
+ context.addIssue({
11986
+ code: external_exports.ZodIssueCode.custom,
11987
+ path: ["category"],
11988
+ message: "thread root messages must be chat messages"
11989
+ });
11990
+ }
11991
+ if (message.text !== `Thread: ${message.thread_root.topic}`) {
11992
+ context.addIssue({
11993
+ code: external_exports.ZodIssueCode.custom,
11994
+ path: ["text"],
11995
+ message: "thread root message text must identify its topic"
11996
+ });
11997
+ }
11998
+ const creator = message.thread_root.members.find(
11999
+ (member) => member.participant_id === message.thread_root?.creator_participant_id
12000
+ );
12001
+ if (creator?.identity !== message.author.identity) {
12002
+ context.addIssue({
12003
+ code: external_exports.ZodIssueCode.custom,
12004
+ path: ["thread_root", "creator_participant_id"],
12005
+ message: "thread root creator must match the message author"
12006
+ });
12007
+ }
12008
+ if (message.author_alias !== void 0 && message.author_alias.participant_id !== message.thread_root.creator_participant_id) {
12009
+ context.addIssue({
12010
+ code: external_exports.ZodIssueCode.custom,
12011
+ path: ["author_alias", "participant_id"],
12012
+ message: "thread root author alias must identify the creator"
12013
+ });
12014
+ }
12015
+ for (const field of ["source_msg_id", "source_wire_id", "source_reply_to"]) {
12016
+ if (message[field] !== void 0) {
12017
+ context.addIssue({
12018
+ code: external_exports.ZodIssueCode.custom,
12019
+ path: [field],
12020
+ message: `${field} is forbidden on thread root messages`
12021
+ });
12022
+ }
12023
+ }
12024
+ return;
12025
+ }
12026
+ if (message.scope === void 0) return;
12027
+ if (message.scope.parent_key === void 0) {
12028
+ context.addIssue({
12029
+ code: external_exports.ZodIssueCode.custom,
12030
+ path: ["scope", "parent_key"],
12031
+ message: "thread descendants require an immediate parent_key"
12032
+ });
12033
+ }
12034
+ if (message.scope.thread_id === message.message_id) {
12035
+ context.addIssue({
12036
+ code: external_exports.ZodIssueCode.custom,
12037
+ path: ["scope", "thread_id"],
12038
+ message: "thread descendant thread_id must identify a distinct root message"
12039
+ });
12040
+ }
12041
+ if (message.source_reply_to === void 0) {
12042
+ context.addIssue({
12043
+ code: external_exports.ZodIssueCode.custom,
12044
+ path: ["source_reply_to"],
12045
+ message: "thread descendants require source_reply_to"
12046
+ });
12047
+ }
12048
+ if (message.category !== "chat") {
12049
+ context.addIssue({
12050
+ code: external_exports.ZodIssueCode.custom,
12051
+ path: ["category"],
12052
+ message: "thread descendants must be chat messages"
12053
+ });
12054
+ }
12055
+ }
12056
+ var MAX_TEXT_BYTES, MAX_FILE_BYTES, MAX_HISTORY_PAGE_BYTES, MAX_MANAGEMENT_RESPONSE_BYTES, MAX_EXTERNAL_INVITE_BYTES, MAX_FILE_NAME_BYTES, MAX_MIME_BYTES, MAX_ROLE_BYTES, MAX_ROOM_NAME_CHARACTERS, MAX_ROOM_IDENTITY_NAME_CHARACTERS, NonEmptyStringSchema, PositiveSafeIntegerSchema, LowerCrockfordUlidSchema, ContainerIdSchema2, RuntimeCommandNameSchema, RuntimeCommandNamespacePatternSchema, RuntimeCommandGrantPatternSchema, RuntimeCommandGrantSchema, Rfc3339Schema, RoomNameSchema, ROOM_IDENTITY_PREFIX, MAX_ROOM_IDENTITY_TITLE_CHARACTERS, SDK_IDENTITY_NAME_FORBIDDEN, SDK_IDENTITY_NAME_RESERVED, CoworkIdentityNameError, RoleSchema, ROOM_ROLE, RuntimeRoleCommandGrantSchema, MissionTextSchema, MessageTextSchema, FileNameSchema, FileMimeSchema, RoomStateSchema, SeatStateSchema, InviteModeSchema, DEFAULT_ROLE, InviteStateSchema, RelayStatusSchema, SeatV1Schema, SeatSchema, RoomInviteSchema, MissionV1Schema, MissionSchema, RoleBriefingSchema, RoomCommonShape, RoomV1Schema, CurrentRoomSchema, RoomSchema, CreateRoomInputSchema, UpdateRoomInputSchema, RoleBriefingSetInputSchema, RoleBriefingDeleteInputSchema, PostMessageInputSchema, PostAsRoleInputSchema, RestRoleInputSchema, AcceptExternalInviteInputSchema, ListMembersCommandInputSchema, CommandIdempotencyKeySchema, RemoveMemberCommandInputSchema, RuntimeCommandGrantInputSchema, RuntimeRoleCommandGrantInputSchema, RuntimeCommandAuditSchema, AuthorSnapshotSchema, RecordCommonShape, AppendCommonShape, MembershipNoticeSchema, AuthorAliasSchema, ReplyReferenceSchema, MessageShape, RelayIntentShape, RelayResultStatusSchema, RelayResultShape, FileShape, IntakeRejectionShape, MembershipIntentShape, MembershipResultShape, CloseNoticeIntentShape, CloseNoticeResultShape, MessageRecordSchema, FileRecordSchema, RelayIntentRecordSchema, RelayResultRecordSchema, IntakeRejectionRecordSchema, MembershipIntentRecordSchema, MembershipResultRecordSchema, CloseNoticeIntentRecordSchema, CloseNoticeResultRecordSchema, RawCommunicationRecordSchema, CommunicationRecordSchema, AppendRecordSchema;
11858
12057
  var init_contracts = __esm({
11859
12058
  "src/contracts.ts"() {
11860
12059
  "use strict";
11861
12060
  init_zod();
11862
12061
  init_consumer_commands();
11863
12062
  init_command_names();
12063
+ init_thread_contracts();
11864
12064
  MAX_TEXT_BYTES = 262144;
11865
12065
  MAX_FILE_BYTES = 2 * 1024 * 1024;
11866
12066
  MAX_HISTORY_PAGE_BYTES = 3 * 1024 * 1024;
@@ -11877,11 +12077,17 @@ var init_contracts = __esm({
11877
12077
  /^[0-7][0-9a-hjkmnp-tv-z]{25}$/,
11878
12078
  "must be a 26-character lowercase Crockford ULID"
11879
12079
  );
11880
- ContainerIdSchema = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
12080
+ ContainerIdSchema2 = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
11881
12081
  RuntimeCommandNameSchema = external_exports.union([external_exports.enum(RUNTIME_COMMAND_NAMES), ConsumerCommandNameSchema]);
12082
+ RuntimeCommandNamespacePatternSchema = external_exports.string().max(128).regex(/^(?:[a-z0-9][a-z0-9-]*\.)+\*$/);
12083
+ RuntimeCommandGrantPatternSchema = external_exports.union([
12084
+ RuntimeCommandNameSchema,
12085
+ external_exports.literal("*"),
12086
+ RuntimeCommandNamespacePatternSchema
12087
+ ]);
11882
12088
  RuntimeCommandGrantSchema = external_exports.object({
11883
- caller_cid: ContainerIdSchema,
11884
- command: RuntimeCommandNameSchema
12089
+ caller_cid: ContainerIdSchema2,
12090
+ command: RuntimeCommandGrantPatternSchema
11885
12091
  }).strict();
11886
12092
  Rfc3339Schema = external_exports.string().refine(isStrictRfc3339, "must be a valid RFC3339 timestamp");
11887
12093
  RoomNameSchema = external_exports.string().refine(
@@ -11911,7 +12117,7 @@ var init_contracts = __esm({
11911
12117
  ROOM_ROLE = "room";
11912
12118
  RuntimeRoleCommandGrantSchema = external_exports.object({
11913
12119
  role: RoleSchema,
11914
- commands: external_exports.array(RuntimeCommandNameSchema).superRefine((commands, context) => {
12120
+ commands: external_exports.array(RuntimeCommandGrantPatternSchema).superRefine((commands, context) => {
11915
12121
  const seen = /* @__PURE__ */ new Set();
11916
12122
  for (const [index, command] of commands.entries()) {
11917
12123
  if (seen.has(command)) {
@@ -12159,7 +12365,7 @@ var init_contracts = __esm({
12159
12365
  lifecycle_request: external_exports.object({
12160
12366
  request_id: external_exports.string().min(1).max(256),
12161
12367
  command: external_exports.enum(["room.close", "room.delete"]),
12162
- caller_cid: ContainerIdSchema,
12368
+ caller_cid: ContainerIdSchema2,
12163
12369
  accepted_at: Rfc3339Schema,
12164
12370
  state: external_exports.enum(["pending", "failed", "completed"]),
12165
12371
  error: external_exports.literal("lifecycle_failed").optional()
@@ -12310,7 +12516,7 @@ var init_contracts = __esm({
12310
12516
  (value) => Buffer.byteLength(value, "utf8") <= MAX_EXTERNAL_INVITE_BYTES,
12311
12517
  `invite input must be at most ${MAX_EXTERNAL_INVITE_BYTES} UTF-8 bytes`
12312
12518
  ),
12313
- expected_cid: ContainerIdSchema.optional()
12519
+ expected_cid: ContainerIdSchema2.optional()
12314
12520
  }).strict();
12315
12521
  ListMembersCommandInputSchema = external_exports.object({}).strict();
12316
12522
  CommandIdempotencyKeySchema = external_exports.string().regex(
@@ -12387,7 +12593,9 @@ var init_contracts = __esm({
12387
12593
  }),
12388
12594
  source_msg_id: external_exports.number().int().nonnegative().safe().optional(),
12389
12595
  source_wire_id: NonEmptyStringSchema.optional(),
12390
- source_reply_to: ReplyReferenceSchema.optional()
12596
+ source_reply_to: ReplyReferenceSchema.optional(),
12597
+ scope: ThreadScopeSchema.optional(),
12598
+ thread_root: ThreadRootSchema.optional()
12391
12599
  };
12392
12600
  RelayIntentShape = {
12393
12601
  kind: external_exports.literal("relay_intent"),
@@ -12395,7 +12603,12 @@ var init_contracts = __esm({
12395
12603
  file_id: LowerCrockfordUlidSchema.optional(),
12396
12604
  recipient_identity: NonEmptyStringSchema
12397
12605
  };
12398
- RelayResultStatusSchema = external_exports.enum(["queued", "send_failed", "skipped_removed"]);
12606
+ RelayResultStatusSchema = external_exports.enum([
12607
+ "queued",
12608
+ "send_failed",
12609
+ "skipped_removed",
12610
+ "skipped_reply_unavailable"
12611
+ ]);
12399
12612
  RelayResultShape = {
12400
12613
  kind: external_exports.literal("relay_result"),
12401
12614
  intent_record_id: NonEmptyStringSchema,
@@ -12433,6 +12646,18 @@ var init_contracts = __esm({
12433
12646
  source_wire_id: NonEmptyStringSchema.optional(),
12434
12647
  source_reply_to: ReplyReferenceSchema.optional()
12435
12648
  };
12649
+ IntakeRejectionShape = {
12650
+ kind: external_exports.literal("intake_rejection"),
12651
+ source_kind: external_exports.enum(["message", "file"]),
12652
+ source_msg_id: external_exports.number().int().nonnegative().safe().optional(),
12653
+ source_file_id: external_exports.number().int().nonnegative().safe().optional(),
12654
+ source_wire_id: NonEmptyStringSchema,
12655
+ sender_identity: NonEmptyStringSchema,
12656
+ sender_participant_id: LowerCrockfordUlidSchema,
12657
+ fingerprint: external_exports.string().regex(/^[0-9a-f]{64}$/),
12658
+ error: external_exports.enum(["reply_target_unavailable", "thread_files_unsupported"]),
12659
+ notification_attempt_claimed: external_exports.literal(true)
12660
+ };
12436
12661
  MembershipIntentShape = {
12437
12662
  kind: external_exports.literal("membership_intent"),
12438
12663
  action: external_exports.enum(["remove"]),
@@ -12470,6 +12695,7 @@ var init_contracts = __esm({
12470
12695
  FileRecordSchema = external_exports.object({ ...RecordCommonShape, ...FileShape }).strict();
12471
12696
  RelayIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...RelayIntentShape }).strict();
12472
12697
  RelayResultRecordSchema = external_exports.object({ ...RecordCommonShape, ...RelayResultShape }).strict();
12698
+ IntakeRejectionRecordSchema = external_exports.object({ ...RecordCommonShape, ...IntakeRejectionShape }).strict();
12473
12699
  MembershipIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...MembershipIntentShape }).strict();
12474
12700
  MembershipResultRecordSchema = external_exports.object({ ...RecordCommonShape, ...MembershipResultShape }).strict();
12475
12701
  CloseNoticeIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...CloseNoticeIntentShape }).strict();
@@ -12479,6 +12705,7 @@ var init_contracts = __esm({
12479
12705
  FileRecordSchema,
12480
12706
  RelayIntentRecordSchema,
12481
12707
  RelayResultRecordSchema,
12708
+ IntakeRejectionRecordSchema,
12482
12709
  MembershipIntentRecordSchema,
12483
12710
  MembershipResultRecordSchema,
12484
12711
  CloseNoticeIntentRecordSchema,
@@ -12492,8 +12719,12 @@ var init_contracts = __esm({
12492
12719
  message: 'record_id must equal room_id + ":" + seq'
12493
12720
  });
12494
12721
  }
12495
- if (record.kind === "message") refineMessageCategory(record, context);
12722
+ if (record.kind === "message") {
12723
+ refineMessageCategory(record, context);
12724
+ refineMessageThread(record, context);
12725
+ }
12496
12726
  refineRelaySubject(record, context);
12727
+ refineIntakeRejection(record, context);
12497
12728
  refineFileRecord(record, context);
12498
12729
  });
12499
12730
  AppendRecordSchema = external_exports.discriminatedUnion("kind", [
@@ -12501,11 +12732,16 @@ var init_contracts = __esm({
12501
12732
  external_exports.object({ ...AppendCommonShape, ...FileShape }).strict(),
12502
12733
  external_exports.object({ ...AppendCommonShape, ...RelayIntentShape }).strict(),
12503
12734
  external_exports.object({ ...AppendCommonShape, ...RelayResultShape }).strict(),
12735
+ external_exports.object({ ...AppendCommonShape, ...IntakeRejectionShape }).strict(),
12504
12736
  external_exports.object({ ...AppendCommonShape, ...CloseNoticeIntentShape }).strict(),
12505
12737
  external_exports.object({ ...AppendCommonShape, ...CloseNoticeResultShape }).strict()
12506
12738
  ]).superRefine((record, context) => {
12507
- if (record.kind === "message") refineMessageCategory(record, context);
12739
+ if (record.kind === "message") {
12740
+ refineMessageCategory(record, context);
12741
+ refineMessageThread(record, context);
12742
+ }
12508
12743
  refineRelaySubject(record, context);
12744
+ refineIntakeRejection(record, context);
12509
12745
  refineFileRecord(record, context);
12510
12746
  });
12511
12747
  }
@@ -12746,7 +12982,7 @@ function apiDocsAsset(pathname) {
12746
12982
  return void 0;
12747
12983
  }
12748
12984
  }
12749
- var RPC_PATH, OPENAPI_DOCUMENT_PATH, API_DOCS_PATH, API_DOCS_SCRIPT_PATH, API_DOCS_STYLESHEET_PATH, API_VERSION, EXAMPLE_ROOM_ID, roomIdProperty, roleProperty, restRoleProperty, missionTextProperty, roomNameProperty, inviteModeProperty, notifyProperty, PRIVATE_ROOM_RPC_METHODS, ROOM_RPC_METHODS, RPC_ERROR_CODES, DOCS_PAGE, DOCS_STYLESHEET, DOCS_SCRIPT;
12985
+ var RPC_PATH, OPENAPI_DOCUMENT_PATH, API_DOCS_PATH, API_DOCS_SCRIPT_PATH, API_DOCS_STYLESHEET_PATH, API_VERSION, commandGrantPatternProperty, EXAMPLE_ROOM_ID, roomIdProperty, roleProperty, restRoleProperty, missionTextProperty, roomNameProperty, inviteModeProperty, notifyProperty, PRIVATE_ROOM_RPC_METHODS, ROOM_RPC_METHODS, RPC_ERROR_CODES, DOCS_PAGE, DOCS_STYLESHEET, DOCS_SCRIPT;
12750
12986
  var init_openapi = __esm({
12751
12987
  "src/openapi.ts"() {
12752
12988
  "use strict";
@@ -12757,6 +12993,15 @@ var init_openapi = __esm({
12757
12993
  API_DOCS_SCRIPT_PATH = "/docs/ui.js";
12758
12994
  API_DOCS_STYLESHEET_PATH = "/docs/ui.css";
12759
12995
  API_VERSION = "1";
12996
+ commandGrantPatternProperty = {
12997
+ anyOf: [
12998
+ { type: "string", enum: [...RUNTIME_COMMAND_NAMES] },
12999
+ { type: "string", maxLength: 128, pattern: "^consumer\\.[a-z0-9][a-z0-9.-]*[a-z0-9]$" },
13000
+ { type: "string", enum: ["*"] },
13001
+ { type: "string", maxLength: 128, pattern: "^(?:[a-z0-9][a-z0-9-]*\\.)+\\*$" }
13002
+ ],
13003
+ description: "Exact command, * for all runtime commands, or terminal namespace.*. Patterns also cover future matching commands."
13004
+ };
12760
13005
  EXAMPLE_ROOM_ID = "01jd7q4h9m2v8xk3znbc5regty";
12761
13006
  roomIdProperty = {
12762
13007
  type: "string",
@@ -12821,7 +13066,7 @@ var init_openapi = __esm({
12821
13066
  {
12822
13067
  method: "room.command.definition.put",
12823
13068
  summary: "Register or replace a consumer command",
12824
- description: "Registers a consumer.* command for the ours catalog using an exact host-configured handler reference. Replacement clears grants for that name. Local definitions cannot be replaced here. A published:false result means desired state is committed; use reload to retry publication.",
13069
+ description: "Registers a consumer.* command for the ours catalog using an exact host-configured handler reference. Replacement clears exact grants for that name; wildcard grants remain effective. Local definitions cannot be replaced here. A published:false result means desired state is committed; use reload to retry publication.",
12825
13070
  params: params({ room_id: roomIdProperty, expected_revision: { type: "integer", minimum: 0 }, definition: {
12826
13071
  type: "object",
12827
13072
  additionalProperties: false,
@@ -12834,7 +13079,7 @@ var init_openapi = __esm({
12834
13079
  {
12835
13080
  method: "room.command.definition.delete",
12836
13081
  summary: "Delete a REST consumer command",
12837
- description: "Deletes the desired definition and its CID/role grants. Requires the current registry revision. Local commands must be removed from their file and reloaded.",
13082
+ description: "Deletes the desired definition and its exact CID/role grants; wildcard grants remain stored. Requires the current registry revision. Local commands must be removed from their file and reloaded.",
12838
13083
  params: params({ room_id: roomIdProperty, expected_revision: { type: "integer", minimum: 0 }, name: { type: "string" } }, ["room_id", "expected_revision", "name"]),
12839
13084
  result: "Updated revision, published and definitions.",
12840
13085
  example: { room_id: EXAMPLE_ROOM_ID, expected_revision: 1, name: "consumer.orders" }
@@ -13004,7 +13249,7 @@ var init_openapi = __esm({
13004
13249
  {
13005
13250
  method: "room.command.grants",
13006
13251
  summary: "List runtime-command grants",
13007
- description: "Lists the default-deny grants that bind one active participant CID to one room runtime command.",
13252
+ description: "Lists the default-deny grants that bind one active participant CID to one room runtime command name or stored wildcard pattern.",
13008
13253
  params: params({ room_id: roomIdProperty }, ["room_id"]),
13009
13254
  result: "An array of `{caller_cid, command}` grants.",
13010
13255
  example: { room_id: EXAMPLE_ROOM_ID }
@@ -13020,13 +13265,11 @@ var init_openapi = __esm({
13020
13265
  {
13021
13266
  method: "room.command.role.set",
13022
13267
  summary: "Set a role runtime-command policy",
13023
- description: "Atomically replaces the command policy for one exact role. An empty command list removes the policy without removing independent per-CID grants.",
13268
+ description: "Atomically replaces the command policy for one exact role. An empty command list removes the policy without removing independent per-CID grants. Accepts exact names, * and namespace.* patterns.",
13024
13269
  params: params({
13025
13270
  room_id: roomIdProperty,
13026
13271
  role: { type: "string", minLength: 1, description: "Exact admitted seat role." },
13027
- commands: { type: "array", uniqueItems: true, items: {
13028
- anyOf: [{ type: "string", enum: [...RUNTIME_COMMAND_NAMES] }, { type: "string", maxLength: 128, pattern: "^consumer\\.[a-z0-9][a-z0-9.-]*[a-z0-9]$" }]
13029
- } }
13272
+ commands: { type: "array", uniqueItems: true, items: commandGrantPatternProperty }
13030
13273
  }, ["room_id", "role", "commands"]),
13031
13274
  result: "The complete sorted role-policy list after the idempotent update.",
13032
13275
  example: { room_id: EXAMPLE_ROOM_ID, role: "Owner", commands: ["list-members", "remove-member"] }
@@ -13034,11 +13277,11 @@ var init_openapi = __esm({
13034
13277
  {
13035
13278
  method: "room.command.grant",
13036
13279
  summary: "Authorize a runtime command caller",
13037
- description: "Idempotently grants one command to one exact active participant CID. Display names and roles are never authority.",
13280
+ description: "Idempotently grants one command name or dynamic pattern to one exact active participant CID. Display names and caller-supplied roles are never authority. * and room.* include permission administration and room lifecycle commands; * also covers future or replaced consumer commands.",
13038
13281
  params: params({
13039
13282
  room_id: roomIdProperty,
13040
13283
  caller_cid: { type: "string", pattern: "^[0-9A-Fa-f]{64}$", description: "Authenticated caller CID." },
13041
- command: { anyOf: [{ type: "string", enum: [...RUNTIME_COMMAND_NAMES] }, { type: "string", maxLength: 128, pattern: "^consumer\\.[a-z0-9][a-z0-9.-]*[a-z0-9]$" }] }
13284
+ command: commandGrantPatternProperty
13042
13285
  }, ["room_id", "caller_cid", "command"]),
13043
13286
  result: "The complete sorted grant list after the idempotent update.",
13044
13287
  example: { room_id: EXAMPLE_ROOM_ID, caller_cid: "A".repeat(64), command: "list-members" }
@@ -13046,11 +13289,11 @@ var init_openapi = __esm({
13046
13289
  {
13047
13290
  method: "room.command.revoke",
13048
13291
  summary: "Revoke a runtime command caller",
13049
- description: "Idempotently removes one exact CID/command grant. Revocation is persisted before subsequent command dispatch can acquire the room mutex.",
13292
+ description: "Idempotently removes only the exact stored CID/name or CID/pattern entry; overlapping grants remain effective. Revocation is persisted before subsequent command dispatch can acquire the room mutex.",
13050
13293
  params: params({
13051
13294
  room_id: roomIdProperty,
13052
13295
  caller_cid: { type: "string", pattern: "^[0-9A-Fa-f]{64}$", description: "Authenticated caller CID." },
13053
- command: { anyOf: [{ type: "string", enum: [...RUNTIME_COMMAND_NAMES] }, { type: "string", maxLength: 128, pattern: "^consumer\\.[a-z0-9][a-z0-9.-]*[a-z0-9]$" }] }
13296
+ command: commandGrantPatternProperty
13054
13297
  }, ["room_id", "caller_cid", "command"]),
13055
13298
  result: "The complete sorted grant list after the idempotent update.",
13056
13299
  example: { room_id: EXAMPLE_ROOM_ID, caller_cid: "A".repeat(64), command: "remove-member" }
@@ -13842,6 +14085,34 @@ var init_packets = __esm({
13842
14085
  this.runtimeHandlers = handlers;
13843
14086
  await this.runBound(() => this.client.registerCommands([
13844
14087
  ...handlers.consumerCommands ?? [],
14088
+ ...handlers.startThread === void 0 ? [] : [{
14089
+ name: "start_thread",
14090
+ description: "Create a scoped reply thread for an explicit set of room participant IDs.",
14091
+ input_schema: {
14092
+ type: "object",
14093
+ additionalProperties: false,
14094
+ required: ["topic", "participant_ids", "idempotency_key"],
14095
+ properties: {
14096
+ topic: {
14097
+ type: "string",
14098
+ minLength: 1,
14099
+ maxLength: 120,
14100
+ pattern: "^(?![\\s\\S]*[\\p{Cc}\\p{Cf}])[\\s\\S]*\\S[\\s\\S]*$"
14101
+ },
14102
+ participant_ids: {
14103
+ type: "array",
14104
+ minItems: 1,
14105
+ uniqueItems: true,
14106
+ items: { type: "string", pattern: "^[0-7][0-9a-hjkmnp-tv-z]{25}$" }
14107
+ },
14108
+ idempotency_key: {
14109
+ type: "string",
14110
+ pattern: "^[A-Za-z0-9._:-]{1,128}$"
14111
+ }
14112
+ }
14113
+ },
14114
+ handler: handlers.startThread
14115
+ }],
13845
14116
  {
13846
14117
  name: "list-members",
13847
14118
  description: "List the room roster using contact-safe member fields.",
@@ -13866,9 +14137,10 @@ var init_packets = __esm({
13866
14137
  ...handlers.sharedCommand === void 0 ? [] : SHARED_ROOM_COMMANDS.map((name) => {
13867
14138
  const doc = [...ROOM_RPC_METHODS, ...PRIVATE_ROOM_RPC_METHODS].find((method) => method.method === name);
13868
14139
  const { room_id: _roomId, ...properties } = doc.params.properties;
14140
+ if (name === "room.history") properties.view = { const: "participant" };
13869
14141
  return {
13870
14142
  name,
13871
- description: `${doc.description} Requires an explicit grant; applies only to this room.`,
14143
+ description: `${name === "room.history" ? "Read your visible messages using viewer-local after cursors." : doc.description} Requires an explicit grant; applies only to this room.`,
13872
14144
  input_schema: {
13873
14145
  ...doc.params,
13874
14146
  properties,
@@ -14011,6 +14283,22 @@ var init_ulid = __esm({
14011
14283
  });
14012
14284
 
14013
14285
  // src/reply-threading.ts
14286
+ function buildAliasIndex(rows, roomId) {
14287
+ const local = rows.filter((r) => r.room_id === roomId);
14288
+ const items = local.filter((r) => r.kind === "message" || r.kind === "file");
14289
+ const intents = local.filter((r) => r.kind === "relay_intent");
14290
+ const results = local.filter((r) => r.kind === "relay_result");
14291
+ const copies = (parent, cid) => results.filter((result) => {
14292
+ if (result.status !== "queued" || result.recipient_identity !== cid || key(result) !== key(parent) || !parent.recipient_identities.includes(cid)) return false;
14293
+ const matches = intents.filter((intent2) => intent2.record_id === result.intent_record_id);
14294
+ if (matches.length !== 1) return false;
14295
+ const intent = matches[0];
14296
+ return key(intent) === key(parent) && intent.recipient_identity === cid && parent.seq < intent.seq && intent.seq < result.seq;
14297
+ }).sort((a, b) => a.seq - b.seq);
14298
+ const wires = (result, parent) => [result.wire_id, ...parent.kind === "file" ? [result.metadata_wire_id] : []].filter(nonempty);
14299
+ const ownersFor = (wireId, cid) => items.filter((item) => item.author.identity === cid && item.source_wire_id === wireId || copies(item, cid).some((result) => wires(result, item).includes(wireId)));
14300
+ return { items, copies, wires, ownersFor };
14301
+ }
14014
14302
  async function readReplyRows(store, roomId) {
14015
14303
  const rows = [];
14016
14304
  let after = 0;
@@ -14028,42 +14316,53 @@ async function readReplyRows(store, roomId) {
14028
14316
  after = last.seq;
14029
14317
  }
14030
14318
  }
14031
- function selectReply(rows, roomId, child, recipientCid) {
14032
- if (child.room_id !== roomId) throw new Error("reply child room mismatch");
14033
- const incoming = child.source_reply_to?.wire_id;
14034
- if (!nonempty(incoming)) return { state: "none" };
14035
- const local = rows.filter((r) => r.room_id === roomId);
14036
- const items = local.filter((r) => r.kind === "message" || r.kind === "file");
14037
- const intents = local.filter((r) => r.kind === "relay_intent");
14038
- const results = local.filter((r) => r.kind === "relay_result");
14039
- const copies = (parent2, cid) => results.filter((result) => {
14040
- if (result.status !== "queued" || result.recipient_identity !== cid || key(result) !== key(parent2) || !parent2.recipient_identities.includes(cid)) return false;
14041
- const matches = intents.filter((intent2) => intent2.record_id === result.intent_record_id);
14042
- if (matches.length !== 1) return false;
14043
- const intent = matches[0];
14044
- return key(intent) === key(parent2) && intent.recipient_identity === cid && parent2.seq < intent.seq && intent.seq < result.seq;
14045
- }).sort((a, b) => a.seq - b.seq);
14046
- const wires = (r, parent2) => [r.wire_id, ...parent2.kind === "file" ? [r.metadata_wire_id] : []].filter(nonempty);
14047
- const ownersFor = (wireId2, cid) => items.filter((item) => item.author.identity === cid && item.source_wire_id === wireId2 || copies(item, cid).some((result) => wires(result, item).includes(wireId2)));
14048
- const candidates = items.filter((parent2) => parent2.author.identity === child.author.identity && parent2.source_wire_id === incoming || copies(parent2, child.author.identity).some((result) => wires(result, parent2).includes(incoming)));
14319
+ function resolveReplyParent(rows, roomId, senderCid, wireId, beforeSeq) {
14320
+ if (wireId === void 0) return { state: "none" };
14321
+ if (!nonempty(wireId)) return { state: "unknown_parent" };
14322
+ const index = buildAliasIndex(rows, roomId);
14323
+ const candidates = index.ownersFor(wireId, senderCid);
14049
14324
  if (candidates.length === 0) return { state: "unknown_parent" };
14050
14325
  if (candidates.length !== 1) return { state: "ambiguous_parent" };
14051
14326
  const parent = candidates[0];
14052
- if (parent.seq >= child.seq) return { state: "unknown_parent" };
14327
+ if (parent.seq >= beforeSeq) return { state: "unknown_parent" };
14053
14328
  const parentKey = key(parent);
14054
- if (items.filter((item) => key(item) === parentKey).length !== 1) {
14329
+ if (index.items.filter((item) => key(item) === parentKey).length !== 1) {
14055
14330
  return { state: "ambiguous_parent" };
14056
14331
  }
14057
- const recipientCopies = copies(parent, recipientCid);
14332
+ return { state: "resolved", parent: { key: parentKey, item: parent } };
14333
+ }
14334
+ function mapReplyParent(rows, roomId, logicalParent, recipientCid) {
14335
+ const index = buildAliasIndex(rows, roomId);
14336
+ if (logicalParent.item.room_id !== roomId || key(logicalParent.item) !== logicalParent.key) {
14337
+ return { state: "ambiguous_parent", parentKey: logicalParent.key };
14338
+ }
14339
+ const matches = index.items.filter((item) => key(item) === logicalParent.key);
14340
+ if (matches.length !== 1) {
14341
+ return { state: "ambiguous_parent", parentKey: logicalParent.key };
14342
+ }
14343
+ const parent = matches[0];
14344
+ const recipientCopies = index.copies(parent, recipientCid);
14058
14345
  const sourceWire = parent.author.identity === recipientCid && nonempty(parent.source_wire_id) ? parent.source_wire_id : void 0;
14059
- const sourceOwners = sourceWire === void 0 ? [] : ownersFor(sourceWire, recipientCid);
14060
- const wireId = sourceOwners.length === 1 && key(sourceOwners[0]) === parentKey ? sourceWire : recipientCopies.map((copy) => wires(copy, parent)[0]).find(nonempty);
14061
- if (!nonempty(wireId)) return { state: "missing_copy", parentKey };
14062
- const owners = ownersFor(wireId, recipientCid);
14063
- if (owners.length !== 1 || key(owners[0]) !== parentKey) {
14064
- return { state: "ambiguous_parent", parentKey };
14346
+ const sourceOwners = sourceWire === void 0 ? [] : index.ownersFor(sourceWire, recipientCid);
14347
+ const wireId = sourceOwners.length === 1 && key(sourceOwners[0]) === logicalParent.key ? sourceWire : recipientCopies.map((copy) => index.wires(copy, parent)[0]).find(nonempty);
14348
+ if (!nonempty(wireId)) return { state: "missing_copy", parentKey: logicalParent.key };
14349
+ const owners = index.ownersFor(wireId, recipientCid);
14350
+ if (owners.length !== 1 || key(owners[0]) !== logicalParent.key) {
14351
+ return { state: "ambiguous_parent", parentKey: logicalParent.key };
14065
14352
  }
14066
- return { state: "linked", parentKey, replyTo: { wire_id: wireId } };
14353
+ return { state: "linked", parentKey: logicalParent.key, replyTo: { wire_id: wireId } };
14354
+ }
14355
+ function selectReply(rows, roomId, child, recipientCid) {
14356
+ if (child.room_id !== roomId) throw new Error("reply child room mismatch");
14357
+ const resolution = resolveReplyParent(
14358
+ rows,
14359
+ roomId,
14360
+ child.author.identity,
14361
+ child.source_reply_to?.wire_id,
14362
+ child.seq
14363
+ );
14364
+ if (resolution.state !== "resolved") return resolution;
14365
+ return mapReplyParent(rows, roomId, resolution.parent, recipientCid);
14067
14366
  }
14068
14367
  var key, nonempty;
14069
14368
  var init_reply_threading = __esm({
@@ -14074,9 +14373,159 @@ var init_reply_threading = __esm({
14074
14373
  }
14075
14374
  });
14076
14375
 
14376
+ // src/threads.ts
14377
+ import { createHash as createHash3 } from "node:crypto";
14378
+ function publicThreadMetadata(root, room) {
14379
+ let creator;
14380
+ if (room.anonymous) {
14381
+ const alias = AuthorAliasSchema.safeParse(root.author_alias);
14382
+ if (!alias.success || alias.data.participant_id !== root.thread_root.creator_participant_id) {
14383
+ throw new ThreadFailure("reply_target_unavailable");
14384
+ }
14385
+ creator = {
14386
+ identity: alias.data.participant_id,
14387
+ display_name: alias.data.alias,
14388
+ role: root.author.role
14389
+ };
14390
+ } else {
14391
+ creator = {
14392
+ identity: root.author.identity,
14393
+ display_name: root.author.display_name,
14394
+ role: root.author.role
14395
+ };
14396
+ }
14397
+ return {
14398
+ schema_version: 1,
14399
+ thread_id: root.thread_root.thread_id,
14400
+ topic: root.thread_root.topic,
14401
+ creator,
14402
+ participant_ids: root.thread_root.members.map((member) => member.participant_id),
14403
+ created_at: root.at
14404
+ };
14405
+ }
14406
+ function selectThreadMembers(room, cid, input) {
14407
+ const selected = new Set(input.participant_ids);
14408
+ const active = room.seats.filter((seat) => seat.state === "active");
14409
+ const creator = active.find((seat) => seat.identity === cid);
14410
+ if (creator === void 0 || selected.size === 0 || selected.size !== input.participant_ids.length || selected.size > active.length || !selected.has(creator.participant_id)) {
14411
+ throw new ThreadFailure("invalid_members");
14412
+ }
14413
+ const members = active.filter((seat) => selected.has(seat.participant_id));
14414
+ if (members.length !== selected.size) throw new ThreadFailure("invalid_members");
14415
+ return members.map(({ participant_id, identity }) => ({ participant_id, identity })).sort((left, right) => left.participant_id.localeCompare(right.participant_id));
14416
+ }
14417
+ function activeThreadSeat(room, root, cid) {
14418
+ return room.seats.find((seat) => seat.state === "active" && seat.identity === cid && root.members.some((member) => member.identity === cid && member.participant_id === seat.participant_id));
14419
+ }
14420
+ function threadRelayEligible(room, root, cid) {
14421
+ return activeThreadSeat(room, root, cid) !== void 0;
14422
+ }
14423
+ function publicThreadAuthor(message, root, room) {
14424
+ const member = root.members.find((member2) => member2.identity === message.author.identity);
14425
+ if (!member) throw new ThreadFailure("reply_target_unavailable");
14426
+ if (!room.anonymous) return message.author;
14427
+ const alias = AuthorAliasSchema.safeParse(message.author_alias);
14428
+ if (!alias.success || alias.data.participant_id !== member.participant_id) {
14429
+ throw new ThreadFailure("reply_target_unavailable");
14430
+ }
14431
+ return { identity: alias.data.participant_id, display_name: alias.data.alias, role: message.author.role };
14432
+ }
14433
+ function threadFingerprint(input) {
14434
+ return createHash3("sha256").update(JSON.stringify({
14435
+ topic: input.topic,
14436
+ participant_ids: [...input.participant_ids].sort()
14437
+ })).digest("hex");
14438
+ }
14439
+ function findThreadRoot(rows, id) {
14440
+ const candidates = rows.filter((row) => row.kind === "message" && (row.message_id === id || row.thread_root?.thread_id === id || row.scope?.thread_id === id && row.scope.parent_key === void 0));
14441
+ if (candidates.length === 0) return void 0;
14442
+ if (candidates.length !== 1) throw new ThreadFailure("reply_target_unavailable");
14443
+ const root = candidates[0];
14444
+ const parsedRoot = ThreadRootSchema.safeParse(root.thread_root);
14445
+ const parsedScope = ThreadScopeSchema.safeParse(root.scope);
14446
+ const parsedAlias = AuthorAliasSchema.safeParse(root.author_alias);
14447
+ const creator = parsedRoot.success ? parsedRoot.data.members.find((member) => member.participant_id === parsedRoot.data.creator_participant_id) : void 0;
14448
+ if (!parsedRoot.success || !parsedScope.success || root.message_id !== id || parsedRoot.data.thread_id !== id || parsedScope.data.thread_id !== id || parsedScope.data.parent_key !== void 0 || root.category !== "chat" || creator?.identity !== root.author.identity || root.author_alias !== void 0 && (!parsedAlias.success || parsedAlias.data.participant_id !== parsedRoot.data.creator_participant_id) || root.source_msg_id !== void 0 || root.source_wire_id !== void 0 || root.source_reply_to !== void 0) {
14449
+ throw new ThreadFailure("reply_target_unavailable");
14450
+ }
14451
+ return root;
14452
+ }
14453
+ function classifyThreadAssociation(room, rows, source) {
14454
+ const chain = [];
14455
+ const seen = /* @__PURE__ */ new Set();
14456
+ let item = source;
14457
+ for (; ; ) {
14458
+ const key2 = item.kind === "message" ? `message:${item.message_id}` : `file:${item.file_id}`;
14459
+ if (item.room_id !== room.room_id || seen.has(key2) || chain.length > rows.length) {
14460
+ throw new ThreadFailure("reply_target_unavailable");
14461
+ }
14462
+ seen.add(key2);
14463
+ const resolved = resolveReplyParent(
14464
+ rows,
14465
+ room.room_id,
14466
+ item.author.identity,
14467
+ item.source_reply_to?.wire_id,
14468
+ item.seq
14469
+ );
14470
+ chain.push({ item, resolved });
14471
+ if (resolved.state !== "resolved") break;
14472
+ if (resolved.parent.item.seq >= item.seq) throw new ThreadFailure("reply_target_unavailable");
14473
+ item = resolved.parent.item;
14474
+ }
14475
+ let association = { state: "ordinary" };
14476
+ for (const { item: item2, resolved } of chain.reverse()) {
14477
+ const declared = item2.kind === "message" && (item2.scope !== void 0 || item2.thread_root !== void 0);
14478
+ if (!declared && association.state === "ordinary") continue;
14479
+ const scope = ThreadScopeSchema.safeParse(item2.kind === "message" ? item2.scope : void 0);
14480
+ if (item2.kind !== "message" || !scope.success || item2.category !== "chat" || rows.filter((row) => row.room_id === room.room_id && row.kind === "message" && row.message_id === item2.message_id).length !== 1) {
14481
+ throw new ThreadFailure("reply_target_unavailable");
14482
+ }
14483
+ const root = findThreadRoot(rows.filter((row) => row.room_id === room.room_id), scope.data.thread_id);
14484
+ if (!root?.thread_root) throw new ThreadFailure("reply_target_unavailable");
14485
+ publicThreadMetadata({ ...root, thread_root: root.thread_root }, room);
14486
+ publicThreadAuthor(item2, root.thread_root, room);
14487
+ if (item2.message_id !== root.message_id) {
14488
+ if (item2.thread_root !== void 0 || root.seq >= item2.seq || resolved.state !== "resolved" || scope.data.parent_key !== resolved.parent.key || association.state !== "scoped" || association.root.message_id !== root.message_id) {
14489
+ throw new ThreadFailure("reply_target_unavailable");
14490
+ }
14491
+ }
14492
+ association = { state: "scoped", root: { ...root, thread_root: root.thread_root }, scope: scope.data };
14493
+ }
14494
+ return association;
14495
+ }
14496
+ function resolveIntakeScope(room, rows, item, beforeSeq) {
14497
+ const ordinary = () => ({ recipients: [...new Set(room.seats.filter((seat) => seat.state === "active" && seat.identity !== item.sender_id).map((seat) => seat.identity))] });
14498
+ if (item.reply_to == null) return ordinary();
14499
+ const reply = item.reply_to;
14500
+ if (typeof reply.wire_id !== "string" || reply.wire_id.length === 0 || reply.wire_id.length > 256 || reply.sentence !== void 0 && (!Number.isSafeInteger(reply.sentence) || reply.sentence < 1) || room.state !== "active" || room.lifecycle_request?.state === "pending" || !room.seats.some((seat) => seat.identity === item.sender_id && seat.state === "active")) {
14501
+ throw new ThreadFailure("reply_target_unavailable");
14502
+ }
14503
+ const resolved = resolveReplyParent(rows, room.room_id, item.sender_id, reply.wire_id, beforeSeq);
14504
+ if (resolved.state !== "resolved") throw new ThreadFailure("reply_target_unavailable");
14505
+ const association = classifyThreadAssociation(room, rows, resolved.parent.item);
14506
+ if (association.state === "ordinary") return ordinary();
14507
+ const { root } = association;
14508
+ if (!activeThreadSeat(room, root.thread_root, item.sender_id)) {
14509
+ throw new ThreadFailure("reply_target_unavailable");
14510
+ }
14511
+ if ("file_id" in item) throw new ThreadFailure("thread_files_unsupported");
14512
+ return {
14513
+ recipients: root.thread_root.members.filter((member) => member.identity !== item.sender_id && activeThreadSeat(room, root.thread_root, member.identity) !== void 0).map((member) => member.identity),
14514
+ scope: { thread_id: root.message_id, parent_key: resolved.parent.key }
14515
+ };
14516
+ }
14517
+ var init_threads = __esm({
14518
+ "src/threads.ts"() {
14519
+ "use strict";
14520
+ init_reply_threading();
14521
+ init_contracts();
14522
+ init_thread_contracts();
14523
+ }
14524
+ });
14525
+
14077
14526
  // src/intake.ts
14078
14527
  import { AsyncLocalStorage } from "node:async_hooks";
14079
- import { createHash as createHash3 } from "node:crypto";
14528
+ import { createHash as createHash4 } from "node:crypto";
14080
14529
  function canonicalJson(value) {
14081
14530
  const encoded = JSON.stringify(canonicalValue(value));
14082
14531
  if (encoded === void 0) throw new TypeError("canonical JSON value is not serializable");
@@ -14091,13 +14540,25 @@ function sameReply2(stored, observed) {
14091
14540
  }
14092
14541
  async function queryStore(store, roomId, options) {
14093
14542
  if (store.query) return store.query(roomId, options);
14094
- let records = await store.read(roomId);
14095
- records = records.filter((record) => {
14543
+ const archive = [];
14544
+ let after = 0;
14545
+ for (; ; ) {
14546
+ const page = await store.read(roomId, { after, limit: JOURNAL_WORK_BATCH_SIZE });
14547
+ if (page.length === 0) break;
14548
+ for (const row of page) {
14549
+ if (row.room_id !== roomId || !Number.isSafeInteger(row.seq) || row.seq <= after) {
14550
+ throw new Error("intake archive cursor did not advance");
14551
+ }
14552
+ archive.push(row);
14553
+ after = row.seq;
14554
+ }
14555
+ }
14556
+ let records = archive.filter((record) => {
14096
14557
  const value = record;
14097
- return (options.kind === void 0 || record.kind === options.kind) && (options.messageId === void 0 || value.message_id === options.messageId) && (options.fileId === void 0 || value.file_id === options.fileId) && (options.sourceMsgId === void 0 || value.source_msg_id === options.sourceMsgId) && (options.sourceFileId === void 0 || value.source_file_id === options.sourceFileId) && (options.recipientIdentity === void 0 || value.recipient_identity === options.recipientIdentity);
14558
+ return (options.after === void 0 || record.seq > options.after) && (options.kind === void 0 || record.kind === options.kind) && (options.messageId === void 0 || value.message_id === options.messageId) && (options.fileId === void 0 || value.file_id === options.fileId) && (options.sourceMsgId === void 0 || value.source_msg_id === options.sourceMsgId) && (options.sourceFileId === void 0 || value.source_file_id === options.sourceFileId) && (options.recipientIdentity === void 0 || value.recipient_identity === options.recipientIdentity);
14098
14559
  });
14099
14560
  if (options.unresolvedResultKind) {
14100
- const completed = new Set((await store.read(roomId)).filter((record) => record.kind === options.unresolvedResultKind).map((record) => record.intent_record_id));
14561
+ const completed = new Set(archive.filter((record) => record.kind === options.unresolvedResultKind).map((record) => record.intent_record_id));
14101
14562
  records = records.filter((record) => !completed.has(record.record_id));
14102
14563
  }
14103
14564
  if (options.descending) records.reverse();
@@ -14115,8 +14576,20 @@ function canonicalValue(value) {
14115
14576
  }
14116
14577
  return value;
14117
14578
  }
14118
- function unique(values) {
14119
- return [...new Set(values)];
14579
+ function inputFingerprint(item) {
14580
+ return createHash4("sha256").update(canonicalJson({
14581
+ sender: item.sender_id,
14582
+ wire: item.wire_id,
14583
+ date: item.date,
14584
+ reply: item.reply_to ?? null,
14585
+ ..."file_id" in item ? {
14586
+ kind: "file",
14587
+ id: item.file_id,
14588
+ filename: item.filename,
14589
+ mime: item.mime,
14590
+ sha256: createHash4("sha256").update(item.data).digest("hex")
14591
+ } : { kind: "message", id: item.msg_id, text: item.text }
14592
+ })).digest("hex");
14120
14593
  }
14121
14594
  function wireKind(category) {
14122
14595
  switch (category) {
@@ -14138,6 +14611,8 @@ var init_intake = __esm({
14138
14611
  init_contracts();
14139
14612
  init_ulid();
14140
14613
  init_reply_threading();
14614
+ init_threads();
14615
+ init_thread_contracts();
14141
14616
  JOURNAL_WORK_BATCH_SIZE = 64;
14142
14617
  INTAKE_BATCH_SIZE = 32;
14143
14618
  IntakePump = class {
@@ -14282,28 +14757,36 @@ var init_intake = __esm({
14282
14757
  });
14283
14758
  }
14284
14759
  async processFileInboxItem(roomId, packet, item) {
14285
- const parsedName = FileNameSchema.safeParse(item.filename);
14286
- const parsedMime = FileMimeSchema.safeParse(item.mime);
14287
- if (!parsedName.success || !parsedMime.success) {
14288
- await packet.acknowledgeFile(item);
14289
- return;
14290
- }
14291
- if (item.data.length > MAX_FILE_BYTES) {
14292
- throw new RangeError(`room files must be at most ${MAX_FILE_BYTES} bytes (2 MiB)`);
14293
- }
14294
14760
  const room = await this.store.load(roomId);
14295
- const seat = room.seats.find(
14296
- (candidate) => candidate.identity === item.sender_id && candidate.state === "active"
14297
- );
14298
- if (room.state !== "active" || !seat) {
14299
- if (room.state === "active") await this.bounceRemovedSender(roomId, room, packet, item);
14761
+ const [stored] = await queryStore(this.store, roomId, { sourceFileId: item.file_id, limit: 1 });
14762
+ if (this.isRejectedReplay(stored, item)) {
14300
14763
  await packet.acknowledgeFile(item);
14301
14764
  return;
14302
14765
  }
14303
- const [storedFile] = await queryStore(this.store, roomId, { kind: "file", sourceFileId: item.file_id, limit: 1 });
14304
- let file = this.findSourceFile(storedFile === void 0 ? [] : [storedFile], item);
14766
+ let file = this.findSourceFile(stored === void 0 ? [] : [stored], item);
14305
14767
  if (!file) {
14306
- const recipientIdentities = unique(room.seats.filter((recipient) => recipient.state === "active").map((recipient) => recipient.identity).filter((identity) => identity !== seat.identity));
14768
+ const seat = room.seats.find((candidate) => candidate.identity === item.sender_id && candidate.state === "active");
14769
+ const known = room.seats.some((candidate) => candidate.identity === item.sender_id);
14770
+ if (!known || item.reply_to == null && (room.state !== "active" || !seat)) {
14771
+ if (room.state === "active") await this.bounceRemovedSender(roomId, room, packet, item);
14772
+ await packet.acknowledgeFile(item);
14773
+ return;
14774
+ }
14775
+ const disposition = await this.freshScopeUnlocked(room, item);
14776
+ if (!disposition) {
14777
+ await packet.acknowledgeFile(item);
14778
+ return;
14779
+ }
14780
+ if (!seat) throw new Error("authorized file sender has no active seat");
14781
+ const parsedName = FileNameSchema.safeParse(item.filename);
14782
+ const parsedMime = FileMimeSchema.safeParse(item.mime);
14783
+ if (!parsedName.success || !parsedMime.success) {
14784
+ await packet.acknowledgeFile(item);
14785
+ return;
14786
+ }
14787
+ if (item.data.length > MAX_FILE_BYTES) {
14788
+ throw new RangeError(`room files must be at most ${MAX_FILE_BYTES} bytes (2 MiB)`);
14789
+ }
14307
14790
  const bytes = Buffer.from(item.data);
14308
14791
  const appended = await this.store.append(roomId, {
14309
14792
  version: 1,
@@ -14316,9 +14799,9 @@ var init_intake = __esm({
14316
14799
  filename: parsedName.data,
14317
14800
  mime: parsedMime.data,
14318
14801
  size: bytes.length,
14319
- sha256: createHash3("sha256").update(bytes).digest("hex"),
14802
+ sha256: createHash4("sha256").update(bytes).digest("hex"),
14320
14803
  data_base64: bytes.toString("base64"),
14321
- recipient_identities: recipientIdentities,
14804
+ recipient_identities: disposition.recipients,
14322
14805
  source_file_id: item.file_id,
14323
14806
  ...item.wire_id === "" ? {} : { source_wire_id: item.wire_id },
14324
14807
  ...item.reply_to == null ? {} : { source_reply_to: item.reply_to }
@@ -14331,18 +14814,26 @@ var init_intake = __esm({
14331
14814
  }
14332
14815
  async processInboxItem(roomId, packet, item, acknowledge = true) {
14333
14816
  const room = await this.store.load(roomId);
14334
- const seat = room.seats.find(
14335
- (candidate) => candidate.identity === item.sender_id && candidate.state === "active"
14336
- );
14337
- if (room.state !== "active" || !seat) {
14338
- if (room.state === "active") await this.bounceRemovedSender(roomId, room, packet, item);
14817
+ const [stored] = await queryStore(this.store, roomId, { sourceMsgId: item.msg_id, limit: 1 });
14818
+ if (this.isRejectedReplay(stored, item)) {
14339
14819
  if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14340
14820
  return;
14341
14821
  }
14342
- const [storedMessage] = await queryStore(this.store, roomId, { kind: "message", sourceMsgId: item.msg_id, limit: 1 });
14343
- let message = this.findSourceMessage(storedMessage === void 0 ? [] : [storedMessage], item);
14822
+ let message = this.findSourceMessage(stored === void 0 ? [] : [stored], item);
14344
14823
  if (!message) {
14345
- const recipientIdentities = unique(room.seats.filter((recipient) => recipient.state === "active").map((recipient) => recipient.identity).filter((identity) => identity !== seat.identity));
14824
+ const seat = room.seats.find((candidate) => candidate.identity === item.sender_id && candidate.state === "active");
14825
+ const known = room.seats.some((candidate) => candidate.identity === item.sender_id);
14826
+ if (!known || item.reply_to == null && (room.state !== "active" || !seat)) {
14827
+ if (room.state === "active") await this.bounceRemovedSender(roomId, room, packet, item);
14828
+ if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14829
+ return;
14830
+ }
14831
+ const disposition = await this.freshScopeUnlocked(room, item);
14832
+ if (!disposition) {
14833
+ if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14834
+ return;
14835
+ }
14836
+ if (!seat) throw new Error("authorized message sender has no active seat");
14346
14837
  const appended = await this.store.append(roomId, {
14347
14838
  version: 1,
14348
14839
  kind: "message",
@@ -14359,7 +14850,8 @@ var init_intake = __esm({
14359
14850
  ...room.anonymous && seat.alias !== void 0 ? { author_alias: { participant_id: seat.participant_id, alias: seat.alias } } : {},
14360
14851
  category: "chat",
14361
14852
  text: item.text,
14362
- recipient_identities: recipientIdentities,
14853
+ recipient_identities: disposition.recipients,
14854
+ ...disposition.scope === void 0 ? {} : { scope: disposition.scope },
14363
14855
  source_msg_id: item.msg_id,
14364
14856
  ...item.wire_id === "" ? {} : { source_wire_id: item.wire_id },
14365
14857
  ...item.reply_to == null ? {} : { source_reply_to: item.reply_to }
@@ -14370,6 +14862,78 @@ var init_intake = __esm({
14370
14862
  await this.completeMessageIntents(roomId, message);
14371
14863
  if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14372
14864
  }
14865
+ async freshScopeUnlocked(room, item) {
14866
+ try {
14867
+ const rows = item.reply_to == null ? [] : await readReplyRows(this.store, room.room_id);
14868
+ const beforeSeq = item.reply_to == null ? 1 : await this.nextRecordSeq(room.room_id);
14869
+ return resolveIntakeScope(room, rows, item, beforeSeq);
14870
+ } catch (error) {
14871
+ if (!(error instanceof ThreadFailure)) throw error;
14872
+ await this.recordRejectionUnlocked(room, item, error);
14873
+ return void 0;
14874
+ }
14875
+ }
14876
+ async nextRecordSeq(roomId) {
14877
+ if (this.store.query) {
14878
+ const [last] = await this.store.query(roomId, { descending: true, limit: 1 });
14879
+ if (last && (last.room_id !== roomId || !Number.isSafeInteger(last.seq) || last.seq < 1)) {
14880
+ throw new Error("intake archive tail is invalid");
14881
+ }
14882
+ return (last?.seq ?? 0) + 1;
14883
+ }
14884
+ let after = 0;
14885
+ for (; ; ) {
14886
+ const page = await this.store.read(roomId, { after, limit: JOURNAL_WORK_BATCH_SIZE });
14887
+ if (page.length === 0) return after + 1;
14888
+ for (const row of page) {
14889
+ if (row.room_id !== roomId || !Number.isSafeInteger(row.seq) || row.seq <= after) {
14890
+ throw new Error("intake archive cursor did not advance");
14891
+ }
14892
+ after = row.seq;
14893
+ }
14894
+ }
14895
+ }
14896
+ isRejectedReplay(record, item) {
14897
+ if (record?.kind !== "intake_rejection") return false;
14898
+ const file = "file_id" in item;
14899
+ if (record.source_kind !== (file ? "file" : "message") || (file ? record.source_file_id !== item.file_id : record.source_msg_id !== item.msg_id) || record.source_wire_id !== item.wire_id || record.sender_identity !== item.sender_id || record.fingerprint !== inputFingerprint(item)) {
14900
+ throw new Error("inbox source does not match its durable intake rejection");
14901
+ }
14902
+ return true;
14903
+ }
14904
+ async recordRejectionUnlocked(room, item, error) {
14905
+ const seat = room.seats.find((seat2) => seat2.identity === item.sender_id && seat2.state === "active") ?? room.seats.find((seat2) => seat2.identity === item.sender_id);
14906
+ if (!seat || typeof item.wire_id !== "string" || item.wire_id.length === 0) {
14907
+ throw new Error("intake rejection requires a known seat and source wire");
14908
+ }
14909
+ if (error.code !== "reply_target_unavailable" && error.code !== "thread_files_unsupported") throw error;
14910
+ await this.store.append(room.room_id, {
14911
+ version: 1,
14912
+ kind: "intake_rejection",
14913
+ room_id: room.room_id,
14914
+ at: this.now(),
14915
+ ..."file_id" in item ? { source_kind: "file", source_file_id: item.file_id } : { source_kind: "message", source_msg_id: item.msg_id },
14916
+ source_wire_id: item.wire_id,
14917
+ sender_identity: item.sender_id,
14918
+ sender_participant_id: seat.participant_id,
14919
+ fingerprint: inputFingerprint(item),
14920
+ error: error.code,
14921
+ notification_attempt_claimed: true
14922
+ });
14923
+ try {
14924
+ await sendRoomBody(this.packet(room.room_id), item.sender_id, {
14925
+ version: 1,
14926
+ kind: "room_msg",
14927
+ room_id: room.room_id,
14928
+ room_name: room.room_name,
14929
+ message_id: this.nextMessageId(),
14930
+ at: this.now(),
14931
+ text: error.code,
14932
+ author: { identity: room.identity_cid, display_name: room.identity_name, role: ROOM_ROLE }
14933
+ });
14934
+ } catch {
14935
+ }
14936
+ }
14373
14937
  acknowledgeMessage(roomId, packet, expected) {
14374
14938
  return packet.acknowledgeMessage(
14375
14939
  expected,
@@ -14493,26 +15057,44 @@ var init_intake = __esm({
14493
15057
  const [message] = intent.message_id === void 0 ? [] : await queryStore(this.store, roomId, { kind: "message", messageId: intent.message_id, limit: 1 });
14494
15058
  const [file] = intent.file_id === void 0 ? [] : await queryStore(this.store, roomId, { kind: "file", fileId: intent.file_id, limit: 1 });
14495
15059
  if (message === void 0 === (file === void 0)) continue;
14496
- const recipients = message?.recipient_identities ?? file.recipient_identities;
14497
- if (!recipients.includes(intent.recipient_identity)) continue;
14498
- if (!activeCids.has(intent.recipient_identity) && removedCids.has(intent.recipient_identity)) {
14499
- const skipped = await this.store.append(roomId, {
14500
- version: 1,
14501
- kind: "relay_result",
14502
- room_id: roomId,
14503
- at: this.now(),
14504
- intent_record_id: intent.record_id,
14505
- ...intent.message_id === void 0 ? {} : { message_id: intent.message_id },
14506
- ...intent.file_id === void 0 ? {} : { file_id: intent.file_id },
14507
- recipient_identity: intent.recipient_identity,
14508
- status: "skipped_removed"
14509
- });
14510
- if (skipped.kind !== "relay_result") throw new Error("storage returned the wrong relay result kind");
14511
- continue;
14512
- }
14513
15060
  const source = message ?? file;
14514
- const replyRows = source.source_reply_to === void 0 ? [] : await readReplyRows(this.store, roomId);
15061
+ const replyRows = source.source_reply_to === void 0 && message?.scope === void 0 && message?.thread_root === void 0 ? [] : await readReplyRows(this.store, roomId);
14515
15062
  const decision = selectReply(replyRows, roomId, source, intent.recipient_identity);
15063
+ let publicThread = {};
15064
+ let scopedAuthor;
15065
+ try {
15066
+ const association = classifyThreadAssociation(room, replyRows, source);
15067
+ if (association.state === "scoped") {
15068
+ const { root, scope } = association;
15069
+ if (!message || !message.recipient_identities.includes(intent.recipient_identity) || message.seq >= intent.seq || !root.thread_root.members.some((member) => member.identity === intent.recipient_identity)) {
15070
+ throw new ThreadFailure("reply_target_unavailable");
15071
+ }
15072
+ if (!threadRelayEligible(room, root.thread_root, intent.recipient_identity)) {
15073
+ await this.skipRelay(roomId, intent, "skipped_removed");
15074
+ continue;
15075
+ }
15076
+ const metadata = publicThreadMetadata({ ...root, thread_root: root.thread_root }, room);
15077
+ scopedAuthor = publicThreadAuthor(message, root.thread_root, room);
15078
+ if (message.message_id === root.message_id) {
15079
+ publicThread = { thread: { schema_version: 1, thread_id: root.message_id }, thread_root: metadata };
15080
+ } else {
15081
+ if (decision.state !== "linked" || decision.parentKey !== scope.parent_key) {
15082
+ throw new ThreadFailure("reply_target_unavailable");
15083
+ }
15084
+ publicThread = { thread: { schema_version: 1, thread_id: root.message_id } };
15085
+ }
15086
+ } else {
15087
+ if (!source.recipient_identities.includes(intent.recipient_identity)) continue;
15088
+ if (!activeCids.has(intent.recipient_identity) && removedCids.has(intent.recipient_identity)) {
15089
+ await this.skipRelay(roomId, intent, "skipped_removed");
15090
+ continue;
15091
+ }
15092
+ }
15093
+ } catch (error) {
15094
+ if (!(error instanceof ThreadFailure)) throw error;
15095
+ await this.skipRelay(roomId, intent, "skipped_reply_unavailable");
15096
+ continue;
15097
+ }
14516
15098
  const replyTo = decision.replyTo;
14517
15099
  if (file !== void 0) {
14518
15100
  const uploader = file.author_alias?.alias ?? file.author.display_name;
@@ -14573,11 +15155,12 @@ var init_intake = __esm({
14573
15155
  room_name: room.room_name,
14574
15156
  message_id: message.message_id,
14575
15157
  // An anonymous author leaves the archive only in alias form.
14576
- author: message.author_alias === void 0 ? message.author : {
15158
+ author: scopedAuthor ?? (message.author_alias === void 0 ? message.author : {
14577
15159
  identity: message.author_alias.participant_id,
14578
15160
  display_name: message.author_alias.alias,
14579
15161
  role: message.author.role
14580
- },
15162
+ }),
15163
+ ...publicThread,
14581
15164
  text: message.text,
14582
15165
  at: message.at,
14583
15166
  ...message.briefing_role === void 0 ? {} : { briefing_role: message.briefing_role },
@@ -14600,6 +15183,20 @@ var init_intake = __esm({
14600
15183
  }
14601
15184
  }
14602
15185
  }
15186
+ async skipRelay(roomId, intent, status) {
15187
+ const result = await this.store.append(roomId, {
15188
+ version: 1,
15189
+ kind: "relay_result",
15190
+ room_id: roomId,
15191
+ at: this.now(),
15192
+ intent_record_id: intent.record_id,
15193
+ ...intent.message_id === void 0 ? {} : { message_id: intent.message_id },
15194
+ ...intent.file_id === void 0 ? {} : { file_id: intent.file_id },
15195
+ recipient_identity: intent.recipient_identity,
15196
+ status
15197
+ });
15198
+ if (result.kind !== "relay_result") throw new Error("storage returned the wrong relay result kind");
15199
+ }
14603
15200
  findSourceMessage(records, item) {
14604
15201
  const message = records.find((record) => record.kind === "message" && record.source_msg_id === item.msg_id);
14605
15202
  if (!message) return void 0;
@@ -14793,13 +15390,13 @@ var init_command_routes = __esm({
14793
15390
  }).strict();
14794
15391
  RuntimeCommandGrantParams = external_exports.object({
14795
15392
  room_id: external_exports.string(),
14796
- caller_cid: ContainerIdSchema,
14797
- command: RuntimeCommandNameSchema
15393
+ caller_cid: ContainerIdSchema2,
15394
+ command: RuntimeCommandGrantPatternSchema
14798
15395
  }).strict();
14799
15396
  RuntimeRoleCommandGrantParams = external_exports.object({
14800
15397
  room_id: external_exports.string(),
14801
15398
  role: external_exports.string(),
14802
- commands: external_exports.array(RuntimeCommandNameSchema)
15399
+ commands: external_exports.array(RuntimeCommandGrantPatternSchema)
14803
15400
  }).strict();
14804
15401
  ParticipantRemoveParams = external_exports.object({
14805
15402
  room_id: external_exports.string(),
@@ -14815,9 +15412,132 @@ var init_command_routes = __esm({
14815
15412
  }
14816
15413
  });
14817
15414
 
15415
+ // src/thread-history.ts
15416
+ function publicHistoryMessage(record, author = record.author) {
15417
+ return {
15418
+ version: 1,
15419
+ room_id: record.room_id,
15420
+ seq: record.seq,
15421
+ record_id: record.record_id,
15422
+ at: record.at,
15423
+ kind: "message",
15424
+ message_id: record.message_id,
15425
+ author: { identity: author.identity, display_name: author.display_name, role: author.role },
15426
+ category: record.category,
15427
+ text: record.text,
15428
+ ...record.category === "role_briefing" ? { briefing_role: record.briefing_role } : {},
15429
+ ...(record.category === "briefing" || record.category === "role_briefing") && record.briefing_version !== void 0 ? { briefing_version: record.briefing_version } : {},
15430
+ ...record.category === "membership" && record.membership ? { membership: {
15431
+ action: record.membership.action,
15432
+ epoch: record.membership.epoch,
15433
+ ...record.membership.alias === void 0 ? {} : { alias: record.membership.alias },
15434
+ ...record.membership.role === void 0 ? {} : { role: record.membership.role }
15435
+ } } : {}
15436
+ };
15437
+ }
15438
+ function projectParticipantHistory(room, records, viewerCid, page) {
15439
+ const viewer = room.seats.find((seat) => seat.state === "active" && seat.identity === viewerCid);
15440
+ if (!viewer || room.state !== "active") throw new ThreadFailure("unauthorized");
15441
+ const { after = 0, limit = 200 } = ParticipantHistoryPageSchema.parse(page);
15442
+ const replyRows = records.filter((row) => ["message", "file", "relay_intent", "relay_result"].includes(row.kind));
15443
+ const output = [];
15444
+ let ordinal = 0, bytes = 2;
15445
+ for (const record of records) {
15446
+ if (record.kind !== "message" || record.room_id !== room.room_id) continue;
15447
+ let author = record.author;
15448
+ let thread;
15449
+ let thread_root;
15450
+ try {
15451
+ const association = classifyThreadAssociation(room, replyRows, record);
15452
+ if (association.state === "scoped") {
15453
+ const { root } = association;
15454
+ if (!activeThreadSeat(room, root.thread_root, viewerCid)) continue;
15455
+ const metadata = publicThreadMetadata(root, room);
15456
+ author = publicThreadAuthor(record, root.thread_root, room);
15457
+ if (record.message_id === root.message_id) thread_root = metadata;
15458
+ thread = { schema_version: 1, thread_id: root.message_id };
15459
+ } else if (record.author_alias !== void 0) {
15460
+ const alias = AuthorAliasSchema.parse(record.author_alias);
15461
+ author = { identity: alias.participant_id, display_name: alias.alias, role: record.author.role };
15462
+ }
15463
+ } catch (error) {
15464
+ if (error instanceof ThreadFailure || error instanceof external_exports.ZodError) continue;
15465
+ throw error;
15466
+ }
15467
+ ordinal += 1;
15468
+ if (ordinal <= after) continue;
15469
+ const projected = {
15470
+ ...publicHistoryMessage(record, author),
15471
+ seq: ordinal,
15472
+ record_id: `${room.room_id}:participant:${viewer.participant_id}:${ordinal}`,
15473
+ ...thread ? { thread } : {},
15474
+ ...thread_root ? { thread_root } : {}
15475
+ };
15476
+ const size = Buffer.byteLength(JSON.stringify(projected), "utf8") + (output.length ? 1 : 0);
15477
+ if (bytes + size > MAX_HISTORY_PAGE_BYTES) {
15478
+ if (output.length === 0) throw new RangeError("one participant history record exceeds the page byte contract");
15479
+ break;
15480
+ }
15481
+ output.push(projected);
15482
+ bytes += size;
15483
+ if (output.length >= limit) break;
15484
+ }
15485
+ return output;
15486
+ }
15487
+ var PublicThreadSchema, PublicThreadMetadataSchema, PublicMessageShape, ParticipantHistoryRecordSchema, ParticipantHistoryPageSchema;
15488
+ var init_thread_history = __esm({
15489
+ "src/thread-history.ts"() {
15490
+ "use strict";
15491
+ init_zod();
15492
+ init_contracts();
15493
+ init_thread_contracts();
15494
+ init_threads();
15495
+ PublicThreadSchema = external_exports.object({ schema_version: external_exports.literal(1), thread_id: LowerCrockfordUlidSchema }).strict();
15496
+ PublicThreadMetadataSchema = PublicThreadSchema.extend({
15497
+ topic: external_exports.string(),
15498
+ creator: AuthorSnapshotSchema,
15499
+ participant_ids: external_exports.array(LowerCrockfordUlidSchema),
15500
+ created_at: Rfc3339Schema
15501
+ }).strict();
15502
+ PublicMessageShape = {
15503
+ version: external_exports.literal(1),
15504
+ room_id: LowerCrockfordUlidSchema,
15505
+ seq: external_exports.number().int().positive().safe(),
15506
+ record_id: external_exports.string(),
15507
+ at: Rfc3339Schema,
15508
+ kind: external_exports.literal("message"),
15509
+ message_id: LowerCrockfordUlidSchema,
15510
+ author: AuthorSnapshotSchema,
15511
+ category: external_exports.enum(["briefing", "role_briefing", "chat", "membership"]),
15512
+ briefing_role: RoleSchema.optional(),
15513
+ briefing_version: external_exports.number().int().positive().safe().optional(),
15514
+ membership: MembershipNoticeSchema.optional(),
15515
+ text: MessageTextSchema
15516
+ };
15517
+ ParticipantHistoryRecordSchema = external_exports.object({
15518
+ ...PublicMessageShape,
15519
+ thread: PublicThreadSchema.optional(),
15520
+ thread_root: PublicThreadMetadataSchema.optional()
15521
+ }).strict().superRefine((row, ctx) => {
15522
+ const prefix = `${row.room_id}:participant:`;
15523
+ const participantId = row.record_id.slice(prefix.length, -(String(row.seq).length + 1));
15524
+ if (!row.record_id.startsWith(prefix) || !LowerCrockfordUlidSchema.safeParse(participantId).success || row.record_id !== `${prefix}${participantId}:${row.seq}`) {
15525
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["record_id"], message: "must identify the viewer and visible ordinal" });
15526
+ }
15527
+ if (row.thread_root && (!row.thread || row.thread.thread_id !== row.thread_root.thread_id || row.message_id !== row.thread.thread_id)) {
15528
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["thread_root"], message: "must identify this thread root" });
15529
+ }
15530
+ });
15531
+ ParticipantHistoryPageSchema = external_exports.object({
15532
+ after: external_exports.number().int().nonnegative().safe().optional(),
15533
+ limit: external_exports.number().int().positive().safe().optional()
15534
+ }).strict();
15535
+ }
15536
+ });
15537
+
14818
15538
  // src/service.ts
14819
15539
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
14820
- import { createHash as createHash4 } from "node:crypto";
15540
+ import { createHash as createHash5 } from "node:crypto";
14821
15541
  function byteBoundedHistoryPage(records) {
14822
15542
  const page = [];
14823
15543
  let bytes = 2;
@@ -14874,7 +15594,7 @@ function uniqueIdentities(identities) {
14874
15594
  function currentContactIdentities(packet) {
14875
15595
  return new Set(packet.listContacts().map((contact) => contact.container_id));
14876
15596
  }
14877
- var CreateInviteInputSchema, HistoryOptionsSchema, JOURNAL_WORK_BATCH_SIZE2, DeleteRoomInputSchema, RemoveParticipantInputSchema, RoomServiceError, RoomService;
15597
+ var MAX_ROOM_MESSAGE_BYTES, CreateInviteInputSchema, HistoryOptionsSchema, JOURNAL_WORK_BATCH_SIZE2, DeleteRoomInputSchema, RemoveParticipantInputSchema, RoomServiceError, RoomService;
14878
15598
  var init_service = __esm({
14879
15599
  "src/service.ts"() {
14880
15600
  "use strict";
@@ -14886,6 +15606,11 @@ var init_service = __esm({
14886
15606
  init_consumer_commands();
14887
15607
  init_command_names();
14888
15608
  init_ulid();
15609
+ init_reply_threading();
15610
+ init_thread_contracts();
15611
+ init_thread_history();
15612
+ init_threads();
15613
+ MAX_ROOM_MESSAGE_BYTES = 262144;
14889
15614
  CreateInviteInputSchema = external_exports.object({
14890
15615
  mode: InviteModeSchema,
14891
15616
  role: RoleSchema.optional(),
@@ -15105,11 +15830,106 @@ var init_service = __esm({
15105
15830
  handler: (input, context) => this.invokeConsumerCommand(roomId, definition.name, input, context)
15106
15831
  })),
15107
15832
  sharedCommand: (name, input, context) => this.lock(roomId, () => this.sharedCommandUnlocked(roomId, name, input, context)),
15833
+ startThread: async (input, context) => {
15834
+ const result = await this.lock(roomId, () => this.startThreadCommandUnlocked(roomId, input, context));
15835
+ if (result.ok === true) await this.intake.resumePending(roomId);
15836
+ return result;
15837
+ },
15108
15838
  listMembers: (input, context) => this.lock(roomId, () => this.listMembersCommandUnlocked(roomId, input, context)),
15109
15839
  removeMember: (input, context) => this.lock(roomId, () => this.removeMemberCommandUnlocked(roomId, input, context))
15110
15840
  });
15111
15841
  this.publishedConsumerRevisions.set(roomId, registered.consumer_commands_revision ?? 0);
15112
15842
  }
15843
+ /** Called by the registered adapter while it owns the room mutex. */
15844
+ async startThreadCommandUnlocked(roomId, input, context) {
15845
+ const room = await this.store.load(roomId);
15846
+ if (room.state !== "active" || room.lifecycle_request?.state === "pending") {
15847
+ return { ok: false, error: "room_unavailable" };
15848
+ }
15849
+ const creator = room.seats.find((seat) => seat.state === "active" && seat.identity === context.sender_cid);
15850
+ if (creator === void 0 || !this.hasRuntimeCommandGrant(room, context.sender_cid, "start_thread")) {
15851
+ return { ok: false, error: "unauthorized" };
15852
+ }
15853
+ const parsed = StartThreadInputSchema.safeParse(input);
15854
+ if (!parsed.success) return { ok: false, error: "invalid_request" };
15855
+ const request = parsed.data;
15856
+ const rows = await readReplyRows(this.store, roomId);
15857
+ const prior = rows.filter((row) => row.kind === "message" && row.thread_root !== void 0 && row.author.identity === context.sender_cid && row.thread_root.idempotency_key === request.idempotency_key);
15858
+ if (prior.length > 1) throw new Error("duplicate thread roots for creator idempotency key");
15859
+ if (prior.length === 1) {
15860
+ const root2 = prior[0];
15861
+ if (root2.thread_root.creator_participant_id !== creator.participant_id) {
15862
+ return { ok: false, error: "unauthorized" };
15863
+ }
15864
+ if (root2.thread_root.fingerprint !== threadFingerprint(request)) {
15865
+ return { ok: false, error: "idempotency_conflict" };
15866
+ }
15867
+ return { ok: true, thread_id: root2.message_id, status: "accepted" };
15868
+ }
15869
+ let members;
15870
+ try {
15871
+ members = selectThreadMembers(room, context.sender_cid, request);
15872
+ } catch (error) {
15873
+ if (error instanceof ThreadFailure) return { ok: false, error: error.code };
15874
+ throw error;
15875
+ }
15876
+ const threadId = LowerCrockfordUlidSchema.parse(this.nextMessageId());
15877
+ const at = this.now();
15878
+ const threadRoot = {
15879
+ schema_version: 1,
15880
+ thread_id: threadId,
15881
+ topic: request.topic,
15882
+ creator_participant_id: creator.participant_id,
15883
+ members,
15884
+ idempotency_key: request.idempotency_key,
15885
+ fingerprint: threadFingerprint(request)
15886
+ };
15887
+ if (room.anonymous && creator.alias === void 0) {
15888
+ throw new Error("anonymous thread creator is missing its room alias");
15889
+ }
15890
+ const root = {
15891
+ version: 1,
15892
+ kind: "message",
15893
+ room_id: roomId,
15894
+ at,
15895
+ message_id: threadId,
15896
+ author: {
15897
+ identity: creator.identity,
15898
+ display_name: creator.display_name,
15899
+ role: creator.role
15900
+ },
15901
+ ...room.anonymous ? {
15902
+ author_alias: { participant_id: creator.participant_id, alias: creator.alias }
15903
+ } : {},
15904
+ category: "chat",
15905
+ text: `Thread: ${request.topic}`,
15906
+ recipient_identities: members.map((member) => member.identity),
15907
+ scope: { thread_id: threadId },
15908
+ thread_root: threadRoot
15909
+ };
15910
+ const projected = {
15911
+ version: 1,
15912
+ kind: "room_msg",
15913
+ room_id: roomId,
15914
+ room_name: room.room_name,
15915
+ message_id: threadId,
15916
+ author: room.anonymous ? {
15917
+ identity: creator.participant_id,
15918
+ display_name: creator.alias,
15919
+ role: creator.role
15920
+ } : root.author,
15921
+ text: root.text,
15922
+ at,
15923
+ thread: { schema_version: 1, thread_id: threadId },
15924
+ thread_root: publicThreadMetadata(root, room)
15925
+ };
15926
+ if (Buffer.byteLength(JSON.stringify(projected), "utf8") > MAX_ROOM_MESSAGE_BYTES) {
15927
+ return { ok: false, error: "invalid_request" };
15928
+ }
15929
+ const appended = await this.store.append(roomId, root);
15930
+ if (appended.kind !== "message") throw new Error("storage returned the wrong thread root kind");
15931
+ return { ok: true, thread_id: appended.message_id, status: "accepted" };
15932
+ }
15113
15933
  /** The SDK supplies authenticated context; arguments never select another room. */
15114
15934
  async sharedCommandUnlocked(roomId, name, input, context) {
15115
15935
  if (!SHARED_ROOM_COMMANDS.includes(name) || input === null || typeof input !== "object" || Array.isArray(input) || Object.hasOwn(input, "room_id")) return { ok: false, error: "invalid_request" };
@@ -15150,8 +15970,30 @@ var init_service = __esm({
15150
15970
  try {
15151
15971
  return await this.commandScope.run(scope, async () => {
15152
15972
  try {
15973
+ if (name === "room.history") {
15974
+ const request = ParticipantHistoryPageSchema.extend({ view: external_exports.literal("participant").optional() }).safeParse(input);
15975
+ if (!request.success) return { ok: false, error: "invalid_request" };
15976
+ const { view: _view, ...page } = request.data;
15977
+ return { ok: true, result: JSON.parse(JSON.stringify(await this.participantHistory(roomId, context.sender_cid, page))) };
15978
+ }
15979
+ if (name === "room.show" || name === "room.participants") {
15980
+ external_exports.object({}).strict().parse(input);
15981
+ const result2 = name === "room.show" ? {
15982
+ room_id: room.room_id,
15983
+ room_name: room.room_name,
15984
+ state: room.state,
15985
+ mission: { goal: room.mission.goal, briefing: room.mission.briefing, briefing_version: room.mission.briefing_version },
15986
+ anonymous: room.anonymous,
15987
+ quiet_membership: room.quiet_membership,
15988
+ membership_epoch: room.membership_epoch
15989
+ } : room.seats.map(({ participant_id, role, state }) => ({ participant_id, role, state }));
15990
+ return { ok: true, result: JSON.parse(JSON.stringify(result2)) };
15991
+ }
15153
15992
  const routes = name === "room.accept" ? createPrivateServiceRoutes(this) : createServiceRoutes(this);
15154
15993
  const result = await routes[name].run({ ...input, room_id: roomId });
15994
+ if (name === "room.message" || name === "room.say") {
15995
+ return { ok: true, result: { message_id: result.message_id, accepted: true } };
15996
+ }
15155
15997
  return { ok: true, result: JSON.parse(JSON.stringify(result)) };
15156
15998
  } catch (error) {
15157
15999
  return { ok: false, error: classifyServiceError(error) };
@@ -15322,7 +16164,7 @@ var init_service = __esm({
15322
16164
  } catch {
15323
16165
  throw new RoomServiceError("external invite is invalid or exceeds the 48 KiB decoded limit");
15324
16166
  }
15325
- const digest = createHash4("sha256").update(decoded).digest("hex");
16167
+ const digest = createHash5("sha256").update(decoded).digest("hex");
15326
16168
  const receipt = await this.lock(id, async () => {
15327
16169
  const room = await this.store.load(id);
15328
16170
  this.assertMutable(room, "accept an external invite for");
@@ -15335,7 +16177,7 @@ var init_service = __esm({
15335
16177
  } catch {
15336
16178
  throw new RoomServiceError("external invite was rejected");
15337
16179
  }
15338
- const cid = ContainerIdSchema.parse(added.container_id);
16180
+ const cid = ContainerIdSchema2.parse(added.container_id);
15339
16181
  if (request.expected_cid !== void 0 && cid !== request.expected_cid) {
15340
16182
  throw new RoomServiceError("external invite inviter CID did not match --expected-cid");
15341
16183
  }
@@ -15439,9 +16281,9 @@ var init_service = __esm({
15439
16281
  return seat;
15440
16282
  }
15441
16283
  hasRuntimeCommandGrant(room, callerCid, command) {
15442
- if (room.command_grants.some((grant) => grant.caller_cid === callerCid && grant.command === command)) return true;
16284
+ if (room.command_grants.some((grant) => grant.caller_cid === callerCid && commandGrantMatches(grant.command, command))) return true;
15443
16285
  const caller = room.seats.find((seat) => seat.state === "active" && seat.identity === callerCid);
15444
- return caller !== void 0 && room.role_command_grants.some((grant) => grant.role === caller.role && grant.commands.includes(command));
16286
+ return caller !== void 0 && room.role_command_grants.some((grant) => grant.role === caller.role && grant.commands.some((pattern) => commandGrantMatches(pattern, command)));
15445
16287
  }
15446
16288
  async beginRemovalUnlocked(room, seat, notify) {
15447
16289
  if (seat.state === "pending" || isCancelledExternalSeat(seat)) {
@@ -15749,7 +16591,7 @@ var init_service = __esm({
15749
16591
  }
15750
16592
  /** List the operator-managed runtime-command grants for one room. */
15751
16593
  assertRegisteredConsumerName(room, name) {
15752
- if (name.startsWith("consumer.") && !(room.consumer_commands ?? []).some((definition) => definition.name === name)) {
16594
+ if (name.startsWith("consumer.") && !name.endsWith(".*") && !(room.consumer_commands ?? []).some((definition) => definition.name === name)) {
15753
16595
  throw new RoomServiceError("consumer command is not registered");
15754
16596
  }
15755
16597
  }
@@ -15893,7 +16735,7 @@ var init_service = __esm({
15893
16735
  return saved.role_command_grants.map((grant) => ({ role: grant.role, commands: [...grant.commands] }));
15894
16736
  });
15895
16737
  }
15896
- /** Grant one exact command to one active authenticated room identity. Idempotent. */
16738
+ /** Grant one command selector to one active authenticated room identity. Idempotent. */
15897
16739
  async grantRuntimeCommand(roomId, input) {
15898
16740
  const id = LowerCrockfordUlidSchema.parse(roomId);
15899
16741
  const request = RuntimeCommandGrantInputSchema.parse(input);
@@ -15912,7 +16754,7 @@ var init_service = __esm({
15912
16754
  return saved.command_grants.map((grant) => ({ ...grant }));
15913
16755
  });
15914
16756
  }
15915
- /** Revoke one exact command grant. Absence is already the requested state. */
16757
+ /** Remove only the exact stored selector; overlapping grants remain effective. */
15916
16758
  async revokeRuntimeCommand(roomId, input) {
15917
16759
  const id = LowerCrockfordUlidSchema.parse(roomId);
15918
16760
  const request = RuntimeCommandGrantInputSchema.parse(input);
@@ -15927,29 +16769,38 @@ var init_service = __esm({
15927
16769
  return saved.command_grants.map((grant) => ({ ...grant }));
15928
16770
  });
15929
16771
  }
16772
+ /** Internal authenticated API; numeric cursors count only this viewer's visible messages. */
16773
+ async participantHistory(roomId, viewerCid, page = {}) {
16774
+ const id = LowerCrockfordUlidSchema.parse(roomId);
16775
+ const request = ParticipantHistoryPageSchema.parse(page);
16776
+ return this.lock(id, async () => {
16777
+ const room = await this.store.load(id);
16778
+ if (room.state !== "active" || !room.seats.some((seat) => seat.state === "active" && seat.identity === viewerCid)) {
16779
+ throw new ThreadFailure("unauthorized");
16780
+ }
16781
+ const records = [];
16782
+ let after = 0;
16783
+ for (; ; ) {
16784
+ const batch = await this.store.read(id, { after, limit: JOURNAL_WORK_BATCH_SIZE2 });
16785
+ if (batch.length === 0) break;
16786
+ const last = batch[batch.length - 1];
16787
+ if (last.seq <= after || batch.some((row) => row.room_id !== id)) throw new Error("invalid participant history archive page");
16788
+ records.push(...batch);
16789
+ after = last.seq;
16790
+ }
16791
+ return projectParticipantHistory(room, records, viewerCid, request);
16792
+ });
16793
+ }
15930
16794
  async history(roomId, options = {}) {
15931
16795
  const id = LowerCrockfordUlidSchema.parse(roomId);
15932
16796
  const { view, ...page } = HistoryOptionsSchema.parse(options);
15933
16797
  const records = view === "participant" ? await queryStore2(this.store, id, { kind: "message", after: page.after, limit: page.limit }) : await this.store.read(id, page);
15934
16798
  if (view !== "participant") return byteBoundedHistoryPage(records);
15935
- const projected = records.filter((record) => record.kind === "message").map((record) => {
15936
- const {
15937
- author_alias,
15938
- recipient_identities: _recipients,
15939
- source_msg_id: _sourceMsg,
15940
- source_wire_id: _sourceWire,
15941
- source_reply_to: _sourceReplyTo,
15942
- ...rest
15943
- } = record;
15944
- return {
15945
- ...rest,
15946
- author: author_alias === void 0 ? record.author : {
15947
- identity: author_alias.participant_id,
15948
- display_name: author_alias.alias,
15949
- role: record.author.role
15950
- }
15951
- };
15952
- });
16799
+ const projected = records.filter((record) => record.kind === "message").map((record) => publicHistoryMessage(record, record.author_alias === void 0 ? record.author : {
16800
+ identity: record.author_alias.participant_id,
16801
+ display_name: record.author_alias.alias,
16802
+ role: record.author.role
16803
+ }));
15953
16804
  return byteBoundedHistoryPage(projected.slice(0, page.limit ?? Number.MAX_SAFE_INTEGER));
15954
16805
  }
15955
16806
  /**
@@ -16509,7 +17360,7 @@ import * as nodeFs2 from "node:fs";
16509
17360
  import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
16510
17361
  import { basename, dirname as dirname3, join as join4 } from "node:path";
16511
17362
  import Database from "better-sqlite3";
16512
- var DIRECTORY_MODE2, FILE_MODE2, NO_FOLLOW2, SQLITE_SCHEMA_VERSION, DEFAULT_WORK_BATCH_SIZE, utf8Decoder, CoworkStorageError, RoomQueue, CoworkStore;
17363
+ var DIRECTORY_MODE2, FILE_MODE2, NO_FOLLOW2, SQLITE_SCHEMA_VERSION, DEFAULT_WORK_BATCH_SIZE, utf8Decoder, SQLITE_V2_EXTENSION_DDL, CoworkStorageError, RoomQueue, CoworkStore;
16513
17364
  var init_storage = __esm({
16514
17365
  "src/storage.ts"() {
16515
17366
  "use strict";
@@ -16518,9 +17369,24 @@ var init_storage = __esm({
16518
17369
  DIRECTORY_MODE2 = 448;
16519
17370
  FILE_MODE2 = 384;
16520
17371
  NO_FOLLOW2 = nodeFs2.constants.O_NOFOLLOW ?? 0;
16521
- SQLITE_SCHEMA_VERSION = 1;
17372
+ SQLITE_SCHEMA_VERSION = 2;
16522
17373
  DEFAULT_WORK_BATCH_SIZE = 64;
16523
17374
  utf8Decoder = new TextDecoder("utf-8", { fatal: true });
17375
+ SQLITE_V2_EXTENSION_DDL = `
17376
+ CREATE UNIQUE INDEX IF NOT EXISTS records_thread_creation_key
17377
+ ON records(json_extract(payload_json,'$.author.identity'),
17378
+ json_extract(payload_json,'$.thread_root.idempotency_key'))
17379
+ WHERE kind='message' AND json_type(payload_json,'$.thread_root')='object';
17380
+ CREATE INDEX IF NOT EXISTS records_thread_id
17381
+ ON records(json_extract(payload_json,'$.scope.thread_id'))
17382
+ WHERE kind='message' AND json_type(payload_json,'$.scope')='object';
17383
+ DROP INDEX IF EXISTS records_source_message;
17384
+ DROP INDEX IF EXISTS records_source_file;
17385
+ CREATE UNIQUE INDEX records_source_message ON records(source_msg_id)
17386
+ WHERE source_msg_id IS NOT NULL;
17387
+ CREATE UNIQUE INDEX records_source_file ON records(source_file_id)
17388
+ WHERE source_file_id IS NOT NULL;
17389
+ `;
16524
17390
  CoworkStorageError = class extends Error {
16525
17391
  constructor(message, options) {
16526
17392
  super(message, options);
@@ -16924,55 +17790,65 @@ var init_storage = __esm({
16924
17790
  try {
16925
17791
  this.secureSqliteFiles(path);
16926
17792
  db = new Database(path, { fileMustExist: !create });
17793
+ const activeDb = db;
16927
17794
  if (guardFd !== void 0) this.validateOpenPath(guardFd, path, "room archive database", "file", true);
16928
17795
  this.fs.chmodSync(path, FILE_MODE2);
16929
- db.pragma("journal_mode = WAL");
16930
- db.pragma("synchronous = FULL");
16931
- db.pragma("foreign_keys = ON");
16932
- db.pragma("busy_timeout = 5000");
17796
+ const existingVersion = create ? void 0 : activeDb.pragma("user_version", { simple: true });
17797
+ if (existingVersion !== void 0 && existingVersion !== 1 && existingVersion !== SQLITE_SCHEMA_VERSION) {
17798
+ throw new CoworkStorageError(`unsupported room archive schema version ${existingVersion}`);
17799
+ }
17800
+ activeDb.pragma("journal_mode = WAL");
17801
+ activeDb.pragma("synchronous = FULL");
17802
+ activeDb.pragma("foreign_keys = ON");
17803
+ activeDb.pragma("busy_timeout = 5000");
16933
17804
  if (create) {
16934
- db.exec(`CREATE TABLE records (
16935
- seq INTEGER PRIMARY KEY, record_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, at TEXT NOT NULL,
16936
- payload_json TEXT NOT NULL, blob_path TEXT, message_id TEXT, file_id TEXT, intent_record_id TEXT,
16937
- recipient_identity TEXT, source_msg_id INTEGER, source_file_id INTEGER, category TEXT,
16938
- briefing_role TEXT, briefing_version INTEGER, membership_epoch INTEGER
16939
- );
16940
- CREATE TABLE record_recipients (
16941
- record_seq INTEGER NOT NULL REFERENCES records(seq) ON DELETE CASCADE,
16942
- recipient_identity TEXT NOT NULL, category TEXT, briefing_role TEXT,
16943
- briefing_version INTEGER, PRIMARY KEY(record_seq, recipient_identity)
16944
- );
16945
- CREATE TABLE relay_intent_work (
16946
- record_seq INTEGER NOT NULL REFERENCES records(seq) ON DELETE CASCADE,
16947
- recipient_identity TEXT NOT NULL, PRIMARY KEY(record_seq, recipient_identity)
16948
- );
16949
- CREATE INDEX relay_work_source ON relay_intent_work(record_seq, recipient_identity);
16950
- CREATE INDEX records_kind_seq ON records(kind, seq);
16951
- CREATE INDEX records_message ON records(message_id, kind, seq);
16952
- CREATE INDEX records_file ON records(file_id, kind, seq);
16953
- CREATE INDEX records_intent_result ON records(intent_record_id, kind);
16954
- CREATE INDEX records_relay_recipient ON records(kind, recipient_identity, seq);
16955
- CREATE UNIQUE INDEX records_source_message ON records(source_msg_id) WHERE kind='message' AND source_msg_id IS NOT NULL;
16956
- CREATE UNIQUE INDEX records_source_file ON records(source_file_id) WHERE kind='file' AND source_file_id IS NOT NULL;
16957
- CREATE INDEX records_briefing ON records(category, briefing_role, briefing_version, seq);
16958
- CREATE INDEX records_membership_epoch ON records(category, membership_epoch);
16959
- CREATE INDEX recipients_identity ON record_recipients(recipient_identity, record_seq);
16960
- CREATE INDEX recipients_briefing_delivery ON record_recipients
16961
- (recipient_identity, category, briefing_role, briefing_version, record_seq);`);
16962
- db.pragma(`user_version = ${SQLITE_SCHEMA_VERSION}`);
17805
+ activeDb.transaction(() => {
17806
+ activeDb.exec(`CREATE TABLE records (
17807
+ seq INTEGER PRIMARY KEY, record_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, at TEXT NOT NULL,
17808
+ payload_json TEXT NOT NULL, blob_path TEXT, message_id TEXT, file_id TEXT, intent_record_id TEXT,
17809
+ recipient_identity TEXT, source_msg_id INTEGER, source_file_id INTEGER, category TEXT,
17810
+ briefing_role TEXT, briefing_version INTEGER, membership_epoch INTEGER
17811
+ );
17812
+ CREATE TABLE record_recipients (
17813
+ record_seq INTEGER NOT NULL REFERENCES records(seq) ON DELETE CASCADE,
17814
+ recipient_identity TEXT NOT NULL, category TEXT, briefing_role TEXT,
17815
+ briefing_version INTEGER, PRIMARY KEY(record_seq, recipient_identity)
17816
+ );
17817
+ CREATE TABLE relay_intent_work (
17818
+ record_seq INTEGER NOT NULL REFERENCES records(seq) ON DELETE CASCADE,
17819
+ recipient_identity TEXT NOT NULL, PRIMARY KEY(record_seq, recipient_identity)
17820
+ );
17821
+ CREATE INDEX relay_work_source ON relay_intent_work(record_seq, recipient_identity);
17822
+ CREATE INDEX records_kind_seq ON records(kind, seq);
17823
+ CREATE INDEX records_message ON records(message_id, kind, seq);
17824
+ CREATE INDEX records_file ON records(file_id, kind, seq);
17825
+ CREATE INDEX records_intent_result ON records(intent_record_id, kind);
17826
+ CREATE INDEX records_relay_recipient ON records(kind, recipient_identity, seq);
17827
+ CREATE UNIQUE INDEX records_source_message ON records(source_msg_id) WHERE kind='message' AND source_msg_id IS NOT NULL;
17828
+ CREATE UNIQUE INDEX records_source_file ON records(source_file_id) WHERE kind='file' AND source_file_id IS NOT NULL;
17829
+ CREATE INDEX records_briefing ON records(category, briefing_role, briefing_version, seq);
17830
+ CREATE INDEX records_membership_epoch ON records(category, membership_epoch);
17831
+ CREATE INDEX recipients_identity ON record_recipients(recipient_identity, record_seq);
17832
+ CREATE INDEX recipients_briefing_delivery ON record_recipients
17833
+ (recipient_identity, category, briefing_role, briefing_version, record_seq);`);
17834
+ activeDb.exec(SQLITE_V2_EXTENSION_DDL);
17835
+ activeDb.pragma(`user_version = ${SQLITE_SCHEMA_VERSION}`);
17836
+ }).immediate();
16963
17837
  this.reconciledBlobRooms.add(roomId);
16964
17838
  } else {
16965
- const version = db.pragma("user_version", { simple: true });
16966
- if (version !== SQLITE_SCHEMA_VERSION) {
16967
- throw new CoworkStorageError(`unsupported room archive schema version ${version}`);
17839
+ if (existingVersion === 1) {
17840
+ activeDb.transaction(() => {
17841
+ activeDb.exec(SQLITE_V2_EXTENSION_DDL);
17842
+ activeDb.pragma(`user_version = ${SQLITE_SCHEMA_VERSION}`);
17843
+ }).immediate();
16968
17844
  }
16969
17845
  if (!this.reconciledBlobRooms.has(roomId)) {
16970
- this.reconcileBlobDirectory(roomId, db);
17846
+ this.reconcileBlobDirectory(roomId, activeDb);
16971
17847
  this.reconciledBlobRooms.add(roomId);
16972
17848
  }
16973
17849
  }
16974
17850
  this.secureSqliteFiles(path);
16975
- const result = work(db);
17851
+ const result = work(activeDb);
16976
17852
  this.secureSqliteFiles(path);
16977
17853
  return result;
16978
17854
  } catch (error) {