@ours.network/cowork 1.1.4 → 1.2.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
@@ -11656,6 +11656,7 @@ var init_command_names = __esm({
11656
11656
  "room.role.rest.remove"
11657
11657
  ];
11658
11658
  RUNTIME_COMMAND_NAMES = [
11659
+ "start_thread",
11659
11660
  "list-members",
11660
11661
  "remove-member",
11661
11662
  ...SHARED_ROOM_COMMANDS
@@ -11663,6 +11664,93 @@ var init_command_names = __esm({
11663
11664
  }
11664
11665
  });
11665
11666
 
11667
+ // src/thread-contracts.ts
11668
+ var ParticipantIdSchema, ContainerIdSchema, TopicTextSchema, InputTopicSchema, StoredTopicSchema, IdempotencyKeySchema, ThreadMemberSchema, ThreadRootSchema, ThreadScopeSchema, StartThreadInputSchema, ThreadFailure;
11669
+ var init_thread_contracts = __esm({
11670
+ "src/thread-contracts.ts"() {
11671
+ "use strict";
11672
+ init_zod();
11673
+ ParticipantIdSchema = external_exports.string().regex(
11674
+ /^[0-7][0-9a-hjkmnp-tv-z]{25}$/,
11675
+ "must be a 26-character lowercase Crockford ULID"
11676
+ );
11677
+ ContainerIdSchema = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
11678
+ TopicTextSchema = external_exports.string().refine(
11679
+ (value) => Array.from(value).length >= 1 && Array.from(value).length <= 120 && value.trim().length > 0 && !/[\p{Cc}\p{Cf}]/u.test(value),
11680
+ "topic must contain 1-120 Unicode characters without control or format characters"
11681
+ );
11682
+ InputTopicSchema = TopicTextSchema.transform((value) => value.trim());
11683
+ StoredTopicSchema = TopicTextSchema.refine(
11684
+ (value) => value === value.trim(),
11685
+ "stored topic must already be trimmed"
11686
+ );
11687
+ IdempotencyKeySchema = external_exports.string().regex(
11688
+ /^[A-Za-z0-9._:-]{1,128}$/,
11689
+ "must contain 1-128 portable idempotency-key characters"
11690
+ );
11691
+ ThreadMemberSchema = external_exports.object({
11692
+ participant_id: ParticipantIdSchema,
11693
+ identity: ContainerIdSchema
11694
+ }).strict();
11695
+ ThreadRootSchema = external_exports.object({
11696
+ schema_version: external_exports.literal(1),
11697
+ thread_id: ParticipantIdSchema,
11698
+ topic: StoredTopicSchema,
11699
+ creator_participant_id: ParticipantIdSchema,
11700
+ members: external_exports.array(ThreadMemberSchema).min(1),
11701
+ idempotency_key: IdempotencyKeySchema,
11702
+ fingerprint: external_exports.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase SHA-256 digest")
11703
+ }).strict().superRefine((root, context) => {
11704
+ const participantIds = /* @__PURE__ */ new Set();
11705
+ const identities = /* @__PURE__ */ new Set();
11706
+ for (const [index, member] of root.members.entries()) {
11707
+ if (participantIds.has(member.participant_id)) {
11708
+ context.addIssue({
11709
+ code: external_exports.ZodIssueCode.custom,
11710
+ path: ["members", index, "participant_id"],
11711
+ message: "thread member participant IDs must be unique"
11712
+ });
11713
+ }
11714
+ participantIds.add(member.participant_id);
11715
+ if (identities.has(member.identity)) {
11716
+ context.addIssue({
11717
+ code: external_exports.ZodIssueCode.custom,
11718
+ path: ["members", index, "identity"],
11719
+ message: "thread member identities must be unique"
11720
+ });
11721
+ }
11722
+ identities.add(member.identity);
11723
+ }
11724
+ if (!participantIds.has(root.creator_participant_id)) {
11725
+ context.addIssue({
11726
+ code: external_exports.ZodIssueCode.custom,
11727
+ path: ["creator_participant_id"],
11728
+ message: "thread creator must be a member"
11729
+ });
11730
+ }
11731
+ });
11732
+ ThreadScopeSchema = external_exports.object({
11733
+ thread_id: ParticipantIdSchema,
11734
+ parent_key: external_exports.string().regex(
11735
+ /^(?:message|file):[0-7][0-9a-hjkmnp-tv-z]{25}$/,
11736
+ "must identify an immediate message or file parent"
11737
+ ).optional()
11738
+ }).strict();
11739
+ StartThreadInputSchema = external_exports.object({
11740
+ topic: InputTopicSchema,
11741
+ participant_ids: external_exports.array(ParticipantIdSchema).min(1),
11742
+ idempotency_key: IdempotencyKeySchema
11743
+ }).strict();
11744
+ ThreadFailure = class extends Error {
11745
+ constructor(code) {
11746
+ super(code);
11747
+ this.code = code;
11748
+ this.name = "ThreadFailure";
11749
+ }
11750
+ };
11751
+ }
11752
+ });
11753
+
11666
11754
  // src/contracts.ts
11667
11755
  import { createHash } from "node:crypto";
11668
11756
  function utf8Bounded(label, maximumBytes) {
@@ -11804,6 +11892,18 @@ function refineRelaySubject(record, context) {
11804
11892
  });
11805
11893
  }
11806
11894
  }
11895
+ function refineIntakeRejection(record, context) {
11896
+ if (record.kind !== "intake_rejection") return;
11897
+ const hasMessage = record.source_msg_id !== void 0;
11898
+ const hasFile = record.source_file_id !== void 0;
11899
+ if (hasMessage === hasFile || (record.source_kind === "message" ? !hasMessage : !hasFile)) {
11900
+ context.addIssue({
11901
+ code: external_exports.ZodIssueCode.custom,
11902
+ path: ["source_kind"],
11903
+ message: "intake rejections require exactly one numeric source field matching source_kind"
11904
+ });
11905
+ }
11906
+ }
11807
11907
  function refineFileRecord(record, context) {
11808
11908
  if (record.kind !== "file" || record.data_base64 === void 0) return;
11809
11909
  const bytes = Buffer.from(record.data_base64, "base64");
@@ -11854,13 +11954,110 @@ function refineMessageCategory(message, context) {
11854
11954
  }
11855
11955
  }
