@opengeni/core 2.8.0-canary.1 → 2.8.2-canary.1

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.
@@ -0,0 +1,218 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import { pinnedFetch, readResponseJsonBounded } from "@opengeni/network";
3
+
4
+ export type PublicSkillSearchInput = Readonly<{
5
+ query: string;
6
+ limit?: number;
7
+ owner?: string;
8
+ }>;
9
+
10
+ export type PublicSkillSearchResult = Readonly<{
11
+ provider: "skills_sh";
12
+ query: string;
13
+ items: readonly Readonly<{
14
+ id: string;
15
+ name: string;
16
+ source: string;
17
+ skillId: string;
18
+ url: string;
19
+ installs: number;
20
+ }>[];
21
+ nextCursor: null;
22
+ }>;
23
+
24
+ export type PublicSkillSearchClient = Readonly<{
25
+ search(input: PublicSkillSearchInput): Promise<PublicSkillSearchResult>;
26
+ }>;
27
+
28
+ export type PublicSkillSearchErrorCode =
29
+ | "invalid_query"
30
+ | "rate_limited"
31
+ | "timeout"
32
+ | "unavailable"
33
+ | "invalid_response";
34
+
35
+ export class PublicSkillSearchError extends Error {
36
+ constructor(
37
+ readonly code: PublicSkillSearchErrorCode,
38
+ message: string,
39
+ readonly retryAfterSeconds: number | null = null,
40
+ ) {
41
+ super(message);
42
+ this.name = "PublicSkillSearchError";
43
+ }
44
+ }
45
+
46
+ // Undocumented, unauthenticated compatibility endpoint used by the official CLI.
47
+ // Verified 2026-09-08 against vercel-labs/skills commit
48
+ // 1682051d48c34f5eb135e6475c1a965dce05e820, src/find.ts. This is NOT the documented
49
+ // /api/v1/skills/search API (which requires Vercel OIDC). Keep this contract behind
50
+ // this adapter: no promised pagination/quota, no CLI dependency or credentials.
51
+ const endpoint = "https://skills.sh/api/search";
52
+ const timeoutMs = 10_000;
53
+ const maxResponseBytes = 256 * 1024;
54
+ const maxResults = 20;
55
+ const ownerPattern = /^[a-z0-9](?:[a-z0-9-]{0,38})$/iu;
56
+ const segmentPattern = /^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/iu;
57
+
58
+ export function createPublicSkillSearchClient(
59
+ settings: Settings,
60
+ fetcher: typeof pinnedFetch = pinnedFetch,
61
+ ): PublicSkillSearchClient {
62
+ return {
63
+ async search(input) {
64
+ const query = input.query.trim();
65
+ const limit = input.limit ?? maxResults;
66
+ const owner = input.owner?.trim().toLowerCase();
67
+ if (
68
+ query.length < 2 ||
69
+ query.length > 200 ||
70
+ /[\u0000-\u001f\u007f]/u.test(query) ||
71
+ !Number.isSafeInteger(limit) ||
72
+ limit < 1 ||
73
+ limit > maxResults ||
74
+ (owner !== undefined && !ownerPattern.test(owner))
75
+ ) {
76
+ throw new PublicSkillSearchError("invalid_query", "Invalid public Skill search parameters");
77
+ }
78
+ const url = new URL(endpoint);
79
+ url.searchParams.set("q", query);
80
+ url.searchParams.set("limit", String(limit));
81
+ if (owner) url.searchParams.set("owner", owner);
82
+ const controller = new AbortController();
83
+ let timer: ReturnType<typeof setTimeout> | undefined;
84
+ const deadline = new Promise<never>((_, reject) => {
85
+ timer = setTimeout(() => {
86
+ controller.abort();
87
+ reject(new PublicSkillSearchError("timeout", "Public Skill search timed out"));
88
+ }, timeoutMs);
89
+ });
90
+ try {
91
+ return await Promise.race([
92
+ deadline,
93
+ (async () => {
94
+ const response = await fetcher(
95
+ url,
96
+ {
97
+ method: "GET",
98
+ headers: { accept: "application/json" },
99
+ credentials: "omit",
100
+ redirect: "manual",
101
+ signal: controller.signal,
102
+ },
103
+ settings,
104
+ { label: "Public Skill search", requireHttpsOutsideLocalTest: true },
105
+ );
106
+ if (controller.signal.aborted || !response.ok) {
107
+ void response.body?.cancel().catch(() => undefined);
108
+ if (controller.signal.aborted) {
109
+ throw new PublicSkillSearchError("timeout", "Public Skill search timed out");
110
+ }
111
+ if (response.status === 429) {
112
+ throw new PublicSkillSearchError(
113
+ "rate_limited",
114
+ "Public Skill search is rate limited",
115
+ parseRetryAfter(response.headers.get("retry-after")),
116
+ );
117
+ }
118
+ throw new PublicSkillSearchError("unavailable", "Public Skill search is unavailable");
119
+ }
120
+ let payload: unknown;
121
+ try {
122
+ payload = await readResponseJsonBounded(
123
+ response,
124
+ maxResponseBytes,
125
+ "Public Skill search",
126
+ {
127
+ signal: controller.signal,
128
+ },
129
+ );
130
+ } catch {
131
+ throw new PublicSkillSearchError(
132
+ controller.signal.aborted ? "timeout" : "invalid_response",
133
+ controller.signal.aborted
134
+ ? "Public Skill search timed out"
135
+ : "Public Skill search returned an invalid response",
136
+ );
137
+ }
138
+ return normalizeResponse(payload, query, limit, owner);
139
+ })(),
140
+ ]);
141
+ } catch (error) {
142
+ if (error instanceof PublicSkillSearchError) throw error;
143
+ throw new PublicSkillSearchError(
144
+ controller.signal.aborted ? "timeout" : "unavailable",
145
+ controller.signal.aborted
146
+ ? "Public Skill search timed out"
147
+ : "Public Skill search is unavailable",
148
+ );
149
+ } finally {
150
+ clearTimeout(timer);
151
+ }
152
+ },
153
+ };
154
+ }
155
+
156
+ function normalizeResponse(
157
+ payload: unknown,
158
+ query: string,
159
+ limit: number,
160
+ owner: string | undefined,
161
+ ): PublicSkillSearchResult {
162
+ const invalid = () =>
163
+ new PublicSkillSearchError(
164
+ "invalid_response",
165
+ "Public Skill search returned an invalid response",
166
+ );
167
+ if (!isRecord(payload) || !Array.isArray(payload.skills) || payload.skills.length > maxResults) {
168
+ throw invalid();
169
+ }
170
+ const seen = new Set<string>();
171
+ const items: PublicSkillSearchResult["items"][number][] = [];
172
+ for (const skill of payload.skills) {
173
+ if (!isRecord(skill) || typeof skill.id !== "string" || typeof skill.source !== "string")
174
+ throw invalid();
175
+ const parts = skill.id.split("/");
176
+ if (
177
+ parts.length !== 3 ||
178
+ !ownerPattern.test(parts[0]!) ||
179
+ !parts.every((part) => segmentPattern.test(part)) ||
180
+ skill.source !== parts.slice(0, 2).join("/") ||
181
+ (skill.skillId !== undefined && skill.skillId !== parts[2]) ||
182
+ typeof skill.name !== "string" ||
183
+ !skill.name.trim() ||
184
+ skill.name.length > 200 ||
185
+ /[\u0000-\u001f\u007f]/u.test(skill.name) ||
186
+ typeof skill.installs !== "number" ||
187
+ !Number.isSafeInteger(skill.installs) ||
188
+ skill.installs < 0 ||
189
+ (owner !== undefined && parts[0]!.toLowerCase() !== owner)
190
+ )
191
+ throw invalid();
192
+ if (seen.has(skill.id)) continue;
193
+ seen.add(skill.id);
194
+ items.push({
195
+ id: skill.id,
196
+ name: skill.name.trim(),
197
+ source: skill.source,
198
+ skillId: parts[2]!,
199
+ url: `https://skills.sh/${parts.map(encodeURIComponent).join("/")}`,
200
+ installs: skill.installs,
201
+ });
202
+ }
203
+ return { provider: "skills_sh", query, items: items.slice(0, limit), nextCursor: null };
204
+ }
205
+
206
+ function isRecord(value: unknown): value is Record<string, unknown> {
207
+ return typeof value === "object" && value !== null && !Array.isArray(value);
208
+ }
209
+
210
+ function parseRetryAfter(value: string | null): number | null {
211
+ if (value === null) return null;
212
+ if (/^\d+$/u.test(value)) {
213
+ const seconds = Number(value);
214
+ return Number.isSafeInteger(seconds) ? seconds : null;
215
+ }
216
+ const date = Date.parse(value);
217
+ return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1000)) : null;
218
+ }
@@ -0,0 +1,87 @@
1
+ import type {
2
+ SkillFile,
3
+ SkillInstallInput,
4
+ SkillRevisionInput,
5
+ SkillSaveInput,
6
+ } from "@opengeni/contracts";
7
+ import { validateSkillTextFiles } from "@opengeni/contracts";
8
+ import { buildPortableSkillArtifact } from "@opengeni/runtime/skill-library";
9
+ import {
10
+ applySkillLifecycle,
11
+ listSkillRecords,
12
+ skillFilesContentHash,
13
+ type Database,
14
+ type SkillReadContext,
15
+ } from "@opengeni/db";
16
+ export { replayPortableSkillInstall } from "@opengeni/db";
17
+
18
+ /** Canonical text-only folder; rejects invalid Unicode before PostgreSQL conversion. */
19
+ export function validateSkillFiles(files: readonly SkillFile[]): SkillFile[] {
20
+ validateSkillTextFiles(files);
21
+ const result = files.map(({ path, content }) => ({ path, content }));
22
+ if (!result.some((file) => file.path === "SKILL.md" && file.content.trim()))
23
+ throw new Error("Skill requires nonempty SKILL.md");
24
+ return result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
25
+ }
26
+
27
+ /** Separate from the historical SKILL.md hash, which must never be rewritten. */
28
+ export function skillBundleHash(files: readonly SkillFile[]): string {
29
+ return skillFilesContentHash(validateSkillFiles(files));
30
+ }
31
+
32
+ export const listSkills = listSkillRecords;
33
+ export async function readSkill(
34
+ db: Database,
35
+ context: SkillReadContext,
36
+ skillId: string,
37
+ revisionId?: string,
38
+ ) {
39
+ return (
40
+ (
41
+ await listSkillRecords(db, context, {
42
+ skillId,
43
+ ...(revisionId ? { revisionId } : {}),
44
+ limit: 1,
45
+ })
46
+ )[0] ?? null
47
+ );
48
+ }
49
+ export async function saveSkill(db: Database, input: SkillSaveInput) {
50
+ const { accountId, workspaceId, actor, ...request } = input;
51
+ const artifact = buildPortableSkillArtifact(validateSkillFiles(input.files));
52
+ return applySkillLifecycle(
53
+ db,
54
+ { accountId, workspaceId, actor },
55
+ {
56
+ ...request,
57
+ operation: "save",
58
+ files: artifact.files,
59
+ title: artifact.name,
60
+ description: artifact.description,
61
+ },
62
+ );
63
+ }
64
+ export async function installSkill(db: Database, input: SkillInstallInput) {
65
+ const { accountId, workspaceId, actor, ...request } = input;
66
+ return applySkillLifecycle(
67
+ db,
68
+ { accountId, workspaceId, actor },
69
+ { ...request, operation: "install" },
70
+ );
71
+ }
72
+ export async function approveSkill(db: Database, input: SkillRevisionInput) {
73
+ const { accountId, workspaceId, actor, ...request } = input;
74
+ return applySkillLifecycle(
75
+ db,
76
+ { accountId, workspaceId, actor },
77
+ { ...request, operation: "approve" },
78
+ );
79
+ }
80
+ export async function restoreSkill(db: Database, input: SkillRevisionInput) {
81
+ const { accountId, workspaceId, actor, ...request } = input;
82
+ return applySkillLifecycle(
83
+ db,
84
+ { accountId, workspaceId, actor },
85
+ { ...request, operation: "restore" },
86
+ );
87
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./domain/skills";
1
2
  // @opengeni/core — the framework-agnostic OpenGeni core.
2
3
  //
3
4
  // WHAT THIS PACKAGE IS: the OpenGeni domain, access, and billing layers carved
@@ -71,6 +72,8 @@ export * from "./billing/limits";
71
72
  export * from "./domain/capabilities";
72
73
  export * from "./domain/host-mcp-authority-source-admission";
73
74
  export * from "./domain/skill-imports";
75
+ export * from "./domain/skill-search";
76
+ export * from "./domain/github-skill-source";
74
77
  export * from "./domain/environments";
75
78
  export * from "./rigs";
76
79
  export * from "./domain/packs";
@@ -12,6 +12,11 @@
12
12
  // over the events bus) lives here, not in the leaf (which stays db-free).
13
13
 
14
14
  import { sandboxLifecycleTransitionWaitMs, type Settings } from "@opengeni/config";
15
+ import { appendSessionCommandOutput } from "@opengeni/db/session-command-output";
16
+ import {
17
+ createProviderCommandRetainer,
18
+ retainedProviderCommandPersistence,
19
+ } from "@opengeni/db/retained-provider-commands";
15
20
  import {
16
21
  advanceWorkspaceGenerationForDirectRequest,
17
22
  advanceWorkspaceGenerationForRetainedProcess,
@@ -21,6 +26,7 @@ import {
21
26
  markWarmLeaseInstanceLost,
22
27
  readActiveSandbox,
23
28
  retainWorkspaceMutationProcess,
29
+ SandboxRetainedProcessPromotionFencedError,
24
30
  retainedProcessSettlementIdentity,
25
31
  settleRetainedProcess,
26
32
  verifyDirectWorkspaceMutationSettlement,
@@ -58,6 +64,11 @@ type PersistableMutationAdmission = {
58
64
  > | null;
59
65
  };
60
66
 
67
+ const retainWorkspaceProviderCommand = createProviderCommandRetainer(
68
+ retainWorkspaceMutationProcess,
69
+ (error) => (error instanceof SandboxRetainedProcessPromotionFencedError ? error.process : null),
70
+ );
71
+
61
72
  type DirectRetainedProcessRoute = {
62
73
  providerSessionId: number;
63
74
  providerBackend: string;
@@ -310,12 +321,15 @@ export function wrapChannelABoxWithRouting(
310
321
  throw new Error("API-direct workspace mutation settlement lacked its bound admission");
311
322
  }
312
323
  if (outcome === "resolved" && retainedProcess) {
313
- await retainWorkspaceMutationProcess(db, {
324
+ await retainWorkspaceProviderCommand(db, {
314
325
  accountId: ids.accountId,
315
326
  workspaceId: ids.workspaceId,
316
327
  sessionId: ids.sessionId,
317
328
  processId: retainedProcess.id,
318
329
  providerSessionId: retainedProcess.providerSessionId,
330
+ ...(retainedProcess.providerCommand
331
+ ? { providerCommand: retainedProcess.providerCommand }
332
+ : {}),
319
333
  admissionId: exactAdmission.id,
320
334
  admittedWorkspaceGeneration: exactAdmission.workspaceGeneration,
321
335
  operation: op,
@@ -516,6 +530,31 @@ export function wrapChannelABoxWithRouting(
516
530
  });
517
531
 
518
532
  const proxy = new RoutingSandboxSession({
533
+ providerCommandHandle: (value) =>
534
+ value && typeof value === "object"
535
+ ? (value as PersistableMutationAdmission).admission?.workspaceGeneration
536
+ : undefined,
537
+ providerCommandPersistence: (process) =>
538
+ retainedProviderCommandPersistence(db, {
539
+ accountId: ids.accountId,
540
+ workspaceId: ids.workspaceId,
541
+ sessionId: ids.sessionId,
542
+ processId: process.id,
543
+ }),
544
+ captureProcessOutput: async ({ process, chunkId, chunk, stream, streamFidelity }) => {
545
+ const events = await appendSessionCommandOutput(db, {
546
+ accountId: ids.accountId,
547
+ workspaceId: ids.workspaceId,
548
+ sessionId: ids.sessionId,
549
+ commandId: process.id,
550
+ chunkId,
551
+ chunk,
552
+ stream,
553
+ streamFidelity,
554
+ });
555
+ if (events.length && bus)
556
+ await bus.publish(ids.workspaceId, ids.sessionId, events).catch(() => undefined);
557
+ },
519
558
  bindActiveRouteOnFirstResolve: true,
520
559
  defaultResolved: {
521
560
  session: established.session as RoutableBackendSession,
@@ -1,6 +1,7 @@
1
1
  import type { Settings } from "@opengeni/config";
2
2
  import {
3
3
  CapabilityPack,
4
+ StoredCapabilityPack,
4
5
  type RigVersion,
5
6
  type Session,
6
7
  type SandboxBackend,
@@ -72,8 +73,14 @@ export async function resolveWorkspaceLegacyRuntimePacks(
72
73
  continue;
73
74
  }
74
75
  const registration = await getWorkspacePack(db, workspaceId, installation.packId);
75
- const parsed = CapabilityPack.safeParse(registration?.pack);
76
- if (parsed.success) packs.push(parsed.data);
76
+ if (!registration)
77
+ throw new Error(`Enabled Pack ${installation.packId} has no registered manifest`);
78
+ const parsed = StoredCapabilityPack.safeParse(registration.pack);
79
+ if (!parsed.success)
80
+ throw new Error(
81
+ `Enabled Pack ${installation.packId} requires repair: ${parsed.error.message}`,
82
+ );
83
+ packs.push(parsed.data);
77
84
  }
78
85
  return packs;
79
86
  }
@@ -31,6 +31,9 @@ export type TranscriptionRequest = {
31
31
  providerDeadlineAt?: Date | undefined;
32
32
  /** Exact provider selected before a resumable segment is first sent upstream. */
33
33
  providerId?: string | undefined;
34
+ preferredProvider?: string | null | undefined;
35
+ fallbackEnabled?: boolean | undefined;
36
+ excludedProviders?: readonly string[] | undefined;
34
37
  };
35
38
 
36
39
  export type TranscriptionResult = TranscribeAudioResponse & {
@@ -44,18 +47,22 @@ export class TranscriptionServiceError extends Error {
44
47
  readonly code: VoiceInputErrorCode;
45
48
  readonly status: number;
46
49
  readonly retryable: boolean;
50
+ /** Explicit rejection before any transcription result; safe to try another provider. */
51
+ readonly fallbackSafe: boolean;
47
52
 
48
53
  constructor(input: {
49
54
  code: VoiceInputErrorCode;
50
55
  message: string;
51
56
  status?: number;
52
57
  retryable?: boolean;
58
+ fallbackSafe?: boolean;
53
59
  }) {
54
60
  super(input.message);
55
61
  this.name = "TranscriptionServiceError";
56
62
  this.code = input.code;
57
63
  this.status = input.status ?? statusForVoiceInputError(input.code);
58
64
  this.retryable = input.retryable ?? false;
65
+ this.fallbackSafe = input.fallbackSafe ?? false;
59
66
  }
60
67
  }
61
68
 
@@ -88,6 +95,10 @@ export function statusForVoiceInputError(code: VoiceInputErrorCode): number {
88
95
 
89
96
  /** Optional workspace scope for readiness checks during provider selection. */
90
97
  export type TranscriptionAvailabilityContext = {
98
+ afterProvider?: string | undefined;
99
+ preferredProvider?: string | null | undefined;
100
+ fallbackEnabled?: boolean | undefined;
101
+ excludedProviders?: readonly string[] | undefined;
91
102
  workspaceId?: string | undefined;
92
103
  subjectId?: string | undefined;
93
104
  };
@@ -95,7 +106,8 @@ export type TranscriptionAvailabilityContext = {
95
106
  /**
96
107
  * Extensible transcription provider port. Implementations own credentials and
97
108
  * upstream request shape. Selection happens before audio is sent; providers must
98
- * not fall back to another vendor after an upstream request may have started.
109
+ * only fall back after an explicit rejection and before any successful or
110
+ * uncertain provider attempt. Recording persistence owns that eligibility.
99
111
  */
100
112
  export type TranscriptionProvider = {
101
113
  readonly id: string;