@opengeni/core 2.8.0-canary.0 → 2.8.2-canary.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.
@@ -28,6 +28,9 @@ export type TranscriptionRequest = {
28
28
  providerDeadlineAt?: Date | undefined;
29
29
  /** Exact provider selected before a resumable segment is first sent upstream. */
30
30
  providerId?: string | undefined;
31
+ preferredProvider?: string | null | undefined;
32
+ fallbackEnabled?: boolean | undefined;
33
+ excludedProviders?: readonly string[] | undefined;
31
34
  };
32
35
  export type TranscriptionResult = TranscribeAudioResponse & {
33
36
  /** Server-private provider id for operational metrics only. Never returned to clients. */
@@ -39,23 +42,31 @@ export declare class TranscriptionServiceError extends Error {
39
42
  readonly code: VoiceInputErrorCode;
40
43
  readonly status: number;
41
44
  readonly retryable: boolean;
45
+ /** Explicit rejection before any transcription result; safe to try another provider. */
46
+ readonly fallbackSafe: boolean;
42
47
  constructor(input: {
43
48
  code: VoiceInputErrorCode;
44
49
  message: string;
45
50
  status?: number;
46
51
  retryable?: boolean;
52
+ fallbackSafe?: boolean;
47
53
  });
48
54
  }
49
55
  export declare function statusForVoiceInputError(code: VoiceInputErrorCode): number;
50
56
  /** Optional workspace scope for readiness checks during provider selection. */
51
57
  export type TranscriptionAvailabilityContext = {
58
+ afterProvider?: string | undefined;
59
+ preferredProvider?: string | null | undefined;
60
+ fallbackEnabled?: boolean | undefined;
61
+ excludedProviders?: readonly string[] | undefined;
52
62
  workspaceId?: string | undefined;
53
63
  subjectId?: string | undefined;
54
64
  };
55
65
  /**
56
66
  * Extensible transcription provider port. Implementations own credentials and
57
67
  * upstream request shape. Selection happens before audio is sent; providers must
58
- * not fall back to another vendor after an upstream request may have started.
68
+ * only fall back after an explicit rejection and before any successful or
69
+ * uncertain provider attempt. Recording persistence owns that eligibility.
59
70
  */
60
71
  export type TranscriptionProvider = {
61
72
  readonly id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "2.8.0-canary.0",
3
+ "version": "2.8.2-canary.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -54,17 +54,17 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@modelcontextprotocol/sdk": "^1.29.0",
57
- "@opengeni/capabilities": "^0.3.3-canary.0",
58
- "@opengeni/codex": "^0.2.22-canary.0",
59
- "@opengeni/config": "^1.0.1-canary.0",
60
- "@opengeni/contracts": "^2.14.0-canary.0",
61
- "@opengeni/db": "^4.1.0-canary.0",
62
- "@opengeni/documents": "^0.8.20-canary.0",
63
- "@opengeni/events": "^0.4.18-canary.0",
64
- "@opengeni/network": "^0.3.1-canary.0",
65
- "@opengeni/observability": "^0.8.20-canary.0",
66
- "@opengeni/runtime": "^2.4.0-canary.0",
67
- "@opengeni/storage": "^0.2.121-canary.0",
57
+ "@opengeni/capabilities": "^0.3.3-canary.2",
58
+ "@opengeni/codex": "^0.2.22-canary.2",
59
+ "@opengeni/config": "^1.0.3-canary.0",
60
+ "@opengeni/contracts": "^2.15.1-canary.0",
61
+ "@opengeni/db": "^4.2.1-canary.0",
62
+ "@opengeni/documents": "^0.8.22-canary.0",
63
+ "@opengeni/events": "^0.4.20-canary.0",
64
+ "@opengeni/network": "^0.3.1-canary.2",
65
+ "@opengeni/observability": "^0.8.22-canary.0",
66
+ "@opengeni/runtime": "^2.4.2-canary.0",
67
+ "@opengeni/storage": "^0.2.123-canary.0",
68
68
  "hono": "^4.12.18",
69
69
  "zod": "^4.2.1"
70
70
  },
@@ -287,6 +287,7 @@ export async function forkManagedHumanSession(
287
287
  const forkInput = {
288
288
  sourceWorkspaceId: workspaceId,
289
289
  sourceSessionId,
290
+ ...(request.sourceEventId ? { sourceEventId: request.sourceEventId } : {}),
290
291
  actorSubjectId: authorization.grant.subjectId,
291
292
  destinationWorkspaceId: workspaceId,
292
293
  destinationVisibility:
@@ -9,7 +9,7 @@ import type {
9
9
  SessionAuthorizationPort,
10
10
  TurnInitiator,
11
11
  } from "@opengeni/contracts";
12
- import type { Database } from "@opengeni/db";
12
+ import type { Database, SessionWorkflowWakeDeliveryResult } from "@opengeni/db";
13
13
  import type { DocumentServices } from "@opengeni/documents";
14
14
  import type { EventBus } from "@opengeni/events";
15
15
  import type { Observability } from "@opengeni/observability";
@@ -44,7 +44,9 @@ export type SessionWorkflowClient = {
44
44
  workflowId: string;
45
45
  wakeRevision: number;
46
46
  interruptionRequested?: boolean;
47
- }) => Promise<void>;
47
+ /** Called after transport acceptance, before the fallible durable ACK. */
48
+ onSignalAccepted?: () => void;
49
+ }) => Promise<SessionWorkflowWakeDeliveryResult | void>;
48
50
  /** Trigger one bounded drain of already-committed workflow-wake revisions. */
49
51
  requestSessionWorkflowWakeDispatch: () => Promise<void>;
50
52
  // Dedicated, revision-carrying nudge for a durable Codex capacity waiter.
@@ -0,0 +1,142 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { GitHubSkillSourceClient, GitHubSkillTreeEntry } from "./skill-imports";
3
+ import { pinnedFetch, readResponseJsonBounded, readResponseTextBounded } from "@opengeni/network";
4
+
5
+ const githubApiBase = "https://api.github.com";
6
+ const githubRequestTimeoutMs = 15_000;
7
+ const githubMetadataMaxBytes = 4 * 1024 * 1024;
8
+ const githubBlobResponseMaxBytes = 512 * 1024;
9
+
10
+ type GitHubJsonRequest = (path: string, maxBytes: number, label: string) => Promise<unknown>;
11
+
12
+ export function createGitHubSkillSourceClient(
13
+ settings: Settings,
14
+ requestJson: GitHubJsonRequest = (path, maxBytes, label) =>
15
+ githubJson(settings, path, maxBytes, label),
16
+ ): GitHubSkillSourceClient {
17
+ return {
18
+ resolveCommit: async (owner, repository, ref) => {
19
+ const payload = recordValue(
20
+ await requestJson(
21
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/commits/${encodeURIComponent(ref)}`,
22
+ githubMetadataMaxBytes,
23
+ "GitHub Skill commit",
24
+ ),
25
+ "GitHub commit",
26
+ );
27
+ const sha = stringValue(payload.sha);
28
+ if (!sha) throw new Error("GitHub commit response omitted sha");
29
+ return sha.toLowerCase();
30
+ },
31
+ listTree: async (owner, repository, commit) => {
32
+ const payload = recordValue(
33
+ await requestJson(
34
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/git/trees/${encodeURIComponent(commit)}?recursive=1`,
35
+ githubMetadataMaxBytes,
36
+ "GitHub Skill tree",
37
+ ),
38
+ "GitHub tree",
39
+ );
40
+ if (payload.truncated === true) {
41
+ throw new Error("GitHub repository tree is too large to import safely");
42
+ }
43
+ if (!Array.isArray(payload.tree)) throw new Error("GitHub tree response omitted entries");
44
+ return payload.tree.map((entry, index): GitHubSkillTreeEntry => {
45
+ const record = recordValue(entry, `GitHub tree entry ${index}`);
46
+ const path = stringValue(record.path);
47
+ const type = stringValue(record.type);
48
+ const mode = stringValue(record.mode);
49
+ const sha = stringValue(record.sha);
50
+ const size = record.size;
51
+ if (
52
+ !path ||
53
+ (type !== "blob" && type !== "tree" && type !== "commit") ||
54
+ !mode ||
55
+ !sha ||
56
+ (size !== undefined &&
57
+ size !== null &&
58
+ (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0))
59
+ ) {
60
+ throw new Error(`GitHub tree entry ${index} is invalid`);
61
+ }
62
+ return { path, type, mode, sha, size: typeof size === "number" ? size : null };
63
+ });
64
+ },
65
+ readBlob: async (owner, repository, sha) => {
66
+ const payload = recordValue(
67
+ await requestJson(
68
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/git/blobs/${encodeURIComponent(sha)}`,
69
+ githubBlobResponseMaxBytes,
70
+ "GitHub Skill file",
71
+ ),
72
+ "GitHub blob",
73
+ );
74
+ if (payload.encoding !== "base64" || typeof payload.content !== "string") {
75
+ throw new Error("GitHub Skill file did not use base64 encoding");
76
+ }
77
+ const normalized = payload.content.replace(/\s+/gu, "");
78
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(normalized)) {
79
+ throw new Error("GitHub Skill file contained invalid base64");
80
+ }
81
+ const bytes = Uint8Array.from(Buffer.from(normalized, "base64"));
82
+ if (
83
+ typeof payload.size === "number" &&
84
+ Number.isSafeInteger(payload.size) &&
85
+ payload.size !== bytes.byteLength
86
+ ) {
87
+ throw new Error("GitHub Skill file size did not match its payload");
88
+ }
89
+ return bytes;
90
+ },
91
+ };
92
+ }
93
+
94
+ async function githubJson(
95
+ settings: Settings,
96
+ path: string,
97
+ maxBytes: number,
98
+ label: string,
99
+ ): Promise<unknown> {
100
+ const controller = new AbortController();
101
+ const timeout = setTimeout(() => controller.abort(), githubRequestTimeoutMs);
102
+ try {
103
+ const response = await pinnedFetch(
104
+ `${githubApiBase}${path}`,
105
+ {
106
+ method: "GET",
107
+ headers: {
108
+ accept: "application/vnd.github+json",
109
+ "user-agent": "OpenGeni-Capabilities",
110
+ "x-github-api-version": "2022-11-28",
111
+ },
112
+ signal: controller.signal,
113
+ },
114
+ settings,
115
+ { label, requireHttpsOutsideLocalTest: true },
116
+ );
117
+ if (!response.ok) {
118
+ await readResponseTextBounded(response, 8_192, `${label} error`).catch(() => undefined);
119
+ if (response.status === 404) throw new Error(`${label} was not found or is not public`);
120
+ if (response.status === 403 || response.status === 429) {
121
+ throw new Error(`${label} is temporarily unavailable because GitHub limited the request`);
122
+ }
123
+ throw new Error(`${label} failed with HTTP ${response.status}`);
124
+ }
125
+ return await readResponseJsonBounded(response, maxBytes, label, {
126
+ signal: controller.signal,
127
+ });
128
+ } finally {
129
+ clearTimeout(timeout);
130
+ }
131
+ }
132
+
133
+ function recordValue(value: unknown, label: string): Record<string, unknown> {
134
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
135
+ throw new Error(`${label} response is invalid`);
136
+ }
137
+ return value as Record<string, unknown>;
138
+ }
139
+
140
+ function stringValue(value: unknown): string | null {
141
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
142
+ }
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import {
4
4
  CapabilityPack,
5
+ StoredCapabilityPack,
5
6
  OPENGENI_PR_REVIEW_PACK_ID,
6
7
  OPENGENI_PR_REVIEW_SESSION_ROLE,
7
8
  stableJson,
@@ -298,9 +299,8 @@ export function isBuiltInCapabilityPack(packId: string): boolean {
298
299
 
299
300
  /**
300
301
  * Built-in packs plus the manifests registered for this workspace. Stored
301
- * manifests were validated at registration time; rows that no longer parse
302
- * (for example after a contract tightening) are skipped instead of breaking
303
- * the whole catalog.
302
+ * manifests retain exact historical bytes. Execution views derive Skill labels
303
+ * from their files, but malformed content needs explicit repair, not omission.
304
304
  */
305
305
  export async function listWorkspaceCapabilityPacks(
306
306
  db: Database,
@@ -310,9 +310,13 @@ export async function listWorkspaceCapabilityPacks(
310
310
  const builtInIds = new Set(packs.map((pack) => pack.id));
311
311
  const registeredPacks = registered
312
312
  .filter((registration) => !builtInIds.has(registration.pack.id))
313
- .flatMap((registration) => {
314
- const parsed = CapabilityPack.safeParse(registration.pack);
315
- return parsed.success ? [parsed.data] : [];
313
+ .map((registration) => {
314
+ const parsed = StoredCapabilityPack.safeParse(registration.pack);
315
+ if (!parsed.success)
316
+ throw new HTTPException(422, {
317
+ message: `Stored Pack ${registration.pack.id} requires repair before it can be used: ${parsed.error.message}`,
318
+ });
319
+ return parsed.data;
316
320
  });
317
321
  return [...packs, ...registeredPacks];
318
322
  }
@@ -330,8 +334,12 @@ export async function resolveCapabilityPack(
330
334
  if (!registration) {
331
335
  return null;
332
336
  }
333
- const parsed = CapabilityPack.safeParse(registration.pack);
334
- return parsed.success ? parsed.data : null;
337
+ const parsed = StoredCapabilityPack.safeParse(registration.pack);
338
+ if (!parsed.success)
339
+ throw new HTTPException(422, {
340
+ message: `Stored Pack ${packId} requires repair before it can be used: ${parsed.error.message}`,
341
+ });
342
+ return parsed.data;
335
343
  }
336
344
 
337
345
  export function capabilityPackManifestDigest(pack: CapabilityPack): string {
@@ -37,6 +37,7 @@ The desired outcome is a native-feeling product experience backed by a standalon
37
37
  - When an unknown choice is reversible and low-risk, choose the best-fitting default, state the assumption, and continue. When it changes privacy, tenant authority, write access, cost exposure, or an external mutation, resolve it before crossing that boundary.
38
38
  - Possession of a credential or access to a cloud, repository, or deployment is technical capability, not authorization. Match the user's requested delivery autonomy and the repository's stated workflow.
39
39
  - Keep alternatives open until evidence eliminates them. Use strict rules only for actual security, privacy, protocol, or authorization invariants.
40
+ - For packaged React chat, use SessionConversation or compose MessageTimeline and ChatComposer with the normal SDK and authenticated session routes. For a custom or compatible frontend, use the optional backend chat handler.
40
41
 
41
42
  Read the references selectively:
42
43
 
@@ -50,6 +50,7 @@ export class RememberError extends Error {
50
50
  | "proposal_unavailable"
51
51
  | "proposal_not_confirmable"
52
52
  | "human_confirmation_unavailable"
53
+ | "preference_retired"
53
54
  | "baseline_stale",
54
55
  message: string,
55
56
  ) {
@@ -327,6 +328,12 @@ export function createRememberRouter(options: RememberRouterOptions): {
327
328
  async remember(input) {
328
329
  const attempt = await attemptOf(input.attempt);
329
330
  const request = RememberRequest.parse(input.request);
331
+ if (request.lane === "preference") {
332
+ throw new RememberError(
333
+ "preference_retired",
334
+ "The Remember preference lane is retired; use skill_save for shared Skill files. No note or proposal was created.",
335
+ );
336
+ }
330
337
  const note = await createNote(options.db, {
331
338
  ...attempt,
332
339
  operationId: derivedRememberOperationId(request.operationId, "note"),
@@ -21,6 +21,7 @@ import {
21
21
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
22
22
  OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
23
23
  resolveWorkspaceSessionToolDefaults,
24
+ resolveBundledSkillSelection,
24
25
  SessionAgentAccess,
25
26
  SessionEndUser,
26
27
  SessionMemoryScope,
@@ -480,6 +481,14 @@ export async function validateScheduledTaskTarget(input: {
480
481
  if (!session || session.accountId !== input.grant.accountId) {
481
482
  throw new HTTPException(404, { message: "target session not found" });
482
483
  }
484
+ if (
485
+ input.agentConfig.bundledSkillIds !== undefined &&
486
+ !isDeepStrictEqual(input.agentConfig.bundledSkillIds, session.bundledSkillIds)
487
+ ) {
488
+ throw new HTTPException(422, {
489
+ message: "An existing-session schedule cannot change that session's bundled Skill selection",
490
+ });
491
+ }
483
492
  if (session.status === "cancelled") {
484
493
  throw new HTTPException(409, {
485
494
  message: "target session is cancelled; choose a revivable session",
@@ -1301,6 +1310,24 @@ async function validateScheduledTaskAgentConfig(input: {
1301
1310
  workspaceId: string;
1302
1311
  toolsProvided?: boolean;
1303
1312
  }): Promise<ScheduledTaskAgentConfig> {
1313
+ const actor = creationInitiatorForGrant(input.grant).actor;
1314
+ const parent = actor ? await getSession(input.db, input.workspaceId, actor.sessionId) : null;
1315
+ if (actor && (!parent || parent.accountId !== input.grant.accountId)) {
1316
+ throw new HTTPException(403, {
1317
+ message: "Scheduled Skill selection requires the creating agent's session",
1318
+ });
1319
+ }
1320
+ let bundledSkillIds: ScheduledTaskAgentConfig["bundledSkillIds"];
1321
+ try {
1322
+ bundledSkillIds = resolveBundledSkillSelection(
1323
+ input.payload.agentConfig.bundledSkillIds,
1324
+ parent?.bundledSkillIds,
1325
+ );
1326
+ } catch (error) {
1327
+ throw new HTTPException(422, {
1328
+ message: error instanceof Error ? error.message : "Invalid bundled Skill selection",
1329
+ });
1330
+ }
1304
1331
  // Reject a curated-out model before touching the DB: a scheduled task is a
1305
1332
  // session the worker runs later, so it must pass the same allow-list as the
1306
1333
  // session choke points (a `scheduled_tasks:manage` holder could otherwise set
@@ -1382,6 +1409,7 @@ async function validateScheduledTaskAgentConfig(input: {
1382
1409
  }
1383
1410
  const validated = {
1384
1411
  ...input.payload.agentConfig,
1412
+ ...(bundledSkillIds !== undefined ? { bundledSkillIds } : {}),
1385
1413
  ...(model === undefined || model === null ? {} : { model }),
1386
1414
  prompt,
1387
1415
  resources,
@@ -22,6 +22,7 @@ import {
22
22
  FIRST_PARTY_MCP_TOOL_NAMES,
23
23
  OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
24
24
  SessionSkills,
25
+ resolveBundledSkillSelection,
25
26
  SessionSpawnDenial,
26
27
  ServiceTurnInitiator,
27
28
  ServiceTurnInitiatorContext,
@@ -747,6 +748,7 @@ export async function createAndStartSessionWithOutcome(input: {
747
748
  modelContext?: string | null;
748
749
  resources: ResourceRef[];
749
750
  skills?: SessionSkill[];
751
+ bundledSkillIds?: import("@opengeni/contracts").BundledSkillId[] | undefined;
750
752
  tools: ToolRef[];
751
753
  // Public admission always supplies provenance; optional keeps internal
752
754
  // callers that predate durable tool-policy provenance source-compatible
@@ -995,6 +997,7 @@ export async function createAndStartSessionWithOutcome(input: {
995
997
  initialModelContext: input.modelContext ?? null,
996
998
  resources: input.resources,
997
999
  skills: input.skills ?? [],
1000
+ bundledSkillIds: input.bundledSkillIds,
998
1001
  tools: input.tools,
999
1002
  toolPolicy: input.toolPolicy,
1000
1003
  metadata: sessionMetadata,
@@ -1088,6 +1091,7 @@ export async function createAndStartSessionWithOutcome(input: {
1088
1091
  initialModelContext: input.modelContext ?? null,
1089
1092
  resources: input.resources,
1090
1093
  skills: input.skills ?? [],
1094
+ bundledSkillIds: input.bundledSkillIds,
1091
1095
  tools: input.tools,
1092
1096
  toolPolicy: input.toolPolicy,
1093
1097
  metadata: sessionMetadata,
@@ -2038,6 +2042,17 @@ export async function createSessionForRequestWithOutcome(
2038
2042
  message: error instanceof Error ? error.message : "invalid child visibility",
2039
2043
  });
2040
2044
  }
2045
+ let bundledSkillIds: import("@opengeni/contracts").BundledSkillId[] | undefined;
2046
+ try {
2047
+ bundledSkillIds = resolveBundledSkillSelection(
2048
+ payload.bundledSkillIds,
2049
+ parentSession?.bundledSkillIds,
2050
+ );
2051
+ } catch (error) {
2052
+ throw new HTTPException(422, {
2053
+ message: error instanceof Error ? error.message : "Invalid bundled Skill selection",
2054
+ });
2055
+ }
2041
2056
  // Agent-access/end-user/memory scope inherit and narrow exactly like
2042
2057
  // visibility: presence is read from the raw request because the Zod
2043
2058
  // defaults erase absent-vs-explicit, and the parent side comes from the
@@ -2092,6 +2107,7 @@ export async function createSessionForRequestWithOutcome(
2092
2107
  ) {
2093
2108
  try {
2094
2109
  const initializedReplay = await getInitializedSessionCreateReplay(db, {
2110
+ bundledSkillIds,
2095
2111
  accountId: grant.accountId,
2096
2112
  workspaceId,
2097
2113
  subjectId: replayManagedHumanSubjectId ?? grant.subjectId,
@@ -2941,6 +2957,7 @@ export async function createSessionForRequestWithOutcome(
2941
2957
  modelContext: payload.modelContext ?? null,
2942
2958
  resources,
2943
2959
  skills,
2960
+ bundledSkillIds,
2944
2961
  tools,
2945
2962
  toolPolicy,
2946
2963
  ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import type { SkillImportPreview, SkillImportSource } from "@opengeni/contracts";
3
3
  import {
4
4
  buildPortableSkillArtifact,
5
+ parsePortableSkillFrontmatter,
6
+ PORTABLE_SKILL_MAX_FILE_BYTES,
5
7
  PORTABLE_SKILL_MAX_FILES,
6
8
  PORTABLE_SKILL_MAX_TOTAL_BYTES,
7
9
  type SkillLibraryFile,
@@ -55,7 +57,18 @@ export async function resolveSkillImport(
55
57
  throw new HTTPException(422, { message: "GitHub returned an invalid source commit" });
56
58
  }
57
59
  const tree = await client.listTree(parsed.owner, parsed.repository, sourceCommit);
58
- const sourcePath = selectSkillRoot(parsed, tree);
60
+ // Metadata discovery and the selected artifact share immutable blobs. This
61
+ // avoids reading SKILL.md twice without caching mutable refs or remote URLs.
62
+ const blobs = new Map<string, Promise<Uint8Array>>();
63
+ const readBlob = (sha: string) => {
64
+ let pending = blobs.get(sha);
65
+ if (!pending) {
66
+ pending = client.readBlob(parsed.owner, parsed.repository, sha);
67
+ blobs.set(sha, pending);
68
+ }
69
+ return pending;
70
+ };
71
+ const sourcePath = await selectSkillRoot(parsed, tree, readBlob, sourceCommit);
59
72
  const entries = skillFilesUnderRoot(tree, sourcePath);
60
73
  const declaredBytes = entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0);
61
74
  if (entries.length > PORTABLE_SKILL_MAX_FILES) {
@@ -69,11 +82,11 @@ export async function resolveSkillImport(
69
82
  });
70
83
  }
71
84
  const files = await mapConcurrent(entries, maxConcurrentBlobReads, async (entry) => {
85
+ // Keep provider/network failures distinct from UTF-8 validation failures.
86
+ const bytes = await readBlob(entry.sha);
72
87
  let content: string;
73
88
  try {
74
- content = new TextDecoder("utf-8", { fatal: true }).decode(
75
- await client.readBlob(parsed.owner, parsed.repository, entry.sha),
76
- );
89
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
77
90
  } catch {
78
91
  throw new HTTPException(422, {
79
92
  message: `Skill file is not valid UTF-8 text: ${relativeSkillPath(entry.path, sourcePath)}`,
@@ -159,7 +172,12 @@ export function parseSkillSource(rawUrl: string): ParsedSkillSource {
159
172
  message: "Skill imports require a credential-free HTTPS URL without a fragment",
160
173
  });
161
174
  }
162
- const segments = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
175
+ let segments: string[];
176
+ try {
177
+ segments = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
178
+ } catch {
179
+ throw new HTTPException(422, { message: "The Skill URL contains invalid encoding" });
180
+ }
163
181
  if (url.hostname === "skills.sh" || url.hostname === "www.skills.sh") {
164
182
  if (segments.length !== 3) {
165
183
  throw new HTTPException(422, {
@@ -212,14 +230,14 @@ export function parseSkillSource(rawUrl: string): ParsedSkillSource {
212
230
  const ref = segments[3];
213
231
  if (!ref) throw new HTTPException(422, { message: "The GitHub URL is missing a revision" });
214
232
  const pathSegments = segments.slice(4);
215
- if (pathSegments.length === 0) {
233
+ if (pathSegments.length === 0 && mode === "blob") {
216
234
  throw new HTTPException(422, { message: "The GitHub URL is missing a Skill folder" });
217
235
  }
218
- const requestedPath = normalizeGitHubPath(
236
+ const folderSegments =
219
237
  mode === "blob" && pathSegments.at(-1)?.toLowerCase() === "skill.md"
220
238
  ? pathSegments.slice(0, -1)
221
- : pathSegments,
222
- );
239
+ : pathSegments;
240
+ const requestedPath = folderSegments.length === 0 ? "." : normalizeGitHubPath(folderSegments);
223
241
  return {
224
242
  source: "github",
225
243
  owner,
@@ -231,7 +249,12 @@ export function parseSkillSource(rawUrl: string): ParsedSkillSource {
231
249
  };
232
250
  }
233
251
 
234
- function selectSkillRoot(source: ParsedSkillSource, tree: readonly GitHubSkillTreeEntry[]): string {
252
+ async function selectSkillRoot(
253
+ source: ParsedSkillSource,
254
+ tree: readonly GitHubSkillTreeEntry[],
255
+ readBlob: (sha: string) => Promise<Uint8Array>,
256
+ sourceCommit: string,
257
+ ): Promise<string> {
235
258
  const skillFiles = tree
236
259
  .filter(
237
260
  (entry) =>
@@ -239,7 +262,7 @@ function selectSkillRoot(source: ParsedSkillSource, tree: readonly GitHubSkillTr
239
262
  entry.mode !== "120000" &&
240
263
  (entry.path === "SKILL.md" || entry.path.endsWith("/SKILL.md")),
241
264
  )
242
- .map((entry) => entry.path.slice(0, -"/SKILL.md".length) || ".")
265
+ .map((entry) => (entry.path === "SKILL.md" ? "." : entry.path.slice(0, -"/SKILL.md".length)))
243
266
  .sort();
244
267
  if (source.requestedPath) {
245
268
  const root = source.requestedPath;
@@ -250,9 +273,61 @@ function selectSkillRoot(source: ParsedSkillSource, tree: readonly GitHubSkillTr
250
273
  }
251
274
  return root;
252
275
  }
253
- const candidates = source.skillSlug
254
- ? skillFiles.filter((path) => path.split("/").at(-1) === source.skillSlug)
255
- : skillFiles;
276
+ let candidates = skillFiles;
277
+ if (source.skillSlug) {
278
+ // skills.sh identifies frontmatter names, not necessarily folder basenames
279
+ // (e.g. vercel-react-best-practices lives in skills/react-best-practices).
280
+ // Bound discovery as well as the eventual artifact; exact folder URLs avoid
281
+ // this scan for very large repositories or duplicate names.
282
+ if (skillFiles.length > PORTABLE_SKILL_MAX_FILES) {
283
+ throw new HTTPException(422, {
284
+ message: "Too many Skill candidates; paste the exact GitHub folder URL",
285
+ });
286
+ }
287
+ let metadataBytes = 0;
288
+ const entriesByPath = new Map(tree.map((entry) => [entry.path, entry]));
289
+ const matches = await mapConcurrent(skillFiles, maxConcurrentBlobReads, async (root) => {
290
+ const entry = entriesByPath.get(root === "." ? "SKILL.md" : `${root}/SKILL.md`)!;
291
+ if ((entry.size ?? 0) > PORTABLE_SKILL_MAX_FILE_BYTES) {
292
+ throw new HTTPException(422, {
293
+ message: "Skill metadata is too large; paste the exact GitHub folder URL",
294
+ });
295
+ }
296
+ const bytes = await readBlob(entry.sha);
297
+ metadataBytes += bytes.byteLength;
298
+ if (
299
+ bytes.byteLength > PORTABLE_SKILL_MAX_FILE_BYTES ||
300
+ metadataBytes > PORTABLE_SKILL_MAX_TOTAL_BYTES
301
+ ) {
302
+ throw new HTTPException(422, {
303
+ message: "Skill metadata is too large; paste the exact GitHub folder URL",
304
+ });
305
+ }
306
+ let markdown: string;
307
+ try {
308
+ markdown = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
309
+ } catch {
310
+ throw new HTTPException(422, {
311
+ message: "Skill metadata is not valid UTF-8; paste the exact GitHub folder URL",
312
+ });
313
+ }
314
+ return (
315
+ parsePortableSkillFrontmatter(markdown).name?.toLowerCase() ===
316
+ source.skillSlug!.toLowerCase()
317
+ );
318
+ });
319
+ candidates = skillFiles.filter((_, index) => matches[index]);
320
+ if (candidates.length === 0) {
321
+ // A folder basename is not a skills.sh identity. Never silently import a
322
+ // different frontmatter name from a stale directory-shaped link.
323
+ const folders = skillFiles.filter((path) => path.split("/").at(-1) === source.skillSlug);
324
+ const exactPath =
325
+ folders.length === 1 ? encodeGitHubPath(folders[0]!) : "<exact-skill-folder-path>";
326
+ throw new HTTPException(422, {
327
+ message: `No Skill frontmatter name matches skills.sh slug "${source.skillSlug}"; the link may be stale. Check the current Skill name, or explicitly select the intended Skill using its exact GitHub folder URL: https://github.com/${source.owner}/${source.repository}/tree/${sourceCommit}/${exactPath}`,
328
+ });
329
+ }
330
+ }
256
331
  if (candidates.length === 0) {
257
332
  throw new HTTPException(422, { message: "No Skill folder with SKILL.md was found" });
258
333
  }
@@ -332,13 +407,22 @@ async function mapConcurrent<Input, Output>(
332
407
  ): Promise<Output[]> {
333
408
  const output = new Array<Output>(values.length);
334
409
  let next = 0;
410
+ let failed = false;
335
411
  await Promise.all(
336
412
  Array.from({ length: Math.min(concurrency, values.length) }, async () => {
337
413
  for (;;) {
414
+ if (failed) return;
338
415
  const index = next;
339
416
  next += 1;
340
417
  if (index >= values.length) return;
341
- output[index] = await map(values[index]!);
418
+ try {
419
+ output[index] = await map(values[index]!);
420
+ } catch (error) {
421
+ // Let existing reads settle, but do not launch further provider calls
422
+ // after any peer detects a failed request or an exceeded byte budget.
423
+ failed = true;
424
+ throw error;
425
+ }
342
426
  }
343
427
  }),
344
428
  );