11856
11956
  }
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;
11957
+ function refineMessageThread(message, context) {
11958
+ if (message.thread_root !== void 0) {
11959
+ if (message.scope === void 0) {
11960
+ context.addIssue({
11961
+ code: external_exports.ZodIssueCode.custom,
11962
+ path: ["scope"],
11963
+ message: "thread root messages require scope"
11964
+ });
11965
+ return;
11966
+ }
11967
+ if (message.scope.thread_id !== message.message_id || message.thread_root.thread_id !== message.message_id) {
11968
+ context.addIssue({
11969
+ code: external_exports.ZodIssueCode.custom,
11970
+ path: ["thread_root", "thread_id"],
11971
+ message: "thread root thread_id and scope thread_id must equal message_id"
11972
+ });
11973
+ }
11974
+ if (message.scope.parent_key !== void 0) {
11975
+ context.addIssue({
11976
+ code: external_exports.ZodIssueCode.custom,
11977
+ path: ["scope", "parent_key"],
11978
+ message: "parent_key is forbidden on thread root messages"
11979
+ });
11980
+ }
11981
+ if (message.category !== "chat") {
11982
+ context.addIssue({
11983
+ code: external_exports.ZodIssueCode.custom,
11984
+ path: ["category"],
11985
+ message: "thread root messages must be chat messages"
11986
+ });
11987
+ }
11988
+ if (message.text !== `Thread: ${message.thread_root.topic}`) {
11989
+ context.addIssue({
11990
+ code: external_exports.ZodIssueCode.custom,
11991
+ path: ["text"],
11992
+ message: "thread root message text must identify its topic"
11993
+ });
11994
+ }
11995
+ const creator = message.thread_root.members.find(
11996
+ (member) => member.participant_id === message.thread_root?.creator_participant_id
11997
+ );
11998
+ if (creator?.identity !== message.author.identity) {
11999
+ context.addIssue({
12000
+ code: external_exports.ZodIssueCode.custom,
12001
+ path: ["thread_root", "creator_participant_id"],
12002
+ message: "thread root creator must match the message author"
12003
+ });
12004
+ }
12005
+ if (message.author_alias !== void 0 && message.author_alias.participant_id !== message.thread_root.creator_participant_id) {
12006
+ context.addIssue({
12007
+ code: external_exports.ZodIssueCode.custom,
12008
+ path: ["author_alias", "participant_id"],
12009
+ message: "thread root author alias must identify the creator"
12010
+ });
12011
+ }
12012
+ for (const field of ["source_msg_id", "source_wire_id", "source_reply_to"]) {
12013
+ if (message[field] !== void 0) {
12014
+ context.addIssue({
12015
+ code: external_exports.ZodIssueCode.custom,
12016
+ path: [field],
12017
+ message: `${field} is forbidden on thread root messages`
12018
+ });
12019
+ }
12020
+ }
12021
+ return;
12022
+ }
12023
+ if (message.scope === void 0) return;
12024
+ if (message.scope.parent_key === void 0) {
12025
+ context.addIssue({
12026
+ code: external_exports.ZodIssueCode.custom,
12027
+ path: ["scope", "parent_key"],
12028
+ message: "thread descendants require an immediate parent_key"
12029
+ });
12030
+ }
12031
+ if (message.scope.thread_id === message.message_id) {
12032
+ context.addIssue({
12033
+ code: external_exports.ZodIssueCode.custom,
12034
+ path: ["scope", "thread_id"],
12035
+ message: "thread descendant thread_id must identify a distinct root message"
12036
+ });
12037
+ }
12038
+ if (message.source_reply_to === void 0) {
12039
+ context.addIssue({
12040
+ code: external_exports.ZodIssueCode.custom,
12041
+ path: ["source_reply_to"],
12042
+ message: "thread descendants require source_reply_to"
12043
+ });
12044
+ }
12045
+ if (message.category !== "chat") {
12046
+ context.addIssue({
12047
+ code: external_exports.ZodIssueCode.custom,
12048
+ path: ["category"],
12049
+ message: "thread descendants must be chat messages"
12050
+ });
12051
+ }
12052
+ }
12053
+ 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, 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
12054
  var init_contracts = __esm({
11859
12055
  "src/contracts.ts"() {
11860
12056
  "use strict";
11861
12057
  init_zod();
11862
12058
  init_consumer_commands();
11863
12059
  init_command_names();
12060
+ init_thread_contracts();
11864
12061
  MAX_TEXT_BYTES = 262144;
11865
12062
  MAX_FILE_BYTES = 2 * 1024 * 1024;
11866
12063
  MAX_HISTORY_PAGE_BYTES = 3 * 1024 * 1024;
@@ -11877,10 +12074,10 @@ var init_contracts = __esm({
11877
12074
  /^[0-7][0-9a-hjkmnp-tv-z]{25}$/,
11878
12075
  "must be a 26-character lowercase Crockford ULID"
11879
12076
  );
11880
- ContainerIdSchema = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
12077
+ ContainerIdSchema2 = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
11881
12078
  RuntimeCommandNameSchema = external_exports.union([external_exports.enum(RUNTIME_COMMAND_NAMES), ConsumerCommandNameSchema]);
11882
12079
  RuntimeCommandGrantSchema = external_exports.object({
11883
- caller_cid: ContainerIdSchema,
12080
+ caller_cid: ContainerIdSchema2,
11884
12081
  command: RuntimeCommandNameSchema
11885
12082
  }).strict();
11886
12083
  Rfc3339Schema = external_exports.string().refine(isStrictRfc3339, "must be a valid RFC3339 timestamp");
@@ -12159,7 +12356,7 @@ var init_contracts = __esm({
12159
12356
  lifecycle_request: external_exports.object({
12160
12357
  request_id: external_exports.string().min(1).max(256),
12161
12358
  command: external_exports.enum(["room.close", "room.delete"]),
12162
- caller_cid: ContainerIdSchema,
12359
+ caller_cid: ContainerIdSchema2,
12163
12360
  accepted_at: Rfc3339Schema,
12164
12361
  state: external_exports.enum(["pending", "failed", "completed"]),
12165
12362
  error: external_exports.literal("lifecycle_failed").optional()
@@ -12310,7 +12507,7 @@ var init_contracts = __esm({
12310
12507
  (value) => Buffer.byteLength(value, "utf8") <= MAX_EXTERNAL_INVITE_BYTES,
12311
12508
  `invite input must be at most ${MAX_EXTERNAL_INVITE_BYTES} UTF-8 bytes`
12312
12509
  ),
12313
- expected_cid: ContainerIdSchema.optional()
12510
+ expected_cid: ContainerIdSchema2.optional()
12314
12511
  }).strict();
12315
12512
  ListMembersCommandInputSchema = external_exports.object({}).strict();
12316
12513
  CommandIdempotencyKeySchema = external_exports.string().regex(
@@ -12387,7 +12584,9 @@ var init_contracts = __esm({
12387
12584
  }),
12388
12585
  source_msg_id: external_exports.number().int().nonnegative().safe().optional(),
12389
12586
  source_wire_id: NonEmptyStringSchema.optional(),
12390
- source_reply_to: ReplyReferenceSchema.optional()
12587
+ source_reply_to: ReplyReferenceSchema.optional(),
12588
+ scope: ThreadScopeSchema.optional(),
12589
+ thread_root: ThreadRootSchema.optional()
12391
12590
  };
12392
12591
  RelayIntentShape = {
12393
12592
  kind: external_exports.literal("relay_intent"),
@@ -12395,7 +12594,12 @@ var init_contracts = __esm({
12395
12594
  file_id: LowerCrockfordUlidSchema.optional(),
12396
12595
  recipient_identity: NonEmptyStringSchema
12397
12596
  };
12398
- RelayResultStatusSchema = external_exports.enum(["queued", "send_failed", "skipped_removed"]);
12597
+ RelayResultStatusSchema = external_exports.enum([
12598
+ "queued",
12599
+ "send_failed",
12600
+ "skipped_removed",
12601
+ "skipped_reply_unavailable"
12602
+ ]);
12399
12603
  RelayResultShape = {
12400
12604
  kind: external_exports.literal("relay_result"),
12401
12605
  intent_record_id: NonEmptyStringSchema,
@@ -12433,6 +12637,18 @@ var init_contracts = __esm({
12433
12637
  source_wire_id: NonEmptyStringSchema.optional(),
12434
12638
  source_reply_to: ReplyReferenceSchema.optional()
12435
12639
  };
12640
+ IntakeRejectionShape = {
12641
+ kind: external_exports.literal("intake_rejection"),
12642
+ source_kind: external_exports.enum(["message", "file"]),
12643
+ source_msg_id: external_exports.number().int().nonnegative().safe().optional(),
12644
+ source_file_id: external_exports.number().int().nonnegative().safe().optional(),
12645
+ source_wire_id: NonEmptyStringSchema,
12646
+ sender_identity: NonEmptyStringSchema,
12647
+ sender_participant_id: LowerCrockfordUlidSchema,
12648
+ fingerprint: external_exports.string().regex(/^[0-9a-f]{64}$/),
12649
+ error: external_exports.enum(["reply_target_unavailable", "thread_files_unsupported"]),
12650
+ notification_attempt_claimed: external_exports.literal(true)
12651
+ };
12436
12652
  MembershipIntentShape = {
12437
12653
  kind: external_exports.literal("membership_intent"),
12438
12654
  action: external_exports.enum(["remove"]),
@@ -12470,6 +12686,7 @@ var init_contracts = __esm({
12470
12686
  FileRecordSchema = external_exports.object({ ...RecordCommonShape, ...FileShape }).strict();
12471
12687
  RelayIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...RelayIntentShape }).strict();
12472
12688
  RelayResultRecordSchema = external_exports.object({ ...RecordCommonShape, ...RelayResultShape }).strict();
12689
+ IntakeRejectionRecordSchema = external_exports.object({ ...RecordCommonShape, ...IntakeRejectionShape }).strict();
12473
12690
  MembershipIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...MembershipIntentShape }).strict();
12474
12691
  MembershipResultRecordSchema = external_exports.object({ ...RecordCommonShape, ...MembershipResultShape }).strict();
12475
12692
  CloseNoticeIntentRecordSchema = external_exports.object({ ...RecordCommonShape, ...CloseNoticeIntentShape }).strict();
@@ -12479,6 +12696,7 @@ var init_contracts = __esm({
12479
12696
  FileRecordSchema,
12480
12697
  RelayIntentRecordSchema,
12481
12698
  RelayResultRecordSchema,
12699
+ IntakeRejectionRecordSchema,
12482
12700
  MembershipIntentRecordSchema,
12483
12701
  MembershipResultRecordSchema,
12484
12702
  CloseNoticeIntentRecordSchema,
@@ -12492,8 +12710,12 @@ var init_contracts = __esm({
12492
12710
  message: 'record_id must equal room_id + ":" + seq'
12493
12711
  });
12494
12712
  }
12495
- if (record.kind === "message") refineMessageCategory(record, context);
12713
+ if (record.kind === "message") {
12714
+ refineMessageCategory(record, context);
12715
+ refineMessageThread(record, context);
12716
+ }
12496
12717
  refineRelaySubject(record, context);
12718
+ refineIntakeRejection(record, context);
12497
12719
  refineFileRecord(record, context);
12498
12720
  });
12499
12721
  AppendRecordSchema = external_exports.discriminatedUnion("kind", [
@@ -12501,11 +12723,16 @@ var init_contracts = __esm({
12501
12723
  external_exports.object({ ...AppendCommonShape, ...FileShape }).strict(),
12502
12724
  external_exports.object({ ...AppendCommonShape, ...RelayIntentShape }).strict(),
12503
12725
  external_exports.object({ ...AppendCommonShape, ...RelayResultShape }).strict(),
12726
+ external_exports.object({ ...AppendCommonShape, ...IntakeRejectionShape }).strict(),
12504
12727
  external_exports.object({ ...AppendCommonShape, ...CloseNoticeIntentShape }).strict(),
12505
12728
  external_exports.object({ ...AppendCommonShape, ...CloseNoticeResultShape }).strict()
12506
12729
  ]).superRefine((record, context) => {
12507
- if (record.kind === "message") refineMessageCategory(record, context);
12730
+ if (record.kind === "message") {
12731
+ refineMessageCategory(record, context);
12732
+ refineMessageThread(record, context);
12733
+ }
12508
12734
  refineRelaySubject(record, context);
12735
+ refineIntakeRejection(record, context);
12509
12736
  refineFileRecord(record, context);
12510
12737
  });
12511
12738
  }
@@ -13842,6 +14069,34 @@ var init_packets = __esm({
13842
14069
  this.runtimeHandlers = handlers;
13843
14070
  await this.runBound(() => this.client.registerCommands([
13844
14071
  ...handlers.consumerCommands ?? [],
14072
+ ...handlers.startThread === void 0 ? [] : [{
14073
+ name: "start_thread",
14074
+ description: "Create a scoped reply thread for an explicit set of room participant IDs.",
14075
+ input_schema: {
14076
+ type: "object",
14077
+ additionalProperties: false,
14078
+ required: ["topic", "participant_ids", "idempotency_key"],
14079
+ properties: {
14080
+ topic: {
14081
+ type: "string",
14082
+ minLength: 1,
14083
+ maxLength: 120,
14084
+ pattern: "^(?![\\s\\S]*[\\p{Cc}\\p{Cf}])[\\s\\S]*\\S[\\s\\S]*$"
14085
+ },
14086
+ participant_ids: {
14087
+ type: "array",
14088
+ minItems: 1,
14089
+ uniqueItems: true,
14090
+ items: { type: "string", pattern: "^[0-7][0-9a-hjkmnp-tv-z]{25}$" }
14091
+ },
14092
+ idempotency_key: {
14093
+ type: "string",
14094
+ pattern: "^[A-Za-z0-9._:-]{1,128}$"
14095
+ }
14096
+ }
14097
+ },
14098
+ handler: handlers.startThread
14099
+ }],
13845
14100
  {
13846
14101
  name: "list-members",
13847
14102
  description: "List the room roster using contact-safe member fields.",
@@ -13866,9 +14121,10 @@ var init_packets = __esm({
13866
14121
  ...handlers.sharedCommand === void 0 ? [] : SHARED_ROOM_COMMANDS.map((name) => {
13867
14122
  const doc = [...ROOM_RPC_METHODS, ...PRIVATE_ROOM_RPC_METHODS].find((method) => method.method === name);
13868
14123
  const { room_id: _roomId, ...properties } = doc.params.properties;
14124
+ if (name === "room.history") properties.view = { const: "participant" };
13869
14125
  return {
13870
14126
  name,
13871
- description: `${doc.description} Requires an explicit grant; applies only to this room.`,
14127
+ 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
14128
  input_schema: {
13873
14129
  ...doc.params,
13874
14130
  properties,
@@ -14011,6 +14267,22 @@ var init_ulid = __esm({
14011
14267
  });
14012
14268
 
14013
14269
  // src/reply-threading.ts
14270
+ function buildAliasIndex(rows, roomId) {
14271
+ const local = rows.filter((r) => r.room_id === roomId);
14272
+ const items = local.filter((r) => r.kind === "message" || r.kind === "file");
14273
+ const intents = local.filter((r) => r.kind === "relay_intent");
14274
+ const results = local.filter((r) => r.kind === "relay_result");
14275
+ const copies = (parent, cid) => results.filter((result) => {
14276
+ if (result.status !== "queued" || result.recipient_identity !== cid || key(result) !== key(parent) || !parent.recipient_identities.includes(cid)) return false;
14277
+ const matches = intents.filter((intent2) => intent2.record_id === result.intent_record_id);
14278
+ if (matches.length !== 1) return false;
14279
+ const intent = matches[0];
14280
+ return key(intent) === key(parent) && intent.recipient_identity === cid && parent.seq < intent.seq && intent.seq < result.seq;
14281
+ }).sort((a, b) => a.seq - b.seq);
14282
+ const wires = (result, parent) => [result.wire_id, ...parent.kind === "file" ? [result.metadata_wire_id] : []].filter(nonempty);
14283
+ 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)));
14284
+ return { items, copies, wires, ownersFor };
14285
+ }
14014
14286
  async function readReplyRows(store, roomId) {
14015
14287
  const rows = [];
14016
14288
  let after = 0;
@@ -14028,42 +14300,53 @@ async function readReplyRows(store, roomId) {
14028
14300
  after = last.seq;
14029
14301
  }
14030
14302
  }
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)));
14303
+ function resolveReplyParent(rows, roomId, senderCid, wireId, beforeSeq) {
14304
+ if (wireId === void 0) return { state: "none" };
14305
+ if (!nonempty(wireId)) return { state: "unknown_parent" };
14306
+ const index = buildAliasIndex(rows, roomId);
14307
+ const candidates = index.ownersFor(wireId, senderCid);
14049
14308
  if (candidates.length === 0) return { state: "unknown_parent" };
14050
14309
  if (candidates.length !== 1) return { state: "ambiguous_parent" };
14051
14310
  const parent = candidates[0];
14052
- if (parent.seq >= child.seq) return { state: "unknown_parent" };
14311
+ if (parent.seq >= beforeSeq) return { state: "unknown_parent" };
14053
14312
  const parentKey = key(parent);
14054
- if (items.filter((item) => key(item) === parentKey).length !== 1) {
14313
+ if (index.items.filter((item) => key(item) === parentKey).length !== 1) {
14055
14314
  return { state: "ambiguous_parent" };
14056
14315
  }
14057
- const recipientCopies = copies(parent, recipientCid);
14316
+ return { state: "resolved", parent: { key: parentKey, item: parent } };
14317
+ }
14318
+ function mapReplyParent(rows, roomId, logicalParent, recipientCid) {
14319
+ const index = buildAliasIndex(rows, roomId);
14320
+ if (logicalParent.item.room_id !== roomId || key(logicalParent.item) !== logicalParent.key) {
14321
+ return { state: "ambiguous_parent", parentKey: logicalParent.key };
14322
+ }
14323
+ const matches = index.items.filter((item) => key(item) === logicalParent.key);
14324
+ if (matches.length !== 1) {
14325
+ return { state: "ambiguous_parent", parentKey: logicalParent.key };
14326
+ }
14327
+ const parent = matches[0];
14328
+ const recipientCopies = index.copies(parent, recipientCid);
14058
14329
  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 };
14330
+ const sourceOwners = sourceWire === void 0 ? [] : index.ownersFor(sourceWire, recipientCid);
14331
+ const wireId = sourceOwners.length === 1 && key(sourceOwners[0]) === logicalParent.key ? sourceWire : recipientCopies.map((copy) => index.wires(copy, parent)[0]).find(nonempty);
14332
+ if (!nonempty(wireId)) return { state: "missing_copy", parentKey: logicalParent.key };
14333
+ const owners = index.ownersFor(wireId, recipientCid);
14334
+ if (owners.length !== 1 || key(owners[0]) !== logicalParent.key) {
14335
+ return { state: "ambiguous_parent", parentKey: logicalParent.key };
14065
14336
  }
14066
- return { state: "linked", parentKey, replyTo: { wire_id: wireId } };
14337
+ return { state: "linked", parentKey: logicalParent.key, replyTo: { wire_id: wireId } };
14338
+ }
14339
+ function selectReply(rows, roomId, child, recipientCid) {
14340
+ if (child.room_id !== roomId) throw new Error("reply child room mismatch");
14341
+ const resolution = resolveReplyParent(
14342
+ rows,
14343
+ roomId,
14344
+ child.author.identity,
14345
+ child.source_reply_to?.wire_id,
14346
+ child.seq
14347
+ );
14348
+ if (resolution.state !== "resolved") return resolution;
14349
+ return mapReplyParent(rows, roomId, resolution.parent, recipientCid);
14067
14350
  }
14068
14351
  var key, nonempty;
14069
14352
  var init_reply_threading = __esm({
@@ -14074,9 +14357,159 @@ var init_reply_threading = __esm({
14074
14357
  }
14075
14358
  });
14076
14359
 
14360
+ // src/threads.ts
14361
+ import { createHash as createHash3 } from "node:crypto";
14362
+ function publicThreadMetadata(root, room) {
14363
+ let creator;
14364
+ if (room.anonymous) {
14365
+ const alias = AuthorAliasSchema.safeParse(root.author_alias);
14366
+ if (!alias.success || alias.data.participant_id !== root.thread_root.creator_participant_id) {
14367
+ throw new ThreadFailure("reply_target_unavailable");
14368
+ }
14369
+ creator = {
14370
+ identity: alias.data.participant_id,
14371
+ display_name: alias.data.alias,
14372
+ role: root.author.role
14373
+ };
14374
+ } else {
14375
+ creator = {
14376
+ identity: root.author.identity,
14377
+ display_name: root.author.display_name,
14378
+ role: root.author.role
14379
+ };
14380
+ }
14381
+ return {
14382
+ schema_version: 1,
14383
+ thread_id: root.thread_root.thread_id,
14384
+ topic: root.thread_root.topic,
14385
+ creator,
14386
+ participant_ids: root.thread_root.members.map((member) => member.participant_id),
14387
+ created_at: root.at
14388
+ };
14389
+ }
14390
+ function selectThreadMembers(room, cid, input) {
14391
+ const selected = new Set(input.participant_ids);
14392
+ const active = room.seats.filter((seat) => seat.state === "active");
14393
+ const creator = active.find((seat) => seat.identity === cid);
14394
+ if (creator === void 0 || selected.size === 0 || selected.size !== input.participant_ids.length || selected.size > active.length || !selected.has(creator.participant_id)) {
14395
+ throw new ThreadFailure("invalid_members");
14396
+ }
14397
+ const members = active.filter((seat) => selected.has(seat.participant_id));
14398
+ if (members.length !== selected.size) throw new ThreadFailure("invalid_members");
14399
+ return members.map(({ participant_id, identity }) => ({ participant_id, identity })).sort((left, right) => left.participant_id.localeCompare(right.participant_id));
14400
+ }
14401
+ function activeThreadSeat(room, root, cid) {
14402
+ return room.seats.find((seat) => seat.state === "active" && seat.identity === cid && root.members.some((member) => member.identity === cid && member.participant_id === seat.participant_id));
14403
+ }
14404
+ function threadRelayEligible(room, root, cid) {
14405
+ return activeThreadSeat(room, root, cid) !== void 0;
14406
+ }
14407
+ function publicThreadAuthor(message, root, room) {
14408
+ const member = root.members.find((member2) => member2.identity === message.author.identity);
14409
+ if (!member) throw new ThreadFailure("reply_target_unavailable");
14410
+ if (!room.anonymous) return message.author;
14411
+ const alias = AuthorAliasSchema.safeParse(message.author_alias);
14412
+ if (!alias.success || alias.data.participant_id !== member.participant_id) {
14413
+ throw new ThreadFailure("reply_target_unavailable");
14414
+ }
14415
+ return { identity: alias.data.participant_id, display_name: alias.data.alias, role: message.author.role };
14416
+ }
14417
+ function threadFingerprint(input) {
14418
+ return createHash3("sha256").update(JSON.stringify({
14419
+ topic: input.topic,
14420
+ participant_ids: [...input.participant_ids].sort()
14421
+ })).digest("hex");
14422
+ }
14423
+ function findThreadRoot(rows, id) {
14424
+ 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));
14425
+ if (candidates.length === 0) return void 0;
14426
+ if (candidates.length !== 1) throw new ThreadFailure("reply_target_unavailable");
14427
+ const root = candidates[0];
14428
+ const parsedRoot = ThreadRootSchema.safeParse(root.thread_root);
14429
+ const parsedScope = ThreadScopeSchema.safeParse(root.scope);
14430
+ const parsedAlias = AuthorAliasSchema.safeParse(root.author_alias);
14431
+ const creator = parsedRoot.success ? parsedRoot.data.members.find((member) => member.participant_id === parsedRoot.data.creator_participant_id) : void 0;
14432
+ 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) {
14433
+ throw new ThreadFailure("reply_target_unavailable");
14434
+ }
14435
+ return root;
14436
+ }
14437
+ function classifyThreadAssociation(room, rows, source) {
14438
+ const chain = [];
14439
+ const seen = /* @__PURE__ */ new Set();
14440
+ let item = source;
14441
+ for (; ; ) {
14442
+ const key2 = item.kind === "message" ? `message:${item.message_id}` : `file:${item.file_id}`;
14443
+ if (item.room_id !== room.room_id || seen.has(key2) || chain.length > rows.length) {
14444
+ throw new ThreadFailure("reply_target_unavailable");
14445
+ }
14446
+ seen.add(key2);
14447
+ const resolved = resolveReplyParent(
14448
+ rows,
14449
+ room.room_id,
14450
+ item.author.identity,
14451
+ item.source_reply_to?.wire_id,
14452
+ item.seq
14453
+ );
14454
+ chain.push({ item, resolved });
14455
+ if (resolved.state !== "resolved") break;
14456
+ if (resolved.parent.item.seq >= item.seq) throw new ThreadFailure("reply_target_unavailable");
14457
+ item = resolved.parent.item;
14458
+ }
14459
+ let association = { state: "ordinary" };
14460
+ for (const { item: item2, resolved } of chain.reverse()) {
14461
+ const declared = item2.kind === "message" && (item2.scope !== void 0 || item2.thread_root !== void 0);
14462
+ if (!declared && association.state === "ordinary") continue;
14463
+ const scope = ThreadScopeSchema.safeParse(item2.kind === "message" ? item2.scope : void 0);
14464
+ 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) {
14465
+ throw new ThreadFailure("reply_target_unavailable");
14466
+ }
14467
+ const root = findThreadRoot(rows.filter((row) => row.room_id === room.room_id), scope.data.thread_id);
14468
+ if (!root?.thread_root) throw new ThreadFailure("reply_target_unavailable");
14469
+ publicThreadMetadata({ ...root, thread_root: root.thread_root }, room);
14470
+ publicThreadAuthor(item2, root.thread_root, room);
14471
+ if (item2.message_id !== root.message_id) {
14472
+ 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) {
14473
+ throw new ThreadFailure("reply_target_unavailable");
14474
+ }
14475
+ }
14476
+ association = { state: "scoped", root: { ...root, thread_root: root.thread_root }, scope: scope.data };
14477
+ }
14478
+ return association;
14479
+ }
14480
+ function resolveIntakeScope(room, rows, item, beforeSeq) {
14481
+ const ordinary = () => ({ recipients: [...new Set(room.seats.filter((seat) => seat.state === "active" && seat.identity !== item.sender_id).map((seat) => seat.identity))] });
14482
+ if (item.reply_to == null) return ordinary();
14483
+ const reply = item.reply_to;
14484
+ 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")) {
14485
+ throw new ThreadFailure("reply_target_unavailable");
14486
+ }
14487
+ const resolved = resolveReplyParent(rows, room.room_id, item.sender_id, reply.wire_id, beforeSeq);
14488
+ if (resolved.state !== "resolved") throw new ThreadFailure("reply_target_unavailable");
14489
+ const association = classifyThreadAssociation(room, rows, resolved.parent.item);
14490
+ if (association.state === "ordinary") return ordinary();
14491
+ const { root } = association;
14492
+ if (!activeThreadSeat(room, root.thread_root, item.sender_id)) {
14493
+ throw new ThreadFailure("reply_target_unavailable");
14494
+ }
14495
+ if ("file_id" in item) throw new ThreadFailure("thread_files_unsupported");
14496
+ return {
14497
+ 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),
14498
+ scope: { thread_id: root.message_id, parent_key: resolved.parent.key }
14499
+ };
14500
+ }
14501
+ var init_threads = __esm({
14502
+ "src/threads.ts"() {
14503
+ "use strict";
14504
+ init_reply_threading();
14505
+ init_contracts();
14506
+ init_thread_contracts();
14507
+ }
14508
+ });
14509
+
14077
14510
  // src/intake.ts
14078
14511
  import { AsyncLocalStorage } from "node:async_hooks";
14079
- import { createHash as createHash3 } from "node:crypto";
14512
+ import { createHash as createHash4 } from "node:crypto";
14080
14513
  function canonicalJson(value) {
14081
14514
  const encoded = JSON.stringify(canonicalValue(value));
14082
14515
  if (encoded === void 0) throw new TypeError("canonical JSON value is not serializable");
@@ -14091,13 +14524,25 @@ function sameReply2(stored, observed) {
14091
14524
  }
14092
14525
  async function queryStore(store, roomId, options) {
14093
14526
  if (store.query) return store.query(roomId, options);
14094
- let records = await store.read(roomId);
14095
- records = records.filter((record) => {
14527
+ const archive = [];
14528
+ let after = 0;
14529
+ for (; ; ) {
14530
+ const page = await store.read(roomId, { after, limit: JOURNAL_WORK_BATCH_SIZE });
14531
+ if (page.length === 0) break;
14532
+ for (const row of page) {
14533
+ if (row.room_id !== roomId || !Number.isSafeInteger(row.seq) || row.seq <= after) {
14534
+ throw new Error("intake archive cursor did not advance");
14535
+ }
14536
+ archive.push(row);
14537
+ after = row.seq;
14538
+ }
14539
+ }
14540
+ let records = archive.filter((record) => {
14096
14541
  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);
14542
+ 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
14543
  });
14099
14544
  if (options.unresolvedResultKind) {
14100
- const completed = new Set((await store.read(roomId)).filter((record) => record.kind === options.unresolvedResultKind).map((record) => record.intent_record_id));
14545
+ const completed = new Set(archive.filter((record) => record.kind === options.unresolvedResultKind).map((record) => record.intent_record_id));
14101
14546
  records = records.filter((record) => !completed.has(record.record_id));
14102
14547
  }
14103
14548
  if (options.descending) records.reverse();
@@ -14115,8 +14560,20 @@ function canonicalValue(value) {
14115
14560
  }
14116
14561
  return value;
14117
14562
  }
14118
- function unique(values) {
14119
- return [...new Set(values)];
14563
+ function inputFingerprint(item) {
14564
+ return createHash4("sha256").update(canonicalJson({
14565
+ sender: item.sender_id,
14566
+ wire: item.wire_id,
14567
+ date: item.date,
14568
+ reply: item.reply_to ?? null,
14569
+ ..."file_id" in item ? {
14570
+ kind: "file",
14571
+ id: item.file_id,
14572
+ filename: item.filename,
14573
+ mime: item.mime,
14574
+ sha256: createHash4("sha256").update(item.data).digest("hex")
14575
+ } : { kind: "message", id: item.msg_id, text: item.text }
14576
+ })).digest("hex");
14120
14577
  }
14121
14578
  function wireKind(category) {
14122
14579
  switch (category) {
@@ -14138,6 +14595,8 @@ var init_intake = __esm({
14138
14595
  init_contracts();
14139
14596
  init_ulid();
14140
14597
  init_reply_threading();
14598
+ init_threads();
14599
+ init_thread_contracts();
14141
14600
  JOURNAL_WORK_BATCH_SIZE = 64;
14142
14601
  INTAKE_BATCH_SIZE = 32;
14143
14602
  IntakePump = class {
@@ -14282,28 +14741,36 @@ var init_intake = __esm({
14282
14741
  });
14283
14742
  }
14284
14743
  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
14744
  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);
14745
+ const [stored] = await queryStore(this.store, roomId, { sourceFileId: item.file_id, limit: 1 });
14746
+ if (this.isRejectedReplay(stored, item)) {
14300
14747
  await packet.acknowledgeFile(item);
14301
14748
  return;
14302
14749
  }
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);
14750
+ let file = this.findSourceFile(stored === void 0 ? [] : [stored], item);
14305
14751
  if (!file) {
14306
- const recipientIdentities = unique(room.seats.filter((recipient) => recipient.state === "active").map((recipient) => recipient.identity).filter((identity) => identity !== seat.identity));
14752
+ const seat = room.seats.find((candidate) => candidate.identity === item.sender_id && candidate.state === "active");
14753
+ const known = room.seats.some((candidate) => candidate.identity === item.sender_id);
14754
+ if (!known || item.reply_to == null && (room.state !== "active" || !seat)) {
14755
+ if (room.state === "active") await this.bounceRemovedSender(roomId, room, packet, item);
14756
+ await packet.acknowledgeFile(item);
14757
+ return;
14758
+ }
14759
+ const disposition = await this.freshScopeUnlocked(room, item);
14760
+ if (!disposition) {
14761
+ await packet.acknowledgeFile(item);
14762
+ return;
14763
+ }
14764
+ if (!seat) throw new Error("authorized file sender has no active seat");
14765
+ const parsedName = FileNameSchema.safeParse(item.filename);
14766
+ const parsedMime = FileMimeSchema.safeParse(item.mime);
14767
+ if (!parsedName.success || !parsedMime.success) {
14768
+ await packet.acknowledgeFile(item);
14769
+ return;
14770
+ }
14771
+ if (item.data.length > MAX_FILE_BYTES) {
14772
+ throw new RangeError(`room files must be at most ${MAX_FILE_BYTES} bytes (2 MiB)`);
14773
+ }
14307
14774
  const bytes = Buffer.from(item.data);
14308
14775
  const appended = await this.store.append(roomId, {
14309
14776
  version: 1,
@@ -14316,9 +14783,9 @@ var init_intake = __esm({
14316
14783
  filename: parsedName.data,
14317
14784
  mime: parsedMime.data,
14318
14785
  size: bytes.length,
14319
- sha256: createHash3("sha256").update(bytes).digest("hex"),
14786
+ sha256: createHash4("sha256").update(bytes).digest("hex"),
14320
14787
  data_base64: bytes.toString("base64"),
14321
- recipient_identities: recipientIdentities,
14788
+ recipient_identities: disposition.recipients,
14322
14789
  source_file_id: item.file_id,
14323
14790
  ...item.wire_id === "" ? {} : { source_wire_id: item.wire_id },
14324
14791
  ...item.reply_to == null ? {} : { source_reply_to: item.reply_to }
@@ -14331,18 +14798,26 @@ var init_intake = __esm({
14331
14798
  }
14332
14799
  async processInboxItem(roomId, packet, item, acknowledge = true) {
14333
14800
  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);
14801
+ const [stored] = await queryStore(this.store, roomId, { sourceMsgId: item.msg_id, limit: 1 });
14802
+ if (this.isRejectedReplay(stored, item)) {
14339
14803
  if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14340
14804
  return;
14341
14805
  }
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);
14806
+ let message = this.findSourceMessage(stored === void 0 ? [] : [stored], item);
14344
14807
  if (!message) {
14345
- const recipientIdentities = unique(room.seats.filter((recipient) => recipient.state === "active").map((recipient) => recipient.identity).filter((identity) => identity !== seat.identity));
14808
+ const seat = room.seats.find((candidate) => candidate.identity === item.sender_id && candidate.state === "active");
14809
+ const known = room.seats.some((candidate) => candidate.identity === item.sender_id);
14810
+ if (!known || item.reply_to == null && (room.state !== "active" || !seat)) {
14811
+ if (room.state === "active") await this.bounceRemovedSender(roomId, room, packet, item);
14812
+ if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14813
+ return;
14814
+ }
14815
+ const disposition = await this.freshScopeUnlocked(room, item);
14816
+ if (!disposition) {
14817
+ if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14818
+ return;
14819
+ }
14820
+ if (!seat) throw new Error("authorized message sender has no active seat");
14346
14821
  const appended = await this.store.append(roomId, {
14347
14822
  version: 1,
14348
14823
  kind: "message",
@@ -14359,7 +14834,8 @@ var init_intake = __esm({
14359
14834
  ...room.anonymous && seat.alias !== void 0 ? { author_alias: { participant_id: seat.participant_id, alias: seat.alias } } : {},
14360
14835
  category: "chat",
14361
14836
  text: item.text,
14362
- recipient_identities: recipientIdentities,
14837
+ recipient_identities: disposition.recipients,
14838
+ ...disposition.scope === void 0 ? {} : { scope: disposition.scope },
14363
14839
  source_msg_id: item.msg_id,
14364
14840
  ...item.wire_id === "" ? {} : { source_wire_id: item.wire_id },
14365
14841
  ...item.reply_to == null ? {} : { source_reply_to: item.reply_to }
@@ -14370,6 +14846,78 @@ var init_intake = __esm({
14370
14846
  await this.completeMessageIntents(roomId, message);
14371
14847
  if (acknowledge) await this.acknowledgeMessage(roomId, packet, item);
14372
14848
  }
14849
+ async freshScopeUnlocked(room, item) {
14850
+ try {
14851
+ const rows = item.reply_to == null ? [] : await readReplyRows(this.store, room.room_id);
14852
+ const beforeSeq = item.reply_to == null ? 1 : await this.nextRecordSeq(room.room_id);
14853
+ return resolveIntakeScope(room, rows, item, beforeSeq);
14854
+ } catch (error) {
14855
+ if (!(error instanceof ThreadFailure)) throw error;
14856
+ await this.recordRejectionUnlocked(room, item, error);
14857
+ return void 0;
14858
+ }
14859
+ }
14860
+ async nextRecordSeq(roomId) {
14861
+ if (this.store.query) {
14862
+ const [last] = await this.store.query(roomId, { descending: true, limit: 1 });
14863
+ if (last && (last.room_id !== roomId || !Number.isSafeInteger(last.seq) || last.seq < 1)) {
14864
+ throw new Error("intake archive tail is invalid");
14865
+ }
14866
+ return (last?.seq ?? 0) + 1;
14867
+ }
14868
+ let after = 0;
14869
+ for (; ; ) {
14870
+ const page = await this.store.read(roomId, { after, limit: JOURNAL_WORK_BATCH_SIZE });
14871
+ if (page.length === 0) return after + 1;
14872
+ for (const row of page) {
14873
+ if (row.room_id !== roomId || !Number.isSafeInteger(row.seq) || row.seq <= after) {
14874
+ throw new Error("intake archive cursor did not advance");
14875
+ }
14876
+ after = row.seq;
14877
+ }
14878
+ }
14879
+ }
14880
+ isRejectedReplay(record, item) {
14881
+ if (record?.kind !== "intake_rejection") return false;
14882
+ const file = "file_id" in item;
14883
+ 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)) {
14884
+ throw new Error("inbox source does not match its durable intake rejection");
14885
+ }
14886
+ return true;
14887
+ }
14888
+ async recordRejectionUnlocked(room, item, error) {
14889
+ const seat = room.seats.find((seat2) => seat2.identity === item.sender_id && seat2.state === "active") ?? room.seats.find((seat2) => seat2.identity === item.sender_id);
14890
+ if (!seat || typeof item.wire_id !== "string" || item.wire_id.length === 0) {
14891
+ throw new Error("intake rejection requires a known seat and source wire");
14892
+ }
14893
+ if (error.code !== "reply_target_unavailable" && error.code !== "thread_files_unsupported") throw error;
14894
+ await this.store.append(room.room_id, {
14895
+ version: 1,
14896
+ kind: "intake_rejection",
14897
+ room_id: room.room_id,
14898
+ at: this.now(),
14899
+ ..."file_id" in item ? { source_kind: "file", source_file_id: item.file_id } : { source_kind: "message", source_msg_id: item.msg_id },
14900
+ source_wire_id: item.wire_id,
14901
+ sender_identity: item.sender_id,
14902
+ sender_participant_id: seat.participant_id,
14903
+ fingerprint: inputFingerprint(item),
14904
+ error: error.code,
14905
+ notification_attempt_claimed: true
14906
+ });
14907
+ try {
14908
+ await sendRoomBody(this.packet(room.room_id), item.sender_id, {
14909
+ version: 1,
14910
+ kind: "room_msg",
14911
+ room_id: room.room_id,
14912
+ room_name: room.room_name,
14913
+ message_id: this.nextMessageId(),
14914
+ at: this.now(),
14915
+ text: error.code,
14916
+ author: { identity: room.identity_cid, display_name: room.identity_name, role: ROOM_ROLE }
14917
+ });
14918
+ } catch {
14919
+ }
14920
+ }
14373
14921
  acknowledgeMessage(roomId, packet, expected) {
14374
14922
  return packet.acknowledgeMessage(
14375
14923
  expected,
@@ -14493,26 +15041,44 @@ var init_intake = __esm({
14493
15041
  const [message] = intent.message_id === void 0 ? [] : await queryStore(this.store, roomId, { kind: "message", messageId: intent.message_id, limit: 1 });
14494
15042
  const [file] = intent.file_id === void 0 ? [] : await queryStore(this.store, roomId, { kind: "file", fileId: intent.file_id, limit: 1 });
14495
15043
  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
15044
  const source = message ?? file;
14514
- const replyRows = source.source_reply_to === void 0 ? [] : await readReplyRows(this.store, roomId);
15045
+ const replyRows = source.source_reply_to === void 0 && message?.scope === void 0 && message?.thread_root === void 0 ? [] : await readReplyRows(this.store, roomId);
14515
15046
  const decision = selectReply(replyRows, roomId, source, intent.recipient_identity);
15047
+ let publicThread = {};
15048
+ let scopedAuthor;
15049
+ try {
15050
+ const association = classifyThreadAssociation(room, replyRows, source);
15051
+ if (association.state === "scoped") {
15052
+ const { root, scope } = association;
15053
+ if (!message || !message.recipient_identities.includes(intent.recipient_identity) || message.seq >= intent.seq || !root.thread_root.members.some((member) => member.identity === intent.recipient_identity)) {
15054
+ throw new ThreadFailure("reply_target_unavailable");
15055
+ }
15056
+ if (!threadRelayEligible(room, root.thread_root, intent.recipient_identity)) {
15057
+ await this.skipRelay(roomId, intent, "skipped_removed");
15058
+ continue;
15059
+ }
15060
+ const metadata = publicThreadMetadata({ ...root, thread_root: root.thread_root }, room);
15061
+ scopedAuthor = publicThreadAuthor(message, root.thread_root, room);
15062
+ if (message.message_id === root.message_id) {
15063
+ publicThread = { thread: { schema_version: 1, thread_id: root.message_id }, thread_root: metadata };
15064
+ } else {
15065
+ if (decision.state !== "linked" || decision.parentKey !== scope.parent_key) {
15066
+ throw new ThreadFailure("reply_target_unavailable");
15067
+ }
15068
+ publicThread = { thread: { schema_version: 1, thread_id: root.message_id } };
15069
+ }
15070
+ } else {
15071
+ if (!source.recipient_identities.includes(intent.recipient_identity)) continue;
15072
+ if (!activeCids.has(intent.recipient_identity) && removedCids.has(intent.recipient_identity)) {
15073
+ await this.skipRelay(roomId, intent, "skipped_removed");
15074
+ continue;
15075
+ }
15076
+ }
15077
+ } catch (error) {
15078
+ if (!(error instanceof ThreadFailure)) throw error;
15079
+ await this.skipRelay(roomId, intent, "skipped_reply_unavailable");
15080
+ continue;
15081
+ }
14516
15082
  const replyTo = decision.replyTo;
14517
15083
  if (file !== void 0) {
14518
15084
  const uploader = file.author_alias?.alias ?? file.author.display_name;
@@ -14573,11 +15139,12 @@ var init_intake = __esm({
14573
15139
  room_name: room.room_name,
14574
15140
  message_id: message.message_id,
14575
15141
  // An anonymous author leaves the archive only in alias form.
14576
- author: message.author_alias === void 0 ? message.author : {
15142
+ author: scopedAuthor ?? (message.author_alias === void 0 ? message.author : {
14577
15143
  identity: message.author_alias.participant_id,
14578
15144
  display_name: message.author_alias.alias,
14579
15145
  role: message.author.role
14580
- },
15146
+ }),
15147
+ ...publicThread,
14581
15148
  text: message.text,
14582
15149
  at: message.at,
14583
15150
  ...message.briefing_role === void 0 ? {} : { briefing_role: message.briefing_role },
@@ -14600,6 +15167,20 @@ var init_intake = __esm({
14600
15167
  }
14601
15168
  }
14602
15169
  }
15170
+ async skipRelay(roomId, intent, status) {
15171
+ const result = await this.store.append(roomId, {
15172
+ version: 1,
15173
+ kind: "relay_result",
15174
+ room_id: roomId,
15175
+ at: this.now(),
15176
+ intent_record_id: intent.record_id,
15177
+ ...intent.message_id === void 0 ? {} : { message_id: intent.message_id },
15178
+ ...intent.file_id === void 0 ? {} : { file_id: intent.file_id },
15179
+ recipient_identity: intent.recipient_identity,
15180
+ status
15181
+ });
15182
+ if (result.kind !== "relay_result") throw new Error("storage returned the wrong relay result kind");
15183
+ }
14603
15184
  findSourceMessage(records, item) {
14604
15185
  const message = records.find((record) => record.kind === "message" && record.source_msg_id === item.msg_id);
14605
15186
  if (!message) return void 0;
@@ -14793,7 +15374,7 @@ var init_command_routes = __esm({
14793
15374
  }).strict();
14794
15375
  RuntimeCommandGrantParams = external_exports.object({
14795
15376
  room_id: external_exports.string(),
14796
- caller_cid: ContainerIdSchema,
15377
+ caller_cid: ContainerIdSchema2,
14797
15378
  command: RuntimeCommandNameSchema
14798
15379
  }).strict();
14799
15380
  RuntimeRoleCommandGrantParams = external_exports.object({
@@ -14815,9 +15396,132 @@ var init_command_routes = __esm({
14815
15396
  }
14816
15397
  });
14817
15398
 
15399
+ // src/thread-history.ts
15400
+ function publicHistoryMessage(record, author = record.author) {
15401
+ return {
15402
+ version: 1,
15403
+ room_id: record.room_id,
15404
+ seq: record.seq,
15405
+ record_id: record.record_id,
15406
+ at: record.at,
15407
+ kind: "message",
15408
+ message_id: record.message_id,
15409
+ author: { identity: author.identity, display_name: author.display_name, role: author.role },
15410
+ category: record.category,
15411
+ text: record.text,
15412
+ ...record.category === "role_briefing" ? { briefing_role: record.briefing_role } : {},
15413
+ ...(record.category === "briefing" || record.category === "role_briefing") && record.briefing_version !== void 0 ? { briefing_version: record.briefing_version } : {},
15414
+ ...record.category === "membership" && record.membership ? { membership: {
15415
+ action: record.membership.action,
15416
+ epoch: record.membership.epoch,
15417
+ ...record.membership.alias === void 0 ? {} : { alias: record.membership.alias },
15418
+ ...record.membership.role === void 0 ? {} : { role: record.membership.role }
15419
+ } } : {}
15420
+ };
15421
+ }
15422
+ function projectParticipantHistory(room, records, viewerCid, page) {
15423
+ const viewer = room.seats.find((seat) => seat.state === "active" && seat.identity === viewerCid);
15424
+ if (!viewer || room.state !== "active") throw new ThreadFailure("unauthorized");
15425
+ const { after = 0, limit = 200 } = ParticipantHistoryPageSchema.parse(page);
15426
+ const replyRows = records.filter((row) => ["message", "file", "relay_intent", "relay_result"].includes(row.kind));
15427
+ const output = [];
15428
+ let ordinal = 0, bytes = 2;
15429
+ for (const record of records) {
15430
+ if (record.kind !== "message" || record.room_id !== room.room_id) continue;
15431
+ let author = record.author;
15432
+ let thread;
15433
+ let thread_root;
15434
+ try {
15435
+ const association = classifyThreadAssociation(room, replyRows, record);
15436
+ if (association.state === "scoped") {
15437
+ const { root } = association;
15438
+ if (!activeThreadSeat(room, root.thread_root, viewerCid)) continue;
15439
+ const metadata = publicThreadMetadata(root, room);
15440
+ author = publicThreadAuthor(record, root.thread_root, room);
15441
+ if (record.message_id === root.message_id) thread_root = metadata;
15442
+ thread = { schema_version: 1, thread_id: root.message_id };
15443
+ } else if (record.author_alias !== void 0) {
15444
+ const alias = AuthorAliasSchema.parse(record.author_alias);
15445
+ author = { identity: alias.participant_id, display_name: alias.alias, role: record.author.role };
15446
+ }
15447
+ } catch (error) {
15448
+ if (error instanceof ThreadFailure || error instanceof external_exports.ZodError) continue;
15449
+ throw error;
15450
+ }
15451
+ ordinal += 1;
15452
+ if (ordinal <= after) continue;
15453
+ const projected = {
15454
+ ...publicHistoryMessage(record, author),
15455
+ seq: ordinal,
15456
+ record_id: `${room.room_id}:participant:${viewer.participant_id}:${ordinal}`,
15457
+ ...thread ? { thread } : {},
15458
+ ...thread_root ? { thread_root } : {}
15459
+ };
15460
+ const size = Buffer.byteLength(JSON.stringify(projected), "utf8") + (output.length ? 1 : 0);
15461
+ if (bytes + size > MAX_HISTORY_PAGE_BYTES) {
15462
+ if (output.length === 0) throw new RangeError("one participant history record exceeds the page byte contract");
15463
+ break;
15464
+ }
15465
+ output.push(projected);
15466
+ bytes += size;
15467
+ if (output.length >= limit) break;
15468
+ }
15469
+ return output;
15470
+ }
15471
+ var PublicThreadSchema, PublicThreadMetadataSchema, PublicMessageShape, ParticipantHistoryRecordSchema, ParticipantHistoryPageSchema;
15472
+ var init_thread_history = __esm({
15473
+ "src/thread-history.ts"() {
15474
+ "use strict";
15475
+ init_zod();
15476
+ init_contracts();
15477
+ init_thread_contracts();
15478
+ init_threads();
15479
+ PublicThreadSchema = external_exports.object({ schema_version: external_exports.literal(1), thread_id: LowerCrockfordUlidSchema }).strict();
15480
+ PublicThreadMetadataSchema = PublicThreadSchema.extend({
15481
+ topic: external_exports.string(),
15482
+ creator: AuthorSnapshotSchema,
15483
+ participant_ids: external_exports.array(LowerCrockfordUlidSchema),
15484
+ created_at: Rfc3339Schema
15485
+ }).strict();
15486
+ PublicMessageShape = {
15487
+ version: external_exports.literal(1),
15488
+ room_id: LowerCrockfordUlidSchema,
15489
+ seq: external_exports.number().int().positive().safe(),
15490
+ record_id: external_exports.string(),
15491
+ at: Rfc3339Schema,
15492
+ kind: external_exports.literal("message"),
15493
+ message_id: LowerCrockfordUlidSchema,
15494
+ author: AuthorSnapshotSchema,
15495
+ category: external_exports.enum(["briefing", "role_briefing", "chat", "membership"]),
15496
+ briefing_role: RoleSchema.optional(),
15497
+ briefing_version: external_exports.number().int().positive().safe().optional(),
15498
+ membership: MembershipNoticeSchema.optional(),
15499
+ text: MessageTextSchema
15500
+ };
15501
+ ParticipantHistoryRecordSchema = external_exports.object({
15502
+ ...PublicMessageShape,
15503
+ thread: PublicThreadSchema.optional(),
15504
+ thread_root: PublicThreadMetadataSchema.optional()
15505
+ }).strict().superRefine((row, ctx) => {
15506
+ const prefix = `${row.room_id}:participant:`;
15507
+ const participantId = row.record_id.slice(prefix.length, -(String(row.seq).length + 1));
15508
+ if (!row.record_id.startsWith(prefix) || !LowerCrockfordUlidSchema.safeParse(participantId).success || row.record_id !== `${prefix}${participantId}:${row.seq}`) {
15509
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["record_id"], message: "must identify the viewer and visible ordinal" });
15510
+ }
15511
+ if (row.thread_root && (!row.thread || row.thread.thread_id !== row.thread_root.thread_id || row.message_id !== row.thread.thread_id)) {
15512
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["thread_root"], message: "must identify this thread root" });
15513
+ }
15514
+ });
15515
+ ParticipantHistoryPageSchema = external_exports.object({
15516
+ after: external_exports.number().int().nonnegative().safe().optional(),
15517
+ limit: external_exports.number().int().positive().safe().optional()
15518
+ }).strict();
15519
+ }
15520
+ });
15521
+
14818
15522
  // src/service.ts
14819
15523
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
14820
- import { createHash as createHash4 } from "node:crypto";
15524
+ import { createHash as createHash5 } from "node:crypto";
14821
15525
  function byteBoundedHistoryPage(records) {
14822
15526
  const page = [];
14823
15527
  let bytes = 2;
@@ -14874,7 +15578,7 @@ function uniqueIdentities(identities) {
14874
15578
  function currentContactIdentities(packet) {
14875
15579
  return new Set(packet.listContacts().map((contact) => contact.container_id));
14876
15580
  }
14877
- var CreateInviteInputSchema, HistoryOptionsSchema, JOURNAL_WORK_BATCH_SIZE2, DeleteRoomInputSchema, RemoveParticipantInputSchema, RoomServiceError, RoomService;
15581
+ var MAX_ROOM_MESSAGE_BYTES, CreateInviteInputSchema, HistoryOptionsSchema, JOURNAL_WORK_BATCH_SIZE2, DeleteRoomInputSchema, RemoveParticipantInputSchema, RoomServiceError, RoomService;
14878
15582
  var init_service = __esm({
14879
15583
  "src/service.ts"() {
14880
15584
  "use strict";
@@ -14886,6 +15590,11 @@ var init_service = __esm({
14886
15590
  init_consumer_commands();
14887
15591
  init_command_names();
14888
15592
  init_ulid();
15593
+ init_reply_threading();
15594
+ init_thread_contracts();
15595
+ init_thread_history();
15596
+ init_threads();
15597
+ MAX_ROOM_MESSAGE_BYTES = 262144;
14889
15598
  CreateInviteInputSchema = external_exports.object({
14890
15599
  mode: InviteModeSchema,
14891
15600
  role: RoleSchema.optional(),
@@ -15105,11 +15814,106 @@ var init_service = __esm({
15105
15814
  handler: (input, context) => this.invokeConsumerCommand(roomId, definition.name, input, context)
15106
15815
  })),
15107
15816
  sharedCommand: (name, input, context) => this.lock(roomId, () => this.sharedCommandUnlocked(roomId, name, input, context)),
15817
+ startThread: async (input, context) => {
15818
+ const result = await this.lock(roomId, () => this.startThreadCommandUnlocked(roomId, input, context));
15819
+ if (result.ok === true) await this.intake.resumePending(roomId);
15820
+ return result;
15821
+ },
15108
15822
  listMembers: (input, context) => this.lock(roomId, () => this.listMembersCommandUnlocked(roomId, input, context)),
15109
15823
  removeMember: (input, context) => this.lock(roomId, () => this.removeMemberCommandUnlocked(roomId, input, context))
15110
15824
  });
15111
15825
  this.publishedConsumerRevisions.set(roomId, registered.consumer_commands_revision ?? 0);
15112
15826
  }
15827
+ /** Called by the registered adapter while it owns the room mutex. */
15828
+ async startThreadCommandUnlocked(roomId, input, context) {
15829
+ const room = await this.store.load(roomId);
15830
+ if (room.state !== "active" || room.lifecycle_request?.state === "pending") {
15831
+ return { ok: false, error: "room_unavailable" };
15832
+ }
15833
+ const creator = room.seats.find((seat) => seat.state === "active" && seat.identity === context.sender_cid);
15834
+ if (creator === void 0 || !this.hasRuntimeCommandGrant(room, context.sender_cid, "start_thread")) {
15835
+ return { ok: false, error: "unauthorized" };
15836
+ }
15837
+ const parsed = StartThreadInputSchema.safeParse(input);
15838
+ if (!parsed.success) return { ok: false, error: "invalid_request" };
15839
+ const request = parsed.data;
15840
+ const rows = await readReplyRows(this.store, roomId);
15841
+ 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);
15842
+ if (prior.length > 1) throw new Error("duplicate thread roots for creator idempotency key");
15843
+ if (prior.length === 1) {
15844
+ const root2 = prior[0];
15845
+ if (root2.thread_root.creator_participant_id !== creator.participant_id) {
15846
+ return { ok: false, error: "unauthorized" };
15847
+ }
15848
+ if (root2.thread_root.fingerprint !== threadFingerprint(request)) {
15849
+ return { ok: false, error: "idempotency_conflict" };
15850
+ }
15851
+ return { ok: true, thread_id: root2.message_id, status: "accepted" };
15852
+ }
15853
+ let members;
15854
+ try {
15855
+ members = selectThreadMembers(room, context.sender_cid, request);
15856
+ } catch (error) {
15857
+ if (error instanceof ThreadFailure) return { ok: false, error: error.code };
15858
+ throw error;
15859
+ }
15860
+ const threadId = LowerCrockfordUlidSchema.parse(this.nextMessageId());
15861
+ const at = this.now();
15862
+ const threadRoot = {
15863
+ schema_version: 1,
15864
+ thread_id: threadId,
15865
+ topic: request.topic,
15866
+ creator_participant_id: creator.participant_id,
15867
+ members,
15868
+ idempotency_key: request.idempotency_key,
15869
+ fingerprint: threadFingerprint(request)
15870
+ };
15871
+ if (room.anonymous && creator.alias === void 0) {
15872
+ throw new Error("anonymous thread creator is missing its room alias");
15873
+ }
15874
+ const root = {
15875
+ version: 1,
15876
+ kind: "message",
15877
+ room_id: roomId,
15878
+ at,
15879
+ message_id: threadId,
15880
+ author: {
15881
+ identity: creator.identity,
15882
+ display_name: creator.display_name,
15883
+ role: creator.role
15884
+ },
15885
+ ...room.anonymous ? {
15886
+ author_alias: { participant_id: creator.participant_id, alias: creator.alias }
15887
+ } : {},
15888
+ category: "chat",
15889
+ text: `Thread: ${request.topic}`,
15890
+ recipient_identities: members.map((member) => member.identity),
15891
+ scope: { thread_id: threadId },
15892
+ thread_root: threadRoot
15893
+ };
15894
+ const projected = {
15895
+ version: 1,
15896
+ kind: "room_msg",
15897
+ room_id: roomId,
15898
+ room_name: room.room_name,
15899
+ message_id: threadId,
15900
+ author: room.anonymous ? {
15901
+ identity: creator.participant_id,
15902
+ display_name: creator.alias,
15903
+ role: creator.role
15904
+ } : root.author,
15905
+ text: root.text,
15906
+ at,
15907
+ thread: { schema_version: 1, thread_id: threadId },
15908
+ thread_root: publicThreadMetadata(root, room)
15909
+ };
15910
+ if (Buffer.byteLength(JSON.stringify(projected), "utf8") > MAX_ROOM_MESSAGE_BYTES) {
15911
+ return { ok: false, error: "invalid_request" };
15912
+ }
15913
+ const appended = await this.store.append(roomId, root);
15914
+ if (appended.kind !== "message") throw new Error("storage returned the wrong thread root kind");
15915
+ return { ok: true, thread_id: appended.message_id, status: "accepted" };
15916
+ }
15113
15917
  /** The SDK supplies authenticated context; arguments never select another room. */
15114
15918
  async sharedCommandUnlocked(roomId, name, input, context) {
15115
15919
  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 +15954,30 @@ var init_service = __esm({
15150
15954
  try {
15151
15955
  return await this.commandScope.run(scope, async () => {
15152
15956
  try {
15957
+ if (name === "room.history") {
15958
+ const request = ParticipantHistoryPageSchema.extend({ view: external_exports.literal("participant").optional() }).safeParse(input);
15959
+ if (!request.success) return { ok: false, error: "invalid_request" };
15960
+ const { view: _view, ...page } = request.data;
15961
+ return { ok: true, result: JSON.parse(JSON.stringify(await this.participantHistory(roomId, context.sender_cid, page))) };
15962
+ }
15963
+ if (name === "room.show" || name === "room.participants") {
15964
+ external_exports.object({}).strict().parse(input);
15965
+ const result2 = name === "room.show" ? {
15966
+ room_id: room.room_id,
15967
+ room_name: room.room_name,
15968
+ state: room.state,
15969
+ mission: { goal: room.mission.goal, briefing: room.mission.briefing, briefing_version: room.mission.briefing_version },
15970
+ anonymous: room.anonymous,
15971
+ quiet_membership: room.quiet_membership,
15972
+ membership_epoch: room.membership_epoch
15973
+ } : room.seats.map(({ participant_id, role, state }) => ({ participant_id, role, state }));
15974
+ return { ok: true, result: JSON.parse(JSON.stringify(result2)) };
15975
+ }
15153
15976
  const routes = name === "room.accept" ? createPrivateServiceRoutes(this) : createServiceRoutes(this);
15154
15977
  const result = await routes[name].run({ ...input, room_id: roomId });
15978
+ if (name === "room.message" || name === "room.say") {
15979
+ return { ok: true, result: { message_id: result.message_id, accepted: true } };
15980
+ }
15155
15981
  return { ok: true, result: JSON.parse(JSON.stringify(result)) };
15156
15982
  } catch (error) {
15157
15983
  return { ok: false, error: classifyServiceError(error) };
@@ -15322,7 +16148,7 @@ var init_service = __esm({
15322
16148
  } catch {
15323
16149
  throw new RoomServiceError("external invite is invalid or exceeds the 48 KiB decoded limit");
15324
16150
  }
15325
- const digest = createHash4("sha256").update(decoded).digest("hex");
16151
+ const digest = createHash5("sha256").update(decoded).digest("hex");
15326
16152
  const receipt = await this.lock(id, async () => {
15327
16153
  const room = await this.store.load(id);
15328
16154
  this.assertMutable(room, "accept an external invite for");
@@ -15335,7 +16161,7 @@ var init_service = __esm({
15335
16161
  } catch {
15336
16162
  throw new RoomServiceError("external invite was rejected");
15337
16163
  }
15338
- const cid = ContainerIdSchema.parse(added.container_id);
16164
+ const cid = ContainerIdSchema2.parse(added.container_id);
15339
16165
  if (request.expected_cid !== void 0 && cid !== request.expected_cid) {
15340
16166
  throw new RoomServiceError("external invite inviter CID did not match --expected-cid");
15341
16167
  }
@@ -15927,29 +16753,38 @@ var init_service = __esm({
15927
16753
  return saved.command_grants.map((grant) => ({ ...grant }));
15928
16754
  });
15929
16755
  }
16756
+ /** Internal authenticated API; numeric cursors count only this viewer's visible messages. */
16757
+ async participantHistory(roomId, viewerCid, page = {}) {
16758
+ const id = LowerCrockfordUlidSchema.parse(roomId);
16759
+ const request = ParticipantHistoryPageSchema.parse(page);
16760
+ return this.lock(id, async () => {
16761
+ const room = await this.store.load(id);
16762
+ if (room.state !== "active" || !room.seats.some((seat) => seat.state === "active" && seat.identity === viewerCid)) {
16763
+ throw new ThreadFailure("unauthorized");
16764
+ }
16765
+ const records = [];
16766
+ let after = 0;
16767
+ for (; ; ) {
16768
+ const batch = await this.store.read(id, { after, limit: JOURNAL_WORK_BATCH_SIZE2 });
16769
+ if (batch.length === 0) break;
16770
+ const last = batch[batch.length - 1];
16771
+ if (last.seq <= after || batch.some((row) => row.room_id !== id)) throw new Error("invalid participant history archive page");
16772
+ records.push(...batch);
16773
+ after = last.seq;
16774
+ }
16775
+ return projectParticipantHistory(room, records, viewerCid, request);
16776
+ });
16777
+ }
15930
16778
  async history(roomId, options = {}) {
15931
16779
  const id = LowerCrockfordUlidSchema.parse(roomId);
15932
16780
  const { view, ...page } = HistoryOptionsSchema.parse(options);
15933
16781
  const records = view === "participant" ? await queryStore2(this.store, id, { kind: "message", after: page.after, limit: page.limit }) : await this.store.read(id, page);
15934
16782
  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
- });
16783
+ const projected = records.filter((record) => record.kind === "message").map((record) => publicHistoryMessage(record, record.author_alias === void 0 ? record.author : {
16784
+ identity: record.author_alias.participant_id,
16785
+ display_name: record.author_alias.alias,
16786
+ role: record.author.role
16787
+ }));
15953
16788
  return byteBoundedHistoryPage(projected.slice(0, page.limit ?? Number.MAX_SAFE_INTEGER));
15954
16789
  }
15955
16790
  /**
@@ -16509,7 +17344,7 @@ import * as nodeFs2 from "node:fs";
16509
17344
  import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
16510
17345
  import { basename, dirname as dirname3, join as join4 } from "node:path";
16511
17346
  import Database from "better-sqlite3";
16512
- var DIRECTORY_MODE2, FILE_MODE2, NO_FOLLOW2, SQLITE_SCHEMA_VERSION, DEFAULT_WORK_BATCH_SIZE, utf8Decoder, CoworkStorageError, RoomQueue, CoworkStore;
17347
+ var DIRECTORY_MODE2, FILE_MODE2, NO_FOLLOW2, SQLITE_SCHEMA_VERSION, DEFAULT_WORK_BATCH_SIZE, utf8Decoder, SQLITE_V2_EXTENSION_DDL, CoworkStorageError, RoomQueue, CoworkStore;
16513
17348
  var init_storage = __esm({
16514
17349
  "src/storage.ts"() {
16515
17350
  "use strict";
@@ -16518,9 +17353,24 @@ var init_storage = __esm({
16518
17353
  DIRECTORY_MODE2 = 448;
16519
17354
  FILE_MODE2 = 384;
16520
17355
  NO_FOLLOW2 = nodeFs2.constants.O_NOFOLLOW ?? 0;
16521
- SQLITE_SCHEMA_VERSION = 1;
17356
+ SQLITE_SCHEMA_VERSION = 2;
16522
17357
  DEFAULT_WORK_BATCH_SIZE = 64;
16523
17358
  utf8Decoder = new TextDecoder("utf-8", { fatal: true });
17359
+ SQLITE_V2_EXTENSION_DDL = `
17360
+ CREATE UNIQUE INDEX IF NOT EXISTS records_thread_creation_key
17361
+ ON records(json_extract(payload_json,'$.author.identity'),
17362
+ json_extract(payload_json,'$.thread_root.idempotency_key'))
17363
+ WHERE kind='message' AND json_type(payload_json,'$.thread_root')='object';
17364
+ CREATE INDEX IF NOT EXISTS records_thread_id
17365
+ ON records(json_extract(payload_json,'$.scope.thread_id'))
17366
+ WHERE kind='message' AND json_type(payload_json,'$.scope')='object';
17367
+ DROP INDEX IF EXISTS records_source_message;
17368
+ DROP INDEX IF EXISTS records_source_file;
17369
+ CREATE UNIQUE INDEX records_source_message ON records(source_msg_id)
17370
+ WHERE source_msg_id IS NOT NULL;
17371
+ CREATE UNIQUE INDEX records_source_file ON records(source_file_id)
17372
+ WHERE source_file_id IS NOT NULL;
17373
+ `;
16524
17374
  CoworkStorageError = class extends Error {
16525
17375
  constructor(message, options) {
16526
17376
  super(message, options);
@@ -16924,55 +17774,65 @@ var init_storage = __esm({
16924
17774
  try {
16925
17775
  this.secureSqliteFiles(path);
16926
17776
  db = new Database(path, { fileMustExist: !create });
17777
+ const activeDb = db;
16927
17778
  if (guardFd !== void 0) this.validateOpenPath(guardFd, path, "room archive database", "file", true);
16928
17779
  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");
17780
+ const existingVersion = create ? void 0 : activeDb.pragma("user_version", { simple: true });
17781
+ if (existingVersion !== void 0 && existingVersion !== 1 && existingVersion !== SQLITE_SCHEMA_VERSION) {
17782
+ throw new CoworkStorageError(`unsupported room archive schema version ${existingVersion}`);
17783
+ }
17784
+ activeDb.pragma("journal_mode = WAL");
17785
+ activeDb.pragma("synchronous = FULL");
17786
+ activeDb.pragma("foreign_keys = ON");
17787
+ activeDb.pragma("busy_timeout = 5000");
16933
17788
  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}`);
17789
+ activeDb.transaction(() => {
17790
+ activeDb.exec(`CREATE TABLE records (
17791
+ seq INTEGER PRIMARY KEY, record_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, at TEXT NOT NULL,
17792
+ payload_json TEXT NOT NULL, blob_path TEXT, message_id TEXT, file_id TEXT, intent_record_id TEXT,
17793
+ recipient_identity TEXT, source_msg_id INTEGER, source_file_id INTEGER, category TEXT,
17794
+ briefing_role TEXT, briefing_version INTEGER, membership_epoch INTEGER
17795
+ );
17796
+ CREATE TABLE record_recipients (
17797
+ record_seq INTEGER NOT NULL REFERENCES records(seq) ON DELETE CASCADE,
17798
+ recipient_identity TEXT NOT NULL, category TEXT, briefing_role TEXT,
17799
+ briefing_version INTEGER, PRIMARY KEY(record_seq, recipient_identity)
17800
+ );
17801
+ CREATE TABLE relay_intent_work (
17802
+ record_seq INTEGER NOT NULL REFERENCES records(seq) ON DELETE CASCADE,
17803
+ recipient_identity TEXT NOT NULL, PRIMARY KEY(record_seq, recipient_identity)
17804
+ );
17805
+ CREATE INDEX relay_work_source ON relay_intent_work(record_seq, recipient_identity);
17806
+ CREATE INDEX records_kind_seq ON records(kind, seq);
17807
+ CREATE INDEX records_message ON records(message_id, kind, seq);
17808
+ CREATE INDEX records_file ON records(file_id, kind, seq);
17809
+ CREATE INDEX records_intent_result ON records(intent_record_id, kind);
17810
+ CREATE INDEX records_relay_recipient ON records(kind, recipient_identity, seq);
17811
+ CREATE UNIQUE INDEX records_source_message ON records(source_msg_id) WHERE kind='message' AND source_msg_id IS NOT NULL;
17812
+ CREATE UNIQUE INDEX records_source_file ON records(source_file_id) WHERE kind='file' AND source_file_id IS NOT NULL;
17813
+ CREATE INDEX records_briefing ON records(category, briefing_role, briefing_version, seq);
17814
+ CREATE INDEX records_membership_epoch ON records(category, membership_epoch);
17815
+ CREATE INDEX recipients_identity ON record_recipients(recipient_identity, record_seq);
17816
+ CREATE INDEX recipients_briefing_delivery ON record_recipients
17817
+ (recipient_identity, category, briefing_role, briefing_version, record_seq);`);
17818
+ activeDb.exec(SQLITE_V2_EXTENSION_DDL);
17819
+ activeDb.pragma(`user_version = ${SQLITE_SCHEMA_VERSION}`);
17820
+ }).immediate();
16963
17821
  this.reconciledBlobRooms.add(roomId);
16964
17822
  } 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}`);
17823
+ if (existingVersion === 1) {
17824
+ activeDb.transaction(() => {
17825
+ activeDb.exec(SQLITE_V2_EXTENSION_DDL);
17826
+ activeDb.pragma(`user_version = ${SQLITE_SCHEMA_VERSION}`);
17827
+ }).immediate();
16968
17828
  }
16969
17829
  if (!this.reconciledBlobRooms.has(roomId)) {
16970
- this.reconcileBlobDirectory(roomId, db);
17830
+ this.reconcileBlobDirectory(roomId, activeDb);
16971
17831
  this.reconciledBlobRooms.add(roomId);
16972
17832
  }
16973
17833
  }
16974
17834
  this.secureSqliteFiles(path);
16975
- const result = work(db);
17835
+ const result = work(activeDb);
16976
17836
  this.secureSqliteFiles(path);
16977
17837
  return result;
16978
17838
  } catch (error) {