openmausbot 0.1.93 → 0.1.94

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 (140) hide show
  1. package/dist/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-BBdBuCgB.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-BkYwilkG.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-bq1PTDTW.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BKpQmzrT.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +1 -0
  62. package/dist-server/companion/src/routes.js +21 -0
  63. package/dist-server/container-mcp.js +93 -76
  64. package/dist-server/drivers/agents-proxy.js +38 -1
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +1 -0
  67. package/dist-server/index.js +16942 -13191
  68. package/dist-server/local-computer.js +1 -0
  69. package/dist-server/mcp-gate.js +147 -19
  70. package/dist-server/mcp-remote-proxy.js +437 -0
  71. package/dist-server/openmausbot.js +8233 -7651
  72. package/dist-server/pair-cli.js +8233 -7651
  73. package/dist-server/permission-proxy.js +65 -1
  74. package/dist-server/proxy-paths.js +1 -0
  75. package/dist-server/server/auto-approve.js +33 -11
  76. package/dist-server/server/browser-live.js +14 -5
  77. package/dist-server/server/browser-runtime.js +6 -0
  78. package/dist-server/server/channel-queue.js +4 -1
  79. package/dist-server/server/cli.js +32 -12
  80. package/dist-server/server/cloud-move-http.js +9 -3
  81. package/dist-server/server/cloud-move.js +18 -0
  82. package/dist-server/server/computer-wait.js +33 -13
  83. package/dist-server/server/config.js +44 -10
  84. package/dist-server/server/decision-log.js +9 -6
  85. package/dist-server/server/delegations.js +47 -22
  86. package/dist-server/server/drivers/acp/core.js +57 -8
  87. package/dist-server/server/drivers/acp/grok.js +208 -3
  88. package/dist-server/server/drivers/agents-call.js +28 -0
  89. package/dist-server/server/drivers/agents-catalog.js +10 -1
  90. package/dist-server/server/drivers/boatagent.js +2 -0
  91. package/dist-server/server/drivers/chat-mcp-tools.js +27 -14
  92. package/dist-server/server/drivers/claude.js +18 -6
  93. package/dist-server/server/drivers/codex.js +84 -10
  94. package/dist-server/server/drivers/openai-chat.js +11 -3
  95. package/dist-server/server/drivers/pi.js +48 -4
  96. package/dist-server/server/environment.js +3 -0
  97. package/dist-server/server/guarded-requests.js +2 -1
  98. package/dist-server/server/index.js +1143 -254
  99. package/dist-server/server/live-call-controller.js +868 -0
  100. package/dist-server/server/live-call.js +216 -0
  101. package/dist-server/server/mcp-gate-config.js +43 -12
  102. package/dist-server/server/mcp-gate.js +127 -25
  103. package/dist-server/server/mcp-http.js +46 -8
  104. package/dist-server/server/mcp-remote-proxy.js +108 -0
  105. package/dist-server/server/new-bot-defaults.js +3 -0
  106. package/dist-server/server/peer-provenance.js +4 -2
  107. package/dist-server/server/permission-proxy.js +15 -1
  108. package/dist-server/server/plan-usage.js +797 -0
  109. package/dist-server/server/proxy-paths.js +1 -0
  110. package/dist-server/server/redact.js +2 -0
  111. package/dist-server/server/request-auth.js +3 -2
  112. package/dist-server/server/routes/live.js +98 -0
  113. package/dist-server/server/skill-library.js +207 -0
  114. package/dist-server/server/skills-library-migration.js +235 -0
  115. package/dist-server/server/skills.js +66 -3
  116. package/dist-server/server/steer-queue.js +13 -1
  117. package/dist-server/server/store.js +32 -4
  118. package/dist-server/server/surface.js +9 -3
  119. package/dist-server/server/team-backup.js +2 -1
  120. package/dist-server/server/thread-events.js +1 -1
  121. package/dist-server/server/tunnel.js +20 -1
  122. package/dist-server/server/turn-log.js +10 -0
  123. package/dist-server/server/turn-resources.js +124 -0
  124. package/dist-server/shared/ask-question.js +13 -0
  125. package/dist-server/shared/call-consent.js +28 -0
  126. package/dist-server/shared/live-approval.js +124 -0
  127. package/dist-server/shared/live-call.js +176 -0
  128. package/dist-server/shared/skill-md.js +10 -0
  129. package/dist-server/shared/team-backup.js +3 -0
  130. package/dist-server/shared/tool-scope-support.js +25 -0
  131. package/dist-server/shared/tool-scope.js +136 -0
  132. package/dist-server/src/lib/thread-refs.js +406 -0
  133. package/dist-server/vps-container-mcp.js +93 -76
  134. package/dist-server/workspace-backup.worker.js +89 -100
  135. package/enterprise/server/index.js +1 -0
  136. package/package.json +1 -1
  137. package/dist/assets/channel-BU94VpxE.js +0 -1
  138. package/dist/assets/index-Biji-bfL.css +0 -1
  139. package/dist/assets/index-DZlhoES0.js +0 -597
  140. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +0 -8
@@ -40,6 +40,7 @@ export const SPAWNED_PROXIES = {
40
40
  dweb: resolveProxy("drivers/dweb-proxy"),
41
41
  connectors: resolveProxy("connector-proxy"),
42
42
  mcpGate: resolveProxy("mcp-gate"),
43
+ mcpRemote: resolveProxy("mcp-remote-proxy"),
43
44
  phone: resolveProxy("drivers/phone-proxy"),
44
45
  hook: resolveProxy("hooks/omb-hook"),
45
46
  // Loaded by the external `pi` process via `-e`, not by this server — but
@@ -18,6 +18,8 @@ const SECRET_KEY_PARTS = ["token", "secret", "password", "passwd", "apikey", "ap
18
18
  * every real one is spelled (API_KEY, consumer-key, xai_key). */
19
19
  function isSecretName(name) {
20
20
  const lower = name.toLowerCase();
21
+ if (lower === "omb_gate_upstream" || lower === "omb_remote_mcp_server")
22
+ return true;
21
23
  if (SECRET_KEY_PARTS.some((part) => lower.includes(part)))
22
24
  return true;
23
25
  return /(^|[_.-])keys?$/.test(lower);
@@ -1,6 +1,6 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { isIP } from "node:net";
3
- import { denyReason as companionDenial } from "../companion/src/routes.js";
3
+ import { denyReason as companionDenial, isCompanionNotice } from "../companion/src/routes.js";
4
4
  const LOOPBACK_SCOPES = ["admin", "client"];
5
5
  const SERVICE_SCOPES = ["client"];
6
6
  /** What a `service`-trust loopback caller may reach, and nothing else:
@@ -442,7 +442,8 @@ export function resolveRequestAuth(req, options) {
442
442
  if (!secureTokenMatch(companionToken, options.companionMutationToken ?? "") ||
443
443
  req.headers["x-openmausbot-companion"] !== "1" ||
444
444
  !/^[\w-]{1,128}$/.test(headerValue(req.headers["x-openmausbot-companion-device"]) ?? "") ||
445
- companionDenial({ path, method, authenticated: true }))
445
+ // a phone's allowlisted route, or the companion's own notice (an unpaired phone)
446
+ (companionDenial({ path, method, authenticated: true }) && !isCompanionNotice(method, path)))
446
447
  return deny(403, "forbidden: invalid companion request");
447
448
  return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
448
449
  }
@@ -0,0 +1,98 @@
1
+ import { z } from "zod";
2
+ import { LiveSessionError, MAX_SDP_BYTES } from "../live-call.js";
3
+ import { LiveCallBusyError } from "../live-call-controller.js";
4
+ import { PASS } from "./table.js";
5
+ const id = z.string().regex(/^[\w-]{1,120}$/);
6
+ const sessionBody = z.object({
7
+ botId: id,
8
+ threadId: id.optional(),
9
+ // Not trimmed: the SDP offer goes to OpenAI byte for byte.
10
+ sdp: z.string().min(1).refine((sdp) => Buffer.byteLength(sdp) <= MAX_SDP_BYTES),
11
+ client: z.enum(["desktop", "ios", "android"]),
12
+ }).strict();
13
+ const endBody = z.object({ callId: z.string().min(1).max(120) }).strict();
14
+ // Strict, so `key` (or anything else) is refused rather than ignored: the key
15
+ // is only ever set on the computer that runs the harness.
16
+ const settingsBody = z.object({
17
+ voice: z.string().trim().max(40).regex(/^[a-z]*$/).optional(),
18
+ readTypedReplies: z.boolean().optional(),
19
+ idleMinutes: z.number().int().min(1).max(60).optional(),
20
+ }).strict();
21
+ const DEVICE_ID = /^[\w-]{1,128}$/;
22
+ /** The paired phone the companion vouched for, or undefined. The companion
23
+ * sends its own id for the phone that authenticated (never one the phone
24
+ * chose); in the desktop app the harness has already checked the companion's
25
+ * private token before this runs (server/request-auth.ts). */
26
+ function companionDevice(req) {
27
+ if (req.headers["x-openmausbot-companion"] !== "1")
28
+ return undefined;
29
+ const device = req.headers["x-openmausbot-companion-device"];
30
+ return typeof device === "string" && DEVICE_ID.test(device) ? device : undefined;
31
+ }
32
+ /** The parsed JSON body, or undefined when it is not JSON (the schema then refuses it). */
33
+ async function bodyOf(req, readBody) {
34
+ try {
35
+ return await readBody(req);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ export function createLiveRoutes(deps) {
42
+ return async ({ req, res, path, method, auth, json, readBody }) => {
43
+ if (!path.startsWith("/api/live/"))
44
+ return PASS;
45
+ if (method === "POST" && path === "/api/live/session") {
46
+ const parsed = sessionBody.safeParse(await bodyOf(req, readBody));
47
+ if (!parsed.success)
48
+ return json(res, 400, { error: "The call request was not valid." });
49
+ const target = deps.resolveTarget(parsed.data.botId, parsed.data.threadId);
50
+ if (!target)
51
+ return json(res, 404, { error: "That bot or chat does not exist." });
52
+ try {
53
+ // A phone's call is bound to the phone, so unpairing it ends the call.
54
+ const device = companionDevice(req);
55
+ const { call, sdp } = await deps.calls.start({ auth, ...(device ? { device } : {}), ...target, client: parsed.data.client, sdp: parsed.data.sdp });
56
+ return json(res, 201, { call, transport: { type: "webrtc", sdp } });
57
+ }
58
+ catch (error) {
59
+ if (error instanceof LiveCallBusyError)
60
+ return json(res, 409, { error: error.message, activeCall: error.call });
61
+ if (error instanceof LiveSessionError) {
62
+ // The one 409 a session refuses with is a missing key.
63
+ return json(res, error.status, error.status === 409 ? { error: error.message, needsKey: true } : { error: error.message });
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ if (method === "POST" && path === "/api/live/call/end") {
69
+ const parsed = endBody.safeParse(await bodyOf(req, readBody));
70
+ if (!parsed.success)
71
+ return json(res, 400, { error: "The request was not valid." });
72
+ const call = await deps.calls.end(parsed.data.callId);
73
+ return call ? json(res, 200, { call }) : json(res, 404, { error: "That call is not running." });
74
+ }
75
+ if (method === "GET" && path === "/api/live/call")
76
+ return json(res, 200, { call: deps.calls.current() });
77
+ // The companion unpaired a phone: end the call that phone holds, if any.
78
+ if (method === "POST" && path === "/api/live/device-revoked") {
79
+ if (req.headers["x-openmausbot-companion"] !== "1")
80
+ return json(res, 403, { error: "Only the phone companion can report an unpaired phone." });
81
+ const device = companionDevice(req);
82
+ if (!device)
83
+ return json(res, 400, { error: "The unpaired phone was not named." });
84
+ return json(res, 200, { call: deps.calls.deviceRevoked(device) });
85
+ }
86
+ if (method === "PATCH" && path === "/api/live/settings") {
87
+ const parsed = settingsBody.safeParse(await bodyOf(req, readBody));
88
+ if (!parsed.success) {
89
+ return json(res, 400, { error: "Those Live settings are not valid. The OpenAI key can only be changed on the computer that runs OpenMausBot." });
90
+ }
91
+ // as PUT /api/config: an empty patch is not saved (or broadcast)
92
+ if (!Object.keys(parsed.data).length)
93
+ return json(res, 400, { error: "nothing to save" });
94
+ return json(res, 200, { live: await deps.saveSettings(parsed.data) });
95
+ }
96
+ return PASS;
97
+ };
98
+ }
@@ -4,6 +4,13 @@
4
4
  // declared trigger terms and mounted capabilities.
5
5
  import { existsSync, readFileSync, readdirSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import { EventEmitter } from "node:events";
9
+ import { mkdirSync } from "node:fs";
10
+ import { z } from "zod";
11
+ import { writeFileAtomic } from "./atomic.js";
12
+ import { DATA_DIR } from "./config.js";
13
+ import { isSkillName, parseSkillMd, SKILL_FILE_MAX_BYTES } from "../shared/skill-md.js";
7
14
  const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
15
  function strings(value) {
9
16
  return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim())
@@ -114,3 +121,203 @@ export function renderSkillInstructions(selected, { includeRoot = false } = {})
114
121
  return "";
115
122
  return selected.map(({ manifest, instructions, directory }) => `\n\n<openmaus-skill id=${JSON.stringify(manifest.id)} version=${JSON.stringify(manifest.version)}${includeRoot ? ` root=${JSON.stringify(directory)}` : ""}>\n${instructions}\n</openmaus-skill>`).join("");
116
123
  }
124
+ const skillLibraryEntrySchema = z.object({
125
+ name: z.string(),
126
+ description: z.string(),
127
+ source: z.string(),
128
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
129
+ importedAt: z.string(),
130
+ reviewState: z.enum(["approved", "disabled"]),
131
+ license: z.string().optional(),
132
+ compatibility: z.string().optional(),
133
+ tags: z.array(z.string().regex(/^[a-z0-9][a-z0-9-]{0,23}$/)).max(8).optional(),
134
+ warnings: z.array(z.string()),
135
+ skippedFiles: z.array(z.string()),
136
+ package: z.object({
137
+ installId: z.string().regex(/^[a-f0-9]{32}$/),
138
+ key: z.string().min(1).max(64),
139
+ release: z.string().min(1).max(40),
140
+ r: z.string().regex(/^[a-f0-9]{64}$/),
141
+ w: z.string().regex(/^[a-f0-9]{64}$/),
142
+ via: z.literal("preset").optional(),
143
+ }).optional(),
144
+ });
145
+ const skillLibraryIndexSchema = z.record(z.string(), skillLibraryEntrySchema);
146
+ /** Every library write emits here so later skills-lane consumers can drop
147
+ * caches without polling. The payload names what changed. */
148
+ export const skillLibraryEvents = new EventEmitter();
149
+ export function skillsLibraryRoot(dataDir = DATA_DIR) {
150
+ return join(dataDir, "skills-library");
151
+ }
152
+ function libraryIndexPath(root) {
153
+ return join(root, "index.json");
154
+ }
155
+ export function librarySkillDirectory(root, name) {
156
+ return join(root, "skills", name);
157
+ }
158
+ /** The path agents are told to read for an assigned library skill. */
159
+ export function librarySkillFilePath(name, root = skillsLibraryRoot()) {
160
+ return join(root, "skills", name, "SKILL.md");
161
+ }
162
+ /** Where a migrated per-bot copy is preserved. Archives are write-once:
163
+ * migration never deletes the original bytes, it moves them here. */
164
+ export function libraryArchiveDirectory(root, botId, name) {
165
+ return join(root, "archive", botId, name);
166
+ }
167
+ /** A missing index is a legitimate empty library; an index that exists but
168
+ * cannot be read or validated is not, and writers must refuse rather than
169
+ * replace it and strand every stored skill. */
170
+ function readSkillLibraryIndexState(root) {
171
+ try {
172
+ const parsed = JSON.parse(readFileSync(libraryIndexPath(root), "utf8"));
173
+ const result = skillLibraryIndexSchema.safeParse(parsed);
174
+ return result.success
175
+ ? { index: result.data }
176
+ : { error: "the skills library index is invalid" };
177
+ }
178
+ catch (error) {
179
+ if (error.code === "ENOENT")
180
+ return { index: {} };
181
+ return { error: "the skills library index could not be read" };
182
+ }
183
+ }
184
+ export function readSkillLibraryIndex(root = skillsLibraryRoot()) {
185
+ const result = readSkillLibraryIndexState(root);
186
+ return "error" in result ? {} : result.index;
187
+ }
188
+ function writeSkillLibraryIndex(root, index) {
189
+ mkdirSync(root, { recursive: true, mode: 0o700 });
190
+ writeFileAtomic(libraryIndexPath(root), `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 });
191
+ }
192
+ function sha256Hex(text) {
193
+ return createHash("sha256").update(text).digest("hex");
194
+ }
195
+ function libraryContentMatches(root, entry) {
196
+ try {
197
+ const file = join(librarySkillDirectory(root, entry.name), "SKILL.md");
198
+ if (!existsSync(file))
199
+ return false;
200
+ return sha256Hex(readFileSync(file, "utf8")) === entry.sha256;
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ function librarySkillListing(root, entry) {
207
+ const intact = libraryContentMatches(root, entry);
208
+ return {
209
+ name: entry.name,
210
+ description: entry.description,
211
+ enabled: entry.reviewState === "approved" && intact,
212
+ editable: false,
213
+ source: entry.source,
214
+ sha256: entry.sha256,
215
+ importedAt: entry.importedAt,
216
+ ...(entry.license ? { license: entry.license } : {}),
217
+ ...(entry.compatibility ? { compatibility: entry.compatibility } : {}),
218
+ tags: entry.tags ?? [],
219
+ warnings: intact
220
+ ? entry.warnings
221
+ : [...entry.warnings, "stored SKILL.md changed after review — enablement is blocked"],
222
+ skippedFiles: entry.skippedFiles,
223
+ };
224
+ }
225
+ export function listLibrarySkills(root = skillsLibraryRoot()) {
226
+ return Object.values(readSkillLibraryIndex(root))
227
+ .map((entry) => librarySkillListing(root, entry))
228
+ .sort((a, b) => a.name.localeCompare(b.name));
229
+ }
230
+ /** Put a skill in the library. Installing the same name with the same
231
+ * SKILL.md bytes is idempotent (the existing entry wins); the same name
232
+ * with different bytes is refused, mirroring the per-bot import rule.
233
+ * Every failure returns `{ error }` — callers fall back. */
234
+ export function installLibrarySkill(input) {
235
+ const root = input.root ?? skillsLibraryRoot();
236
+ if (!isSkillName(input.name))
237
+ return { error: "invalid skill name" };
238
+ if (Buffer.byteLength(input.instructions, "utf8") > SKILL_FILE_MAX_BYTES) {
239
+ return { error: `SKILL.md is larger than ${SKILL_FILE_MAX_BYTES / 1024}KB` };
240
+ }
241
+ const parsed = parseSkillMd(input.instructions);
242
+ if ("error" in parsed)
243
+ return parsed;
244
+ if (parsed.name !== input.name)
245
+ return { error: `the skill is named "${parsed.name}", not "${input.name}"` };
246
+ const sha256 = sha256Hex(input.instructions);
247
+ const indexRead = readSkillLibraryIndexState(root);
248
+ if ("error" in indexRead)
249
+ return indexRead;
250
+ const index = indexRead.index;
251
+ const existing = index[input.name];
252
+ if (existing) {
253
+ if (existing.sha256 === sha256)
254
+ return librarySkillListing(root, existing);
255
+ return { error: `a skill named "${input.name}" is already in the library — choose a different name` };
256
+ }
257
+ // Callers such as the library migration do not pass tags; frontmatter
258
+ // tags parsed from the instructions are the source of record then.
259
+ const tags = input.tags?.length ? input.tags : parsed.tags;
260
+ const entry = {
261
+ name: input.name,
262
+ description: input.description?.trim() || parsed.description,
263
+ source: input.source,
264
+ sha256,
265
+ importedAt: new Date().toISOString(),
266
+ reviewState: input.reviewState ?? "disabled",
267
+ ...(input.license ? { license: input.license } : {}),
268
+ ...(input.compatibility ? { compatibility: input.compatibility } : {}),
269
+ ...(tags?.length ? { tags } : {}),
270
+ warnings: input.warnings ?? [],
271
+ skippedFiles: [],
272
+ ...(input.package ? { package: { ...input.package } } : {}),
273
+ };
274
+ // Bytes first, index second: the index is the commit point, so a crash
275
+ // between the two leaves unreferenced bytes, never a dangling entry.
276
+ mkdirSync(librarySkillDirectory(root, input.name), { recursive: true, mode: 0o700 });
277
+ writeFileAtomic(join(librarySkillDirectory(root, input.name), "SKILL.md"), input.instructions, { mode: 0o600 });
278
+ index[input.name] = entry;
279
+ writeSkillLibraryIndex(root, index);
280
+ skillLibraryEvents.emit("invalidate", { kind: "install", name: input.name });
281
+ return librarySkillListing(root, entry);
282
+ }
283
+ export function readLibrarySkillFile(name, root = skillsLibraryRoot()) {
284
+ if (!isSkillName(name))
285
+ return null;
286
+ const entry = readSkillLibraryIndex(root)[name];
287
+ if (!entry)
288
+ return null;
289
+ try {
290
+ const text = readFileSync(join(librarySkillDirectory(root, name), "SKILL.md"), "utf8");
291
+ return sha256Hex(text) === entry.sha256 ? text : null;
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ export function setLibrarySkillReviewState(name, reviewState, root = skillsLibraryRoot()) {
298
+ if (!isSkillName(name))
299
+ return { error: "invalid skill name" };
300
+ const index = readSkillLibraryIndex(root);
301
+ const entry = index[name];
302
+ if (!entry)
303
+ return { error: `no library skill named "${name}"` };
304
+ if (reviewState === "approved" && !libraryContentMatches(root, entry)) {
305
+ return { error: "stored SKILL.md changed after review — the library entry cannot be approved" };
306
+ }
307
+ entry.reviewState = reviewState;
308
+ writeSkillLibraryIndex(root, index);
309
+ skillLibraryEvents.emit("invalidate", { kind: "review-state", name });
310
+ return librarySkillListing(root, entry);
311
+ }
312
+ /** The lane's one resolution rule, stated once: a skill name means the
313
+ * bot-private copy when one exists, else the library entry, else the
314
+ * bundled skill. Later slices route every lookup through this order. */
315
+ export function resolveSkillSourceOrder(name, sources) {
316
+ if (sources.private !== undefined)
317
+ return { source: "private", name, value: sources.private };
318
+ if (sources.library !== undefined)
319
+ return { source: "library", name, value: sources.library };
320
+ if (sources.bundled !== undefined)
321
+ return { source: "bundled", name, value: sources.bundled };
322
+ return null;
323
+ }
@@ -0,0 +1,235 @@
1
+ // Skills library migration (features.skillsLibrary, skills lane S1).
2
+ //
3
+ // Per-bot skill copies move into the shared library, deduplicated by the
4
+ // reviewed SKILL.md sha256. Originals are archived under the library root —
5
+ // write-once, never deleted — and the per-bot manifest entry is dropped
6
+ // only after the archive exists and the assignment is durably recorded. The
7
+ // sweep is idempotent through state, not markers: a migrated skill has no
8
+ // manifest entry left to re-read, and an existing archive short-circuits
9
+ // the move.
10
+ import { createHash } from "node:crypto";
11
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { writeFileAtomic } from "./atomic.js";
14
+ import { installLibrarySkill, libraryArchiveDirectory, readSkillLibraryIndex, skillsLibraryRoot, } from "./skill-library.js";
15
+ import { listMigratableSkills, removeManifestEntry } from "./skills.js";
16
+ function sha256Hex(text) {
17
+ return createHash("sha256").update(text).digest("hex");
18
+ }
19
+ /** Move a per-bot skill directory into its archive slot. Same-volume moves
20
+ * rename; a cross-device fallback copies recursively and verifies the
21
+ * SKILL.md hash before the copy counts as the archive. */
22
+ function archiveSkillDirectory(source, archive, expectSha256) {
23
+ mkdirSync(dirname(archive), { recursive: true, mode: 0o700 });
24
+ // Every source directory gets its own slot: the same sha256 can be
25
+ // reinstalled after its manifest entry was cleared, so a collision takes
26
+ // a hash-qualified name, then numeric suffixes, never a silent drop.
27
+ let destination = archive;
28
+ if (existsSync(destination)) {
29
+ const stem = `${archive}.${expectSha256}`;
30
+ destination = stem;
31
+ let suffix = 1;
32
+ while (existsSync(destination))
33
+ destination = `${stem}.${suffix++}`;
34
+ }
35
+ try {
36
+ renameSync(source, destination);
37
+ }
38
+ catch (error) {
39
+ if (error.code !== "EXDEV")
40
+ throw error;
41
+ cpSync(source, destination, { recursive: true, force: false, errorOnExist: true });
42
+ }
43
+ const archived = readFileSync(join(destination, "SKILL.md"), "utf8");
44
+ if (sha256Hex(archived) !== expectSha256) {
45
+ throw new Error("the archived copy does not match the reviewed sha256");
46
+ }
47
+ }
48
+ /** Migrate one bot's per-bot skill copies into the library. Org-stamped
49
+ * skills carry their stamp onto the library entry, so an organization
50
+ * install becomes a library source instead of a parallel system. A bot is
51
+ * assigned only when its own copy was enabled; the first copy's enabled
52
+ * flag still decides the entry's reviewState. */
53
+ export function migrateBotSkillsToLibrary(botId, root = skillsLibraryRoot()) {
54
+ const outcomes = [];
55
+ const assigned = [];
56
+ for (const copy of listMigratableSkills(botId)) {
57
+ if (!copy.intact || !copy.directory) {
58
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: "stored SKILL.md is missing or changed after review" });
59
+ continue;
60
+ }
61
+ // The shared library currently stores SKILL.md only. Moving a package
62
+ // with scripts/references/assets would make its relative paths vanish
63
+ // from the bot's usable directory, so keep that working private copy.
64
+ try {
65
+ if (readdirSync(copy.directory).some((name) => name !== "SKILL.md")) {
66
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: "skill has auxiliary files — keeping its complete private directory" });
67
+ continue;
68
+ }
69
+ }
70
+ catch {
71
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: "could not inspect the private skill directory" });
72
+ continue;
73
+ }
74
+ let instructions;
75
+ try {
76
+ instructions = readFileSync(join(copy.directory, "SKILL.md"), "utf8");
77
+ }
78
+ catch (error) {
79
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: `could not read the stored SKILL.md: ${error instanceof Error ? error.message : String(error)}` });
80
+ continue;
81
+ }
82
+ if (sha256Hex(instructions) !== copy.sha256) {
83
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: "stored SKILL.md changed after review" });
84
+ continue;
85
+ }
86
+ const existing = readSkillLibraryIndex(root)[copy.name];
87
+ if (existing && existing.sha256 !== copy.sha256) {
88
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: `the library already has a different "${copy.name}" — rename one of them` });
89
+ continue;
90
+ }
91
+ // The library entry's review state is shared by every assigned bot, so
92
+ // deduplicating a copy whose own on/off differs from it would silently
93
+ // flip that bot's switch. Keep the per-bot copy in place for a human to
94
+ // reconcile instead of archiving and assigning it.
95
+ if (existing && (existing.reviewState === "approved") !== copy.enabled) {
96
+ outcomes.push({
97
+ botId,
98
+ name: copy.name,
99
+ outcome: "skipped",
100
+ detail: `the library "${copy.name}" is ${existing.reviewState === "approved" ? "enabled" : "disabled"} while this bot had it ${copy.enabled ? "enabled" : "disabled"} — align them before migrating`,
101
+ });
102
+ continue;
103
+ }
104
+ if (!existing) {
105
+ const installed = installLibrarySkill({
106
+ name: copy.name,
107
+ description: copy.description,
108
+ instructions,
109
+ source: copy.source,
110
+ license: copy.license,
111
+ compatibility: copy.compatibility,
112
+ reviewState: copy.enabled ? "approved" : "disabled",
113
+ package: copy.package,
114
+ root,
115
+ });
116
+ if ("error" in installed) {
117
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: installed.error });
118
+ continue;
119
+ }
120
+ outcomes.push({ botId, name: copy.name, outcome: "migrated" });
121
+ }
122
+ else {
123
+ outcomes.push({ botId, name: copy.name, outcome: "deduplicated", detail: "identical sha256 already in the library" });
124
+ }
125
+ // The manifest entry is the only re-readable record of this skill, so
126
+ // the assignment must be durable before it goes away. Record it in the
127
+ // pending-assignments file the boot sweep and the standalone script both
128
+ // replay: if this write — or the later patch to the bot record — fails,
129
+ // the next boot restores the assignment instead of stranding an
130
+ // archived skill with no manifest and no assignment.
131
+ try {
132
+ const pendingAssignments = readPendingAssignments(root);
133
+ if (copy.enabled)
134
+ writePendingAssignments({
135
+ ...pendingAssignments,
136
+ [botId]: [...new Set([...(pendingAssignments[botId] ?? []), ...assigned, copy.name])].sort(),
137
+ }, root);
138
+ }
139
+ catch (error) {
140
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: `could not record the assignment durably: ${error instanceof Error ? error.message : String(error)}` });
141
+ continue;
142
+ }
143
+ try {
144
+ archiveSkillDirectory(copy.directory, libraryArchiveDirectory(root, botId, copy.name), copy.sha256);
145
+ }
146
+ catch (error) {
147
+ // The library entry is idempotent, so leaving the per-bot copy in
148
+ // place only means the next sweep retries this skill. The assignment
149
+ // recorded above replays idempotently, so the extra pending entry a
150
+ // failed archive leaves behind is harmless.
151
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: `archiving failed: ${error instanceof Error ? error.message : String(error)}` });
152
+ continue;
153
+ }
154
+ const removed = removeManifestEntry(botId, copy.name, copy.sha256);
155
+ if ("error" in removed) {
156
+ outcomes.push({ botId, name: copy.name, outcome: "skipped", detail: removed.error });
157
+ continue;
158
+ }
159
+ // Assignment follows this bot's own copy.enabled: a disabled copy
160
+ // migrates its bytes but assigns nothing, so nobody gains a skill
161
+ // they never turned on.
162
+ if (copy.enabled)
163
+ assigned.push(copy.name);
164
+ }
165
+ return { ranAt: new Date().toISOString(), outcomes, assignments: assigned.length ? { [botId]: assigned } : {} };
166
+ }
167
+ export function pendingAssignmentsPath(root = skillsLibraryRoot()) {
168
+ return join(root, "pending-assignments.json");
169
+ }
170
+ /** Assignments waiting to reach bot records: recorded by the standalone
171
+ * script for a stopped server, and by the migration itself before a
172
+ * manifest entry is dropped. The next flag-on boot applies them through
173
+ * the Store, the single writer of bots.json. */
174
+ export function readPendingAssignments(root = skillsLibraryRoot()) {
175
+ try {
176
+ const parsed = JSON.parse(readFileSync(pendingAssignmentsPath(root), "utf8"));
177
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
178
+ return {};
179
+ const result = {};
180
+ for (const [botId, names] of Object.entries(parsed)) {
181
+ if (Array.isArray(names) && names.every((name) => typeof name === "string"))
182
+ result[botId] = [...names];
183
+ }
184
+ return result;
185
+ }
186
+ catch {
187
+ return {};
188
+ }
189
+ }
190
+ export function writePendingAssignments(assignments, root = skillsLibraryRoot()) {
191
+ mkdirSync(root, { recursive: true, mode: 0o700 });
192
+ writeFileAtomic(pendingAssignmentsPath(root), `${JSON.stringify(assignments, null, 2)}\n`, { mode: 0o600 });
193
+ }
194
+ /** The boot sweep: migrate every bot's copies, then apply assignments.
195
+ * With a `patch` callback (the live server) assignments land on the bot
196
+ * records directly and any script-recorded pending file is cleared; without
197
+ * one this only reports. Runs under features.skillsLibrary only. */
198
+ export function runSkillsLibraryBootSweep(options) {
199
+ const root = options.root ?? skillsLibraryRoot();
200
+ const outcomes = [];
201
+ const assignments = {};
202
+ const pending = options.patch ? readPendingAssignments(root) : {};
203
+ for (const bot of options.bots) {
204
+ const report = migrateBotSkillsToLibrary(bot.id, root);
205
+ outcomes.push(...report.outcomes);
206
+ const current = bot.assignedSkills ?? [];
207
+ const merged = [...new Set([...(pending[bot.id] ?? []), ...(report.assignments[bot.id] ?? []), ...current])].sort();
208
+ const changed = merged.length !== current.length || merged.some((name, index) => name !== current[index]);
209
+ if (!changed)
210
+ continue;
211
+ if (options.patch)
212
+ options.patch(bot.id, merged);
213
+ assignments[bot.id] = merged;
214
+ }
215
+ // Clear the pending file once every patch applied: entries recorded by
216
+ // the script before this boot and recovery records written by this run's
217
+ // migrations are both spent.
218
+ if (options.patch && (Object.keys(pending).length || Object.keys(readPendingAssignments(root)).length)) {
219
+ writePendingAssignments({}, root);
220
+ }
221
+ return { ranAt: new Date().toISOString(), outcomes, assignments };
222
+ }
223
+ /** Bot ids that have per-bot skill manifests on disk. Used by the
224
+ * standalone script, which cannot reach the Store. */
225
+ export function botIdsWithSkillState(dataDir) {
226
+ try {
227
+ return readdirSync(join(dataDir, "skill-state"), { withFileTypes: true })
228
+ .filter((entry) => entry.isDirectory())
229
+ .map((entry) => entry.name)
230
+ .sort();
231
+ }
232
+ catch {
233
+ return [];
234
+ }
235
+ }