openmausbot 0.1.90 → 0.1.92

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.
Files changed (177) hide show
  1. package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DUyXd0kL.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
  3. package/dist/assets/{arc-q73qM-At.js → arc-DIYc7Ymx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-D33O3Wur.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DLsJeoXC.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DnaljZnu.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
  7. package/dist/assets/channel-Dtk5Mtic.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-Da0h2i79.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-CfzHi54f.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-LjE4d9Jf.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DY-UGmvc.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BqeGhyhP.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BYa9EBAg.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-vTrDtWyb.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-ic5tXadM.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-DvRCY-c6.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-DVbmgz_L.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-vdUs2n57.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-BvLGlV3U.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-C4GcJQDt.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-BMSaHpt_.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-D5b9UBhC.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CIKLlB7Y.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BzXb-7k7.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-HsTvJ7uT.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-Db9ySNbQ.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-D6BKbpqY.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-Bq067L0t.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-DhaD7fNJ.js → elk-276RUBZZ-v6bj852W.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-fwIhfz36.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-DygIyu0V.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-DPYfSO96.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-DhkHeCle.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
  34. package/dist/assets/index-Cdmjrl28.css +1 -0
  35. package/dist/assets/{index-G1PHKGyh.js → index-DGBaEXrM.js} +1 -1
  36. package/dist/assets/index-DR7YPMkW.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-DstBLxME.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-CrIsGgch.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-CYypGDbB.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DGjPt8E_.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
  41. package/dist/assets/{linear-Dwc3Hrru.js → linear-CmOWzX0P.js} +1 -1
  42. package/dist/assets/{mermaid.core-Dc4OePBL.js → mermaid.core-CF1zwoQ-.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DV-vepj-.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-lx5Kj-n5.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-Bq7Vmm15.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-DeqeqOBj.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-CXwRQzSC.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-8tAeoB88.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-BG5zjGWU.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DKF3iLcw.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-B-y1qUgT.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-MkzPgxAc.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DnbaOEwH.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-Bb4xG_Bt.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-JgkR9pFf.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-CIPIrAYe.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-BjCLuUvJ.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DKgiaYxT.js → xychartDiagram-PMCCYNJV-Dc_fL4mk.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist/novnc-NOTICE.txt +118 -0
  62. package/dist-server/cloud-home-start.js +128 -15
  63. package/dist-server/container-mcp.js +277 -45
  64. package/dist-server/drivers/agents-proxy.js +16 -4
  65. package/dist-server/enterprise/server/index.js +5 -1
  66. package/dist-server/index.js +46703 -39955
  67. package/dist-server/local-computer-proxy.js +128 -7
  68. package/dist-server/mcp-gate.js +11 -5
  69. package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
  70. package/dist-server/openmausbot.js +4099 -1217
  71. package/dist-server/pair-cli.js +4099 -1217
  72. package/dist-server/server/boat.js +67 -22
  73. package/dist-server/server/bot-overview.js +7 -4
  74. package/dist-server/server/browser-engine.js +10 -1
  75. package/dist-server/server/browser-live.js +46 -2
  76. package/dist-server/server/browser-runtime.js +74 -8
  77. package/dist-server/server/browser-tool-shape.js +6 -4
  78. package/dist-server/server/chief-of-staff.js +1 -1
  79. package/dist-server/server/cloud-home-start.js +136 -26
  80. package/dist-server/server/cloud-home.js +64 -5
  81. package/dist-server/server/cloud-lending.js +167 -0
  82. package/dist-server/server/cloud-move-http.js +268 -0
  83. package/dist-server/server/cloud-move.js +418 -0
  84. package/dist-server/server/cloud-owner.js +196 -0
  85. package/dist-server/server/cloud-secrets-boot.js +6 -0
  86. package/dist-server/server/cloud-secrets.js +65 -0
  87. package/dist-server/server/config.js +215 -17
  88. package/dist-server/server/container-computer.js +3 -2
  89. package/dist-server/server/decider/index.js +238 -0
  90. package/dist-server/server/decider/jev.js +183 -0
  91. package/dist-server/server/decider/log.js +62 -0
  92. package/dist-server/server/decider/relay.js +52 -0
  93. package/dist-server/server/decider/room-routing.js +88 -0
  94. package/dist-server/server/decider/types.js +5 -0
  95. package/dist-server/server/desktop-viewer-targets.js +26 -0
  96. package/dist-server/server/drivers/acp/core.js +167 -21
  97. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  98. package/dist-server/server/drivers/agents-catalog.js +22 -6
  99. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  100. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  101. package/dist-server/server/drivers/antigravity.js +21 -0
  102. package/dist-server/server/drivers/boatagent.js +20 -12
  103. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  104. package/dist-server/server/drivers/claude.js +313 -34
  105. package/dist-server/server/drivers/codex.js +228 -18
  106. package/dist-server/server/drivers/grok.js +2 -2
  107. package/dist-server/server/drivers/mistral.js +4 -3
  108. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  109. package/dist-server/server/drivers/openai-chat.js +41 -3
  110. package/dist-server/server/drivers/openai-compat.js +73 -23
  111. package/dist-server/server/engine-install.js +46 -12
  112. package/dist-server/server/env-path.js +15 -3
  113. package/dist-server/server/group-goal-run.js +6 -4
  114. package/dist-server/server/harness/registry.js +2 -2
  115. package/dist-server/server/harness-capabilities.js +2 -0
  116. package/dist-server/server/included-services.js +70 -0
  117. package/dist-server/server/index.js +1753 -263
  118. package/dist-server/server/lending-memory.js +479 -0
  119. package/dist-server/server/local-vm-idle.js +24 -6
  120. package/dist-server/server/managed-desktop.js +3 -0
  121. package/dist-server/server/mcp-bridge.js +5 -3
  122. package/dist-server/server/mcp-http.js +6 -3
  123. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  124. package/dist-server/server/mcp-oauth-store.js +70 -0
  125. package/dist-server/server/mcp-oauth.js +550 -0
  126. package/dist-server/server/mcp-probe.js +14 -1
  127. package/dist-server/server/mcp-registry.js +43 -0
  128. package/dist-server/server/memory-upkeep.js +94 -84
  129. package/dist-server/server/model-catalog/catalog.js +374 -0
  130. package/dist-server/server/model-catalog/presets.js +176 -0
  131. package/dist-server/server/model-catalog/trim.js +219 -0
  132. package/dist-server/server/model-context-window.js +4 -4
  133. package/dist-server/server/package-export.js +17 -10
  134. package/dist-server/server/package-import.js +6 -3
  135. package/dist-server/server/package-parts.js +3 -1
  136. package/dist-server/server/provider-key-check.js +4 -2
  137. package/dist-server/server/recent-work.js +1 -1
  138. package/dist-server/server/request-auth.js +25 -0
  139. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  140. package/dist-server/server/routes/bot-memory.js +31 -6
  141. package/dist-server/server/routes/decider.js +19 -0
  142. package/dist-server/server/routes/desktop-viewer.js +194 -0
  143. package/dist-server/server/routine-requests.js +1 -1
  144. package/dist-server/server/routines.js +1 -1
  145. package/dist-server/server/sessions.js +56 -9
  146. package/dist-server/server/shared-computers.js +116 -30
  147. package/dist-server/server/skills.js +25 -10
  148. package/dist-server/server/store.js +79 -124
  149. package/dist-server/server/surface.js +13 -0
  150. package/dist-server/server/system-prompt.js +11 -0
  151. package/dist-server/server/team-backup.js +6 -2
  152. package/dist-server/server/team-manifest.js +11 -3
  153. package/dist-server/server/thread-events.js +2 -0
  154. package/dist-server/server/thread-starters.js +33 -0
  155. package/dist-server/server/thread-title.js +46 -0
  156. package/dist-server/server/tts/elevenlabs.js +11 -6
  157. package/dist-server/server/tts/fish.js +4 -5
  158. package/dist-server/server/tts/index.js +20 -11
  159. package/dist-server/server/vps-computer.js +79 -12
  160. package/dist-server/server/webhooks.js +18 -1
  161. package/dist-server/server/workspace-backup-policy.js +25 -3
  162. package/dist-server/server/workspace-backup.js +28 -8
  163. package/dist-server/server/workspace.js +65 -15
  164. package/dist-server/shared/cloud-home.js +4 -0
  165. package/dist-server/shared/package-format.js +30 -4
  166. package/dist-server/shared/team-backup.js +20 -3
  167. package/dist-server/shared/workspace-backup-client.js +1 -0
  168. package/dist-server/src/lib/citations.js +177 -0
  169. package/dist-server/src/lib/composer-attachments.js +7 -1
  170. package/dist-server/vps-container-mcp.js +278 -45
  171. package/dist-server/workspace-backup.worker.js +184 -38
  172. package/enterprise/server/index.js +5 -1
  173. package/package.json +1 -1
  174. package/dist/assets/channel-BCuyHjFV.js +0 -1
  175. package/dist/assets/index-CRvo-DS2.css +0 -1
  176. package/dist/assets/index-CWYGlA63.js +0 -588
  177. package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +0 -8
@@ -26,6 +26,12 @@ const EXCLUDED = new Set([
26
26
  ".openmausbot-server-child", "environment-id", "sessions.json", "tunnel-account.json",
27
27
  "team-computers.json",
28
28
  "openmausbot-server.lease", "box-create-requests.lock", "messages.db-wal", "messages.db-shm",
29
+ // This machine's decision-model log (server/decider/log.ts): local
30
+ // measurement of what the classifier picked, not workspace data.
31
+ "decider-log",
32
+ // A Cloud home's record of who its owner was (server/cloud-owner.ts): this
33
+ // machine's own, kept in place by a restore, which settles what it brought.
34
+ "cloud-owner.json",
29
35
  ]);
30
36
  const EXCLUSION_NOTES = [
31
37
  "Device pairing, server identity, live leases and runtime files (existing destination identities are preserved).",
@@ -49,6 +55,12 @@ function preserved(name) {
49
55
  const folded = name.toLowerCase();
50
56
  return excluded(folded) && folded !== "messages.db-wal" && folded !== "messages.db-shm";
51
57
  }
58
+ /** Left where it is by a restore. The session registry's open marker is this
59
+ * machine's own (never exported or installed): a crash before the restore
60
+ * still costs account sign-ins, as it would have without one. */
61
+ function keptInPlace(name) {
62
+ return preserved(name) || name.toLowerCase() === "sessions.json.open";
63
+ }
52
64
  function folder(path) {
53
65
  mkdirSync(path, { recursive: true, mode: 0o700 });
54
66
  const stat = lstatSync(path);
@@ -544,22 +556,24 @@ async function decryptArchive(inputPath, plaintext, password) {
544
556
  }
545
557
  async function inspectTar(path) {
546
558
  const fd = openSync(path, "r");
547
- const signature = Buffer.alloc(2);
559
+ const signature = Buffer.alloc(4);
548
560
  try {
549
- readSync(fd, signature, 0, 2, 0);
561
+ readSync(fd, signature, 0, 4, 0);
550
562
  }
551
563
  finally {
552
564
  closeSync(fd);
553
565
  }
554
- // This format is uncompressed tar. Reject auto-detected gzip before tar's
555
- // parser can inflate an authenticated but malicious decompression bomb.
556
- if (signature[0] === 0x1f && signature[1] === 0x8b)
566
+ // This format is uncompressed tar. Reject auto-detected gzip and zstd
567
+ // before tar's parser can inflate an authenticated but malicious
568
+ // decompression bomb; tar is also told never to decompress (below).
569
+ if ((signature[0] === 0x1f && signature[1] === 0x8b) || signature.equals(Buffer.from([0x28, 0xb5, 0x2f, 0xfd]))) {
557
570
  throw new Error("Compressed payloads are not supported in this workspace backup version.");
571
+ }
558
572
  const entries = new Map();
559
573
  const names = new Set();
560
574
  let bytes = 0;
561
575
  let problem = "";
562
- await tar.t({ file: path, strict: true, onReadEntry(entry) {
576
+ await tar.t({ file: path, strict: true, brotli: false, zstd: false, onReadEntry(entry) {
563
577
  const name = entry.type === "Directory" ? entry.path.replace(/\/$/, "") : entry.path;
564
578
  const expectedRoot = name === "manifest.json" || name === "data" || name.startsWith("data/");
565
579
  // ReadEntry normalizes backslashes on Windows; inspect the raw header too
@@ -651,7 +665,7 @@ export async function stageWorkspaceBackup(dataDir, archivePath, options) {
651
665
  const expected = await inspectTar(plaintext);
652
666
  const staged = join(job.directory, "staged");
653
667
  folder(staged);
654
- await tar.x({ file: plaintext, cwd: staged, strict: true, preservePaths: false, umask: 0o077, noChmod: true });
668
+ await tar.x({ file: plaintext, cwd: staged, strict: true, preservePaths: false, umask: 0o077, noChmod: true, brotli: false, zstd: false });
655
669
  const manifest = validateStaged(staged, expected);
656
670
  const versions = [manifest.summary.appVersion, options.currentAppVersion ?? ""].map((version) => /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version)?.slice(1).map(Number));
657
671
  if (versions[0] && versions[1]) {
@@ -1037,7 +1051,7 @@ export function applyPendingWorkspaceRestore(dataDir) {
1037
1051
  folder(join(safetyCopyPath, "data"));
1038
1052
  const journal = {
1039
1053
  id: pending.id, phase: "applying",
1040
- existing: readdirSync(dataDir).filter((name) => !preserved(name)).sort(),
1054
+ existing: readdirSync(dataDir).filter((name) => !keptInPlace(name)).sort(),
1041
1055
  incoming: readdirSync(prepared).sort(),
1042
1056
  };
1043
1057
  // Top-level source and destination names are checked before recording any
@@ -1087,3 +1101,9 @@ export function removeWorkspaceBackupJob(dataDir, id) {
1087
1101
  throw new Error("Unsafe workspace backup cleanup target.");
1088
1102
  rmSync(path, { recursive: true, force: true });
1089
1103
  }
1104
+ /** Whether a snapshot leaves out this workspace-relative path, by the same
1105
+ * rules as the export walk (cloud-move.ts sizes a move with it). Symbolic
1106
+ * links are the caller's to skip. */
1107
+ export function omittedFromWorkspaceBackup(path) {
1108
+ return excluded(path.split("/")[0]) || excludedWorkspaceAuthPath(path) || ephemeralWorkspaceTokenPath(path) || redownloadedOrgLibraryPath(path);
1109
+ }
@@ -8,7 +8,7 @@
8
8
  // memory/ holds topic files the bot reads on demand with its ordinary
9
9
  // file tools. Plain markdown on purpose — the user can open, edit, or
10
10
  // delete anything the bot believes.
11
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
11
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { writeFileAtomic } from "./atomic.js";
14
14
  import { isMemoryDate, untilMark, withoutExpired } from "./memory-entries.js";
@@ -58,6 +58,53 @@ export function ensureWorkspace(botId) {
58
58
  export function workspaceDir(botId) {
59
59
  return join(WORKSPACES_DIR, botId);
60
60
  }
61
+ /** On an OMB Cloud home a bot's memory is read only from regular files,
62
+ * never through a link: server/lending-memory.ts judges a link by where it
63
+ * points, not by what is there, so a turn must never read through one.
64
+ * Elsewhere memory reads exactly as it always did. */
65
+ let regularMemoryFilesOnly = false;
66
+ export function readMemoryOnlyFromRegularFiles() {
67
+ regularMemoryFilesOnly = true;
68
+ }
69
+ /** A memory file's text. Throws like readFileSync when it cannot be read,
70
+ * and, on a Cloud home, when it is not a regular file. */
71
+ export function readMemoryText(path) {
72
+ if (!regularMemoryFilesOnly)
73
+ return readFileSync(path, "utf8");
74
+ // O_NOFOLLOW refuses a link at open where there is one; Windows has none
75
+ // (and opens what a link points at), so the entry is looked at first too.
76
+ if (!lstatSync(path).isFile())
77
+ throw Object.assign(new Error(`not a regular file: ${path}`), { code: "EINVAL" });
78
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
79
+ try {
80
+ if (!fstatSync(fd).isFile())
81
+ throw Object.assign(new Error(`not a regular file: ${path}`), { code: "EINVAL" });
82
+ return readFileSync(fd, "utf8");
83
+ }
84
+ finally {
85
+ closeSync(fd);
86
+ }
87
+ }
88
+ /** A memory folder's entries; on a Cloud home, none when the folder itself
89
+ * is a link. */
90
+ function memoryFolderEntries(dir) {
91
+ if (regularMemoryFilesOnly && !lstatSync(dir).isDirectory())
92
+ return [];
93
+ return readdirSync(dir);
94
+ }
95
+ function regularFile(path) {
96
+ try {
97
+ return lstatSync(path).isFile();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /** The stat a listing trusts: on a Cloud home, the entry's own (a link is
104
+ * not a file); elsewhere what it points at, as before. */
105
+ function memoryEntryStat(path) {
106
+ return regularMemoryFilesOnly ? lstatSync(path) : statSync(path);
107
+ }
61
108
  /** File locations, not file contents or wider tool permissions. Threads keep
62
109
  * independent working directories; the same bot can find its earlier output
63
110
  * without assuming that a file absent from the current directory was lost. */
@@ -89,7 +136,7 @@ export function memoryOverBudget(text) {
89
136
  export function loadMemory(botId, opts = {}) {
90
137
  let raw;
91
138
  try {
92
- raw = readFileSync(join(workspaceDir(botId), "MEMORY.md"), "utf8");
139
+ raw = readMemoryText(join(workspaceDir(botId), "MEMORY.md"));
93
140
  }
94
141
  catch {
95
142
  return null;
@@ -128,7 +175,7 @@ export const MEMORY_FILE_MAX_BYTES = 256 * 1024;
128
175
  export function readMemoryFile(botId) {
129
176
  let raw;
130
177
  try {
131
- raw = readFileSync(join(workspaceDir(botId), "MEMORY.md"), "utf8");
178
+ raw = readMemoryText(join(workspaceDir(botId), "MEMORY.md"));
132
179
  }
133
180
  catch {
134
181
  return { text: "", truncated: false };
@@ -159,7 +206,7 @@ function indexWrittenMemoryFile(botId, relativePath) {
159
206
  try {
160
207
  const path = join(workspaceDir(botId), relativePath);
161
208
  const stat = statSync(path);
162
- indexMemoryFile(botId, relativePath, searchableMemoryText(relativePath, readFileSync(path, "utf8")), { mtimeMs: stat.mtimeMs, bytes: stat.size });
209
+ indexMemoryFile(botId, relativePath, searchableMemoryText(relativePath, readMemoryText(path)), { mtimeMs: stat.mtimeMs, bytes: stat.size });
163
210
  }
164
211
  catch {
165
212
  // the next search's sync pass picks it up
@@ -176,7 +223,7 @@ function memoryFilesOnDisk(botId) {
176
223
  ];
177
224
  return names.flatMap((relativePath) => {
178
225
  try {
179
- const stat = statSync(join(dir, relativePath));
226
+ const stat = memoryEntryStat(join(dir, relativePath));
180
227
  return stat.isFile() ? [{ path: relativePath, mtimeMs: stat.mtimeMs, bytes: stat.size }] : [];
181
228
  }
182
229
  catch {
@@ -206,7 +253,7 @@ export function syncMemoryIndex(botId) {
206
253
  if (!dateChanged && known && known.bytes === file.bytes && known.mtimeMs === Math.trunc(file.mtimeMs))
207
254
  continue;
208
255
  try {
209
- const text = readFileSync(join(workspaceDir(botId), file.path), "utf8");
256
+ const text = readMemoryText(join(workspaceDir(botId), file.path));
210
257
  indexMemoryFile(botId, file.path, searchableMemoryText(file.path, text), file);
211
258
  }
212
259
  catch {
@@ -318,7 +365,7 @@ export function updateMemory(botId, update, opts = {}) {
318
365
  const dir = ensureWorkspace(botId);
319
366
  // Do not use readMemoryFile's editor-friendly missing/read-error fallback:
320
367
  // a failed read must never turn into a successful overwrite of old notes.
321
- const raw = readFileSync(join(dir, "MEMORY.md"), "utf8");
368
+ const raw = readMemoryText(join(dir, "MEMORY.md"));
322
369
  const current = raw === MEMORY_SEED ? "" : raw;
323
370
  const today = memoryDate(opts.now);
324
371
  // Every appended entry ends its own line; a file the person left without
@@ -419,7 +466,7 @@ export function appendMemoryLog(botId, text, opts = {}) {
419
466
  const path = join(dir, file);
420
467
  let current = "";
421
468
  try {
422
- current = readFileSync(path, "utf8");
469
+ current = readMemoryText(path);
423
470
  }
424
471
  catch {
425
472
  // first line of the day
@@ -442,7 +489,8 @@ export function writeMemoryLog(botId, name, text) {
442
489
  /** The bot's daily log files, oldest first, by day name. */
443
490
  export function listMemoryLogs(botId) {
444
491
  try {
445
- return readdirSync(join(workspaceDir(botId), "memory", MEMORY_LOG_DIR)).filter((name) => LOG_FILE_NAME.test(name)).sort();
492
+ const dir = join(workspaceDir(botId), "memory", MEMORY_LOG_DIR);
493
+ return memoryFolderEntries(dir).filter((name) => LOG_FILE_NAME.test(name) && (!regularMemoryFilesOnly || regularFile(join(dir, name)))).sort();
446
494
  }
447
495
  catch {
448
496
  return [];
@@ -453,7 +501,7 @@ export function readMemoryLog(botId, name) {
453
501
  if (!LOG_FILE_NAME.test(name))
454
502
  return null;
455
503
  try {
456
- return readFileSync(join(workspaceDir(botId), "memory", MEMORY_LOG_DIR, name), "utf8");
504
+ return readMemoryText(join(workspaceDir(botId), "memory", MEMORY_LOG_DIR, name));
457
505
  }
458
506
  catch {
459
507
  return null;
@@ -472,7 +520,7 @@ export function isMemoryTopicName(name) {
472
520
  export function listMemoryTopics(botId) {
473
521
  let entries;
474
522
  try {
475
- entries = readdirSync(join(workspaceDir(botId), "memory"));
523
+ entries = memoryFolderEntries(join(workspaceDir(botId), "memory"));
476
524
  }
477
525
  catch {
478
526
  return [];
@@ -481,7 +529,7 @@ export function listMemoryTopics(botId) {
481
529
  .filter(isMemoryTopicName)
482
530
  .flatMap((name) => {
483
531
  try {
484
- const stat = statSync(join(workspaceDir(botId), "memory", name));
532
+ const stat = memoryEntryStat(join(workspaceDir(botId), "memory", name));
485
533
  return stat.isFile() ? [{ name, bytes: stat.size }] : [];
486
534
  }
487
535
  catch {
@@ -507,7 +555,7 @@ export function readMemoryTopic(botId, name) {
507
555
  if (!isMemoryTopicName(name))
508
556
  return null;
509
557
  try {
510
- return readFileSync(join(workspaceDir(botId), "memory", name), "utf8");
558
+ return readMemoryText(join(workspaceDir(botId), "memory", name));
511
559
  }
512
560
  catch {
513
561
  return null;
@@ -541,12 +589,14 @@ export function memoryTopicIndex(botId) {
541
589
  const dir = join(workspaceDir(botId), "memory");
542
590
  return renderTopicIndex(listMemoryTopics(botId).map((topic) => ({ name: topic.name, header: parseTopicHeader(readTopicHead(join(dir, topic.name))) })));
543
591
  }
544
- /** The memory block appended to a bot's system prompt. Always present for
545
- * bots with a workspace, so the bot knows the mechanism exists even before
592
+ /** The memory block appended to a bot's system prompt when enabled. Present for
593
+ * enabled bots with a workspace, so the bot knows the mechanism exists even before
546
594
  * it has written anything. Content from other bots or imported files must
547
595
  * never be recorded as fact — memory is a prompt-injection persistence
548
596
  * vector the moment a bot copies untrusted text into it. */
549
597
  export function memorySystemPrompt(botId, opts = {}) {
598
+ if (opts.enabled === false)
599
+ return "";
550
600
  const memory = loadMemory(botId);
551
601
  const memoryFile = join(workspaceDir(botId), "MEMORY.md");
552
602
  const topicDir = join(workspaceDir(botId), "memory");
@@ -0,0 +1,4 @@
1
+ /** Whether a Cloud home offers this place at all. */
2
+ export function cloudHomeOffersPlace(place) {
3
+ return place !== "local" && place !== "vm";
4
+ }
@@ -161,7 +161,24 @@ const requirementsSchema = z.object({
161
161
  capabilities: z.array(requiredText(80)).max(20),
162
162
  platforms: z.array(requiredText(80)).max(10).optional(),
163
163
  });
164
- const roomSchema = z.object({
164
+ const ROOM_RESPONDER_KINDS = new Set(["agent", "everyone", "mentions", "auto"]);
165
+ /** A responder kind from a newer release degrades to the room's first member
166
+ * as lead, which is what a room without a responder does, instead of
167
+ * refusing the whole package. Malformed values still fail below. */
168
+ function knownRoomResponder(value) {
169
+ if (!value || typeof value !== "object" || Array.isArray(value))
170
+ return value;
171
+ const room = value;
172
+ const responder = room.defaultResponder;
173
+ if (!responder || typeof responder !== "object" || Array.isArray(responder))
174
+ return value;
175
+ const kind = responder.kind;
176
+ if (typeof kind !== "string" || ROOM_RESPONDER_KINDS.has(kind))
177
+ return value;
178
+ const first = Array.isArray(room.members) && typeof room.members[0] === "string" ? room.members[0] : undefined;
179
+ return { ...room, defaultResponder: first ? { kind: "agent", agent: first } : { kind: "mentions" } };
180
+ }
181
+ const roomSchema = z.preprocess(knownRoomResponder, z.object({
165
182
  key,
166
183
  name: requiredText(100),
167
184
  members: z.array(key).min(1).max(200),
@@ -170,8 +187,11 @@ const roomSchema = z.object({
170
187
  z.object({ kind: z.literal("agent"), agent: key }),
171
188
  z.object({ kind: z.literal("everyone") }),
172
189
  z.object({ kind: z.literal("mentions") }),
190
+ // Read, not written: exports spell an Auto room as its lead (see
191
+ // server/package-export.ts) so every older app can still open them.
192
+ z.object({ kind: z.literal("auto"), agent: key.optional() }),
173
193
  ]),
174
- });
194
+ }));
175
195
  const exampleSchema = z.object({
176
196
  title: requiredText(120),
177
197
  input: requiredText(4_000),
@@ -580,7 +600,8 @@ function checkV1References(pkg) {
580
600
  if (!agents.has(member))
581
601
  throw invalid(`Room ${room.key} references unknown agent: ${member}`);
582
602
  }
583
- if (room.defaultResponder.kind === "agent" && !members.has(room.defaultResponder.agent)) {
603
+ if ((room.defaultResponder.kind === "agent" || room.defaultResponder.kind === "auto") && room.defaultResponder.agent !== undefined &&
604
+ !members.has(room.defaultResponder.agent)) {
584
605
  throw invalid(`Room ${room.key} has an unknown default responder`);
585
606
  }
586
607
  }
@@ -637,7 +658,8 @@ function checkV2References(pkg) {
637
658
  if (!agents.has(member))
638
659
  throw invalid(`Room ${room.key} references unknown agent: ${member}`);
639
660
  }
640
- if (room.defaultResponder.kind === "agent" && !members.has(room.defaultResponder.agent)) {
661
+ if ((room.defaultResponder.kind === "agent" || room.defaultResponder.kind === "auto") && room.defaultResponder.agent !== undefined &&
662
+ !members.has(room.defaultResponder.agent)) {
641
663
  throw invalid(`Room ${room.key} has a default responder who is not a member: ${room.defaultResponder.agent}`);
642
664
  }
643
665
  roomMembers.set(room.key, members);
@@ -821,8 +843,12 @@ export function downgradeToV1(document) {
821
843
  return [{ ...kept, enabledAfterInstall: false }];
822
844
  });
823
845
  const { team, publisher: _publisher, notes: _notes, presets: _presets, connections: _connections, skills: _skills, agents: _agents, routines: _routines, ...rest } = pkg;
846
+ // Older apps know no Auto room: spell one as its lead, as exports do.
847
+ const rooms = rest.rooms?.map((room) => room.defaultResponder.kind !== "auto" ? room
848
+ : { ...room, defaultResponder: { kind: "agent", agent: room.defaultResponder.agent ?? room.members[0] } });
824
849
  const v1 = {
825
850
  ...rest,
851
+ ...(rooms ? { rooms } : {}),
826
852
  agents,
827
853
  ...(team.leader ? { chiefOfStaff: team.leader } : {}),
828
854
  ...(routines.length ? { routines } : {}),
@@ -63,6 +63,20 @@ const memory = z.object({
63
63
  * against. Shareable exports never carry grants at all, and an import
64
64
  * always lands bots grant-less whatever the file says. */
65
65
  const connectorTools = z.record(z.string().regex(CONNECTOR_SLUG_PATTERN), z.object({ tools: z.union([z.literal("*"), z.array(z.string().regex(CONNECTOR_TOOL_NAME_PATTERN)).min(1).max(500)]) }));
66
+ /** A responder kind from a newer release restores as the room's first
67
+ * member as lead (what a room without one does) instead of refusing the
68
+ * whole backup. Malformed values still fail the schema. */
69
+ function knownRoomResponder(value) {
70
+ if (!value || typeof value !== "object" || Array.isArray(value))
71
+ return value;
72
+ const group = value;
73
+ const kind = group.defaultResponder && typeof group.defaultResponder === "object"
74
+ ? group.defaultResponder.kind : undefined;
75
+ if (typeof kind !== "string" || ["member", "everyone", "mentions", "auto"].includes(kind))
76
+ return value;
77
+ const first = Array.isArray(group.memberIds) && typeof group.memberIds[0] === "string" ? group.memberIds[0] : undefined;
78
+ return { ...group, defaultResponder: first ? { kind: "member", botId: first } : { kind: "mentions" } };
79
+ }
66
80
  const backupSchema = z.object({
67
81
  format: z.literal("openmaus.backup"),
68
82
  version: z.literal(1),
@@ -86,7 +100,7 @@ const backupSchema = z.object({
86
100
  connectorTools: connectorTools.optional(),
87
101
  memory: memory.optional(),
88
102
  })).min(1).max(200),
89
- groups: z.array(z.object({
103
+ groups: z.array(z.preprocess(knownRoomResponder, z.object({
90
104
  ...owner,
91
105
  memberIds: z.array(key).max(200),
92
106
  dm: z.boolean(),
@@ -95,8 +109,9 @@ const backupSchema = z.object({
95
109
  z.object({ kind: z.literal("member"), botId: key }),
96
110
  z.object({ kind: z.literal("everyone") }),
97
111
  z.object({ kind: z.literal("mentions") }),
112
+ z.object({ kind: z.literal("auto"), fallbackBotId: key.optional() }),
98
113
  ]),
99
- })).max(2_000),
114
+ }))).max(2_000),
100
115
  routines: z.array(z.object({
101
116
  name, prompt: z.string().trim().min(1).max(200_000),
102
117
  target: z.enum(["bot", "room-goal"]), botId: key, groupId: key.optional(),
@@ -136,7 +151,9 @@ export function parseTeamBackup(input) {
136
151
  if (group.memberIds.some((id) => !bots.has(id)))
137
152
  throw new Error("Invalid backup: unknown room member");
138
153
  unique(group.memberIds, "room member");
139
- if (group.defaultResponder.kind === "member" && !group.memberIds.includes(group.defaultResponder.botId)) {
154
+ const lead = group.defaultResponder.kind === "member" ? group.defaultResponder.botId
155
+ : group.defaultResponder.kind === "auto" ? group.defaultResponder.fallbackBotId : undefined;
156
+ if (lead !== undefined && !group.memberIds.includes(lead)) {
140
157
  throw new Error("Invalid backup: room responder is not a member");
141
158
  }
142
159
  if (group.dm && (group.memberIds.length !== 2 || group.tasks.length !== 1))
@@ -7,6 +7,7 @@ export const WORKSPACE_BACKUP_CLIENT_KEYS = [
7
7
  "omb-draft-channel-modes",
8
8
  "omb-skin",
9
9
  "omb-show-threads",
10
+ "omb-show-run-card",
10
11
  "openmausbot.sidebarDensity",
11
12
  "openmausbot.sidebarCollapsedSections.v1",
12
13
  "openmausbot.sidebarSectionOrder.v1",
@@ -0,0 +1,177 @@
1
+ // Selection matching is adapted from T3 Code's assistantTextSelection.ts
2
+ // (Copyright 2026 T3 Tools Inc., MIT License; see third_party/t3-code/LICENSE).
3
+ export const CITATION_MAX_QUOTE_LENGTH = 12_000;
4
+ export const CITATION_MAX_COMMENT_LENGTH = 4_000;
5
+ const CITATION_CONTEXT_LENGTH = 64;
6
+ const CITATION_MARKER = "<!--omb-citation-v1:";
7
+ const CITATION_MARKER_RE = /<!--omb-citation-v1:([A-Za-z0-9_-]{1,100000})-->/g;
8
+ function boundedString(value, max, allowEmpty = false) {
9
+ return typeof value === "string" && value.length <= max && (allowEmpty || value.length > 0);
10
+ }
11
+ export function isCitationAttachment(value) {
12
+ if (!value || typeof value !== "object")
13
+ return false;
14
+ const citation = value;
15
+ const source = citation.source;
16
+ return citation.kind === "citation" &&
17
+ citation.version === 1 &&
18
+ boundedString(citation.id, 200) &&
19
+ boundedString(citation.quote, CITATION_MAX_QUOTE_LENGTH) &&
20
+ (citation.comment === undefined || boundedString(citation.comment, CITATION_MAX_COMMENT_LENGTH, true)) &&
21
+ typeof citation.size === "number" && Number.isFinite(citation.size) && citation.size >= 0 &&
22
+ Boolean(source) &&
23
+ (source.ownerType === "bot" || source.ownerType === "group") &&
24
+ boundedString(source.ownerId, 200) &&
25
+ boundedString(source.threadId, 200) &&
26
+ boundedString(source.messageId, 200) &&
27
+ Number.isSafeInteger(source.start) && Number(source.start) >= 0 &&
28
+ Number.isSafeInteger(source.end) && Number(source.end) >= Number(source.start) &&
29
+ boundedString(source.prefix, CITATION_CONTEXT_LENGTH, true) &&
30
+ boundedString(source.suffix, CITATION_CONTEXT_LENGTH, true);
31
+ }
32
+ function newId() {
33
+ return globalThis.crypto?.randomUUID?.() ?? `c${Math.random().toString(36).slice(2)}`;
34
+ }
35
+ function byteLength(text) {
36
+ return new TextEncoder().encode(text).length;
37
+ }
38
+ export function citationAttachment(source, selector, comment = "") {
39
+ const trimmedComment = comment.trim();
40
+ if (!selector.text.trim() || selector.text.length > CITATION_MAX_QUOTE_LENGTH) {
41
+ throw new Error("Citation quote is empty or too long");
42
+ }
43
+ if (trimmedComment.length > CITATION_MAX_COMMENT_LENGTH)
44
+ throw new Error("Citation comment is too long");
45
+ return {
46
+ kind: "citation",
47
+ version: 1,
48
+ id: newId(),
49
+ quote: selector.text,
50
+ ...(trimmedComment ? { comment: trimmedComment } : {}),
51
+ source: { ...source, start: selector.start, end: selector.end, prefix: selector.prefix, suffix: selector.suffix },
52
+ size: byteLength(selector.text) + byteLength(trimmedComment),
53
+ };
54
+ }
55
+ export function withCitationComment(citation, comment) {
56
+ const trimmed = comment.trim();
57
+ if (trimmed.length > CITATION_MAX_COMMENT_LENGTH)
58
+ throw new Error("Citation comment is too long");
59
+ const { comment: _old, ...rest } = citation;
60
+ return { ...rest, ...(trimmed ? { comment: trimmed } : {}), size: byteLength(citation.quote) + byteLength(trimmed) };
61
+ }
62
+ function base64UrlEncode(value) {
63
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
64
+ let binary = "";
65
+ for (const byte of bytes)
66
+ binary += String.fromCharCode(byte);
67
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
68
+ }
69
+ function base64UrlDecode(value) {
70
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
71
+ const binary = atob(padded);
72
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))));
73
+ }
74
+ /** Readable in clients without citation UI and unambiguous to model providers. */
75
+ export function citationFallback(citation) {
76
+ const quote = citation.quote.split("\n").map((line) => `> ${line}`).join("\n");
77
+ return `> Quoted message:\n${quote}${citation.comment ? `\n\nComment:\n${citation.comment}` : ""}`;
78
+ }
79
+ export function serializeCitation(citation) {
80
+ return `${CITATION_MARKER}${base64UrlEncode(citation)}-->\n${citationFallback(citation)}`;
81
+ }
82
+ export function splitTranscriptCitations(text) {
83
+ const citations = [];
84
+ let display = "";
85
+ let cursor = 0;
86
+ for (const match of text.matchAll(CITATION_MARKER_RE)) {
87
+ if (match.index < cursor)
88
+ continue;
89
+ let citation;
90
+ try {
91
+ const decoded = base64UrlDecode(match[1]);
92
+ if (!isCitationAttachment(decoded))
93
+ continue;
94
+ citation = decoded;
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ const block = `${match[0]}\n${citationFallback(citation)}`;
100
+ if (!text.startsWith(block, match.index))
101
+ continue;
102
+ display += text.slice(cursor, match.index);
103
+ cursor = match.index + block.length;
104
+ citations.push(citation);
105
+ }
106
+ return { display: `${display}${text.slice(cursor)}`.trim(), citations };
107
+ }
108
+ export function citationPreviewText(text) {
109
+ const cited = splitTranscriptCitations(text);
110
+ if (!cited.citations.length)
111
+ return text;
112
+ return [
113
+ cited.display,
114
+ ...cited.citations.map((citation) => citation.comment
115
+ ? `${citation.quote} — ${citation.comment}`
116
+ : citation.quote),
117
+ ].filter(Boolean).join(" ");
118
+ }
119
+ function normalizeWhitespace(text) {
120
+ return text.replace(/\s+/g, " ");
121
+ }
122
+ function splitsSurrogatePair(text, offset) {
123
+ const before = text.charCodeAt(offset - 1);
124
+ const after = text.charCodeAt(offset);
125
+ return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
126
+ }
127
+ export function createCitationTextSelector(text, rawStart, rawEnd) {
128
+ const quote = text.slice(rawStart, rawEnd);
129
+ if (!quote.trim())
130
+ return null;
131
+ const normalized = normalizeWhitespace(text);
132
+ let start = normalizeWhitespace(text.slice(0, rawStart)).length;
133
+ if (rawStart > 0 && /\s/.test(text[rawStart - 1]) && /\s/.test(text[rawStart]))
134
+ start -= 1;
135
+ const end = normalizeWhitespace(text.slice(0, rawEnd)).length;
136
+ let prefixStart = Math.max(0, start - CITATION_CONTEXT_LENGTH);
137
+ let suffixEnd = Math.min(normalized.length, end + CITATION_CONTEXT_LENGTH);
138
+ if (splitsSurrogatePair(normalized, prefixStart))
139
+ prefixStart += 1;
140
+ if (splitsSurrogatePair(normalized, suffixEnd))
141
+ suffixEnd -= 1;
142
+ return {
143
+ text: quote,
144
+ start,
145
+ end,
146
+ prefix: normalized.slice(prefixStart, start),
147
+ suffix: normalized.slice(end, suffixEnd),
148
+ };
149
+ }
150
+ export function findCitationText(text, selector) {
151
+ const normalized = normalizeWhitespace(text);
152
+ const quote = normalizeWhitespace(selector.text);
153
+ if (!quote.trim())
154
+ return null;
155
+ const prefix = normalizeWhitespace(selector.prefix);
156
+ const suffix = normalizeWhitespace(selector.suffix);
157
+ const matchesContext = (start, end) => normalized.slice(Math.max(0, start - prefix.length), start) === prefix &&
158
+ normalized.slice(end, end + suffix.length) === suffix;
159
+ let match = Number.isSafeInteger(selector.start) && Number.isSafeInteger(selector.end) &&
160
+ selector.start >= 0 && selector.end - selector.start === quote.length &&
161
+ normalized.slice(selector.start, selector.end) === quote && matchesContext(selector.start, selector.end)
162
+ ? { start: selector.start, end: selector.end }
163
+ : null;
164
+ let onlyQuote = null;
165
+ let quoteCount = 0;
166
+ for (let start = normalized.indexOf(quote); start !== -1; start = normalized.indexOf(quote, start + 1)) {
167
+ const end = start + quote.length;
168
+ quoteCount += 1;
169
+ onlyQuote = { start, end };
170
+ if (!matchesContext(start, end))
171
+ continue;
172
+ if (match && match.start !== start)
173
+ return null;
174
+ match = { start, end };
175
+ }
176
+ return match ?? (quoteCount === 1 ? onlyQuote : null);
177
+ }
@@ -1,7 +1,10 @@
1
+ import { isCitationAttachment, serializeCitation } from "./citations.js";
1
2
  export function isAttachment(value) {
2
3
  if (!value || typeof value !== "object")
3
4
  return false;
4
5
  const attachment = value;
6
+ if (attachment.kind === "citation")
7
+ return isCitationAttachment(value);
5
8
  if (typeof attachment.id !== "string" || !validSize(attachment.size))
6
9
  return false;
7
10
  if (attachment.kind === "paste") {
@@ -362,7 +365,10 @@ export function formatSize(bytes) {
362
365
  export function composeMessage(text, attachments) {
363
366
  const parts = [text.trim()];
364
367
  attachments.forEach((a, i) => {
365
- if (a.kind === "paste") {
368
+ if (a.kind === "citation") {
369
+ parts.push(serializeCitation(a));
370
+ }
371
+ else if (a.kind === "paste") {
366
372
  parts.push(`<pasted-text index="${i + 1}">\n${a.text}\n</pasted-text>`);
367
373
  }
368
374
  else if (a.kind === "image") {