@opengeni/core 2.9.3 → 2.9.4-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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "2.9.3",
3
+ "version": "2.9.4-canary.1",
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": {
@@ -58,17 +58,17 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@modelcontextprotocol/sdk": "^1.29.0",
61
- "@opengeni/capabilities": "^0.3.3",
62
- "@opengeni/codex": "^0.2.22",
63
- "@opengeni/config": "^1.1.1",
64
- "@opengeni/contracts": "^3.0.1",
65
- "@opengeni/db": "^4.3.2",
66
- "@opengeni/documents": "^0.8.26",
67
- "@opengeni/events": "^0.4.24",
68
- "@opengeni/network": "^0.3.1",
69
- "@opengeni/observability": "^0.8.25",
70
- "@opengeni/runtime": "^2.5.2",
71
- "@opengeni/storage": "^0.2.126",
61
+ "@opengeni/capabilities": "^0.3.3-canary.9",
62
+ "@opengeni/codex": "^0.2.22-canary.9",
63
+ "@opengeni/config": "^1.1.2-canary.1",
64
+ "@opengeni/contracts": "^3.0.2-canary.1",
65
+ "@opengeni/db": "^4.3.3-canary.1",
66
+ "@opengeni/documents": "^0.8.27-canary.1",
67
+ "@opengeni/events": "^0.4.25-canary.1",
68
+ "@opengeni/network": "^0.3.1-canary.9",
69
+ "@opengeni/observability": "^0.8.26-canary.1",
70
+ "@opengeni/runtime": "^2.5.3-canary.1",
71
+ "@opengeni/storage": "^0.2.127-canary.1",
72
72
  "hono": "^4.12.18",
73
73
  "zod": "^4.2.1"
74
74
  },
@@ -123,7 +123,7 @@ export async function buildCapabilityCatalog(input: {
123
123
  ...configuredMcpCatalogItems(input.settings),
124
124
  ...providerIntegrationCatalogItems(socialConnections),
125
125
  fikenCatalogItem(workspaceConnections.filter(isFikenConnection)),
126
- ...curatedLibrarySkills,
126
+ ...curatedLibrarySkills.filter((item) => installedSkillById.has(item.id)),
127
127
  ...installedSkills
128
128
  .filter(
129
129
  (skill) =>
@@ -980,9 +980,6 @@ export async function discoverMcpRegistryCapabilities(input: {
980
980
  if (!item || seen.has(item.id)) {
981
981
  continue;
982
982
  }
983
- if (query && !catalogSearchText(item).includes(query)) {
984
- continue;
985
- }
986
983
  seen.add(item.id);
987
984
  items.push(item);
988
985
  if (items.length >= limit) {
@@ -990,7 +987,9 @@ export async function discoverMcpRegistryCapabilities(input: {
990
987
  }
991
988
  }
992
989
  cursor = typeof page.metadata?.nextCursor === "string" ? page.metadata.nextCursor : undefined;
993
- if (!cursor) {
990
+ // Return usable results promptly rather than fetching more pages merely
991
+ // to fill the requested limit. The registry already applies the search.
992
+ if (items.length > 0 || !cursor) {
994
993
  break;
995
994
  }
996
995
  }
@@ -1996,22 +1995,6 @@ function validUrl(value: string | undefined): string | null {
1996
1995
  }
1997
1996
  }
1998
1997
 
1999
- function catalogSearchText(item: CapabilityCatalogItem): string {
2000
- return [
2001
- item.name,
2002
- item.description,
2003
- item.category,
2004
- ...item.tags,
2005
- item.endpointUrl,
2006
- item.homepageUrl,
2007
- item.installUrl,
2008
- JSON.stringify(item.metadata),
2009
- ]
2010
- .filter(Boolean)
2011
- .join(" ")
2012
- .toLowerCase();
2013
- }
2014
-
2015
1998
  function capabilityInstallationRuntimeReady(
2016
1999
  item: CapabilityCatalogItem,
2017
2000
  installation: CapabilityInstallation | undefined,
@@ -1,6 +1,12 @@
1
1
  import type { Settings } from "@opengeni/config";
2
+ import { HTTPException } from "hono/http-exception";
2
3
  import type { GitHubSkillSourceClient, GitHubSkillTreeEntry } from "./skill-imports";
3
4
  import { pinnedFetch, readResponseJsonBounded, readResponseTextBounded } from "@opengeni/network";
5
+ import {
6
+ PORTABLE_SKILL_MAX_FILES,
7
+ PORTABLE_SKILL_MAX_TOTAL_BYTES,
8
+ type SkillLibraryFile,
9
+ } from "@opengeni/runtime/skill-library";
4
10
 
5
11
  const githubApiBase = "https://api.github.com";
6
12
  const githubRequestTimeoutMs = 15_000;
@@ -14,7 +20,32 @@ export function createGitHubSkillSourceClient(
14
20
  requestJson: GitHubJsonRequest = (path, maxBytes, label) =>
15
21
  githubJson(settings, path, maxBytes, label),
16
22
  ): GitHubSkillSourceClient {
23
+ const readJson = requestJson;
24
+ const cache = new Map<string, { expires: number; payload: unknown }>();
25
+ const pending = new Map<string, Promise<unknown>>();
26
+ requestJson = async (path, maxBytes, label) => {
27
+ const cached = cache.get(path);
28
+ if (cached && cached.expires > Date.now()) return cached.payload;
29
+ const existing = pending.get(path);
30
+ if (existing) return existing;
31
+ const task = readJson(path, maxBytes, label).then((payload) => {
32
+ if (cache.size >= 128) cache.delete(cache.keys().next().value!);
33
+ cache.set(path, {
34
+ payload,
35
+ expires: Date.now() + (path.includes("/commits/") ? 60_000 : 3_600_000),
36
+ });
37
+ return payload;
38
+ });
39
+ pending.set(path, task);
40
+ try {
41
+ return await task;
42
+ } finally {
43
+ pending.delete(path);
44
+ }
45
+ };
17
46
  return {
47
+ downloadSnapshot: (owner, repository, slug) =>
48
+ downloadSkillSnapshot(settings, owner, repository, slug),
18
49
  resolveCommit: async (owner, repository, ref) => {
19
50
  const payload = recordValue(
20
51
  await requestJson(
@@ -91,6 +122,61 @@ export function createGitHubSkillSourceClient(
91
122
  };
92
123
  }
93
124
 
125
+ async function downloadSkillSnapshot(
126
+ settings: Settings,
127
+ owner: string,
128
+ repository: string,
129
+ slug: string,
130
+ ): Promise<readonly SkillLibraryFile[]> {
131
+ const signal = AbortSignal.timeout(githubRequestTimeoutMs);
132
+ const response = await pinnedFetch(
133
+ `https://skills.sh/api/download/${[owner, repository, slug].map(encodeURIComponent).join("/")}`,
134
+ { headers: { accept: "application/json" }, credentials: "omit", redirect: "manual", signal },
135
+ settings,
136
+ { label: "Skill download", requireHttpsOutsideLocalTest: true },
137
+ );
138
+ if (!response.ok) {
139
+ await response.body?.cancel();
140
+ throw new HTTPException(response.status === 429 ? 429 : 422, {
141
+ message:
142
+ response.status === 429
143
+ ? "skills.sh is busy. Try again shortly."
144
+ : response.status === 404
145
+ ? "This skill has no downloadable snapshot. Import its GitHub folder URL instead."
146
+ : "Could not download this skill from skills.sh. Try again shortly.",
147
+ });
148
+ }
149
+ // JSON escaping may expand the text payload. Artifact validation below applies
150
+ // the stricter limits to decoded files, paths, and total content size.
151
+ const payload = recordValue(
152
+ await readResponseJsonBounded(
153
+ response,
154
+ PORTABLE_SKILL_MAX_TOTAL_BYTES * 6 + 128_000,
155
+ "Skill download",
156
+ { signal },
157
+ ),
158
+ "Skill download",
159
+ );
160
+ if (
161
+ !Array.isArray(payload.files) ||
162
+ payload.files.length === 0 ||
163
+ payload.files.length > PORTABLE_SKILL_MAX_FILES
164
+ ) {
165
+ throw new HTTPException(422, { message: "The downloaded skill has an invalid file list" });
166
+ }
167
+ return payload.files.map((file) => {
168
+ if (
169
+ !file ||
170
+ typeof file !== "object" ||
171
+ typeof file.path !== "string" ||
172
+ typeof file.contents !== "string"
173
+ ) {
174
+ throw new HTTPException(422, { message: "The downloaded skill contains an invalid file" });
175
+ }
176
+ return { path: file.path, content: file.contents };
177
+ });
178
+ }
179
+
94
180
  async function githubJson(
95
181
  settings: Settings,
96
182
  path: string,
@@ -118,7 +204,10 @@ async function githubJson(
118
204
  await readResponseTextBounded(response, 8_192, `${label} error`).catch(() => undefined);
119
205
  if (response.status === 404) throw new Error(`${label} was not found or is not public`);
120
206
  if (response.status === 403 || response.status === 429) {
121
- throw new Error(`${label} is temporarily unavailable because GitHub limited the request`);
207
+ throw new HTTPException(429, {
208
+ message:
209
+ "GitHub's public request limit has been reached. Skill search is still available; try previewing again after the limit resets.",
210
+ });
122
211
  }
123
212
  throw new Error(`${label} failed with HTTP ${response.status}`);
124
213
  }
@@ -43,7 +43,7 @@ export const productIntegrationSkillFiles = [
43
43
  },
44
44
  {
45
45
  "path": "references/product-shapes-and-ui.md",
46
- "content": "# Product shapes and UI\n\n## Choose the smallest suitable surface\n\nOpenGeni supports several product shapes. Select from the product experience and host stack rather than assuming every integration needs a custom chat:\n\n| Need | Likely surface | Product owns |\n| --- | --- | --- |\n| The complete OpenGeni experience is acceptable | Link or deep-link to stock OpenGeni | Entry point and product navigation |\n| Custom UI in any framework, mobile app, CLI, or automation | OpenGeni SDK or public API behind product backend | All user-facing presentation |\n| React product wants canonical session state without packaged visuals | Headless React session hooks and projections | Components, layout, and styling |\n| React product wants packaged chat/session controls | Focused styled React subpaths | Shell, domain UI, and theming |\n| Product exposes files, changes, terminal, or desktop compute | Optional workbench surfaces | Product shell and selected tabs |\n\nStart with the narrowest surface that preserves the desired experience. Do not mount the full workbench for an ordinary analytics chat. Do not rebuild session streaming, replay, queueing, approval, or timeline projection when a compatible package already supplies the needed behavior.\n\n## Evaluate reuse before writing chat UI\n\nFor React hosts, inspect the installed OpenGeni React package before creating replacement components. Its subpaths are composable, and the styled surfaces use scoped compiled CSS plus runtime theme and density tokens. Compare:\n\n- packaged components with customer theme tokens;\n- headless hooks with customer-native components; and\n- a fully custom SDK-driven UI.\n\nChoose based on UX requirements and dependency compatibility, then record why. Styling differences alone are not a reason to skip reusable components if their structure fits. Conversely, do not force a packaged component when the product needs a materially different interaction model.\n\nFor Svelte, SvelteKit, Vue, native mobile, or another non-React frontend, use the product's native component system. Keep the privileged OpenGeni client on a compatible backend boundary. A SvelteKit server route may use the TypeScript SDK directly; a non-JavaScript backend may use the public HTTP contract or a small compatible adapter. The browser still speaks to authenticated product routes.\n\n## Browser/backend split\n\nThe product browser normally sends product-shaped requests to its own same-origin backend. The backend authenticates, resolves the allowed mapping, and calls OpenGeni. Never bundle an organization key into frontend code.\n\nFor live sessions, preserve event sequence, reconnect, replay, and duplicate suppression. The SDK's stream and proxy helpers are preferred where compatible. Treat unknown additive event types as forward-compatible data rather than crashing the UI.\n\nUploads may send bytes directly to a short-lived signed storage URL returned by the trusted flow. That URL is narrow transfer authority, not the OpenGeni API key. Verify storage CORS for every intended browser origin.\n\n## Decide what the user sees\n\nOpenGeni's durable event stream can support different product projections:\n\n- final answer only;\n- assistant messages plus progress and status;\n- selected tool-call summaries;\n- approvals and structured human-input cards; or\n- a detailed operational timeline.\n\nThe customer frontend chooses which event types and fields to render. Hiding an event from the chat view does not remove it from OpenGeni's durable history or from authorized audit readers. Do not promise data erasure or secrecy from presentation filtering.\n\nEven a final-answer-only UI should surface states the user must act on: failure, cancellation, credit or policy denial, approval requests, human-input requests, reconnect status, and a way to retry safely. Avoid presenting tool failures as ordinary assistant prose when product state can represent them more clearly.\n\n## Fit the host product\n\nFollow existing navigation, accessibility, responsive, loading, error, observability, localization, and design-system conventions. Keep OpenGeni IDs behind product-native identifiers. Make the smallest dependency addition that improves correctness.\n\nThe integration should feel native to the customer product while retaining OpenGeni's session semantics. Framework adaptation is expected; protocol reimplementation is not a goal.\n"
46
+ "content": "# Opening host-owned workbench tabs\n\nUse `SandboxWorkspace.openTabRequest={{ tab, requestId }}` to open a built-in or\nhost-injected tab from the host's UI. Increment `requestId` for each intentional\nopen, including repeated clicks on the same item. Keep artifact selection and\ninternal-link recognition in the host; the workbench only selects an available\ntab and expands the dock. Preserve modified clicks and external navigation.\nWhen handing off to a full-page artifact route, retain an explicit originating\nsession return path rather than relying on browser history.\n\n# Product shapes and UI\n\n## Choose the smallest suitable surface\n\nOpenGeni supports several product shapes. Select from the product experience and host stack rather than assuming every integration needs a custom chat:\n\n| Need | Likely surface | Product owns |\n| --- | --- | --- |\n| The complete OpenGeni experience is acceptable | Link or deep-link to stock OpenGeni | Entry point and product navigation |\n| Custom UI in any framework, mobile app, CLI, or automation | OpenGeni SDK or public API behind product backend | All user-facing presentation |\n| React product wants canonical session state without packaged visuals | Headless React session hooks and projections | Components, layout, and styling |\n| React product wants packaged chat/session controls | Focused styled React subpaths | Shell, domain UI, and theming |\n| Product exposes files, changes, terminal, or desktop compute | Optional workbench surfaces | Product shell and selected tabs |\n\nStart with the narrowest surface that preserves the desired experience. Do not mount the full workbench for an ordinary analytics chat. Do not rebuild session streaming, replay, queueing, approval, or timeline projection when a compatible package already supplies the needed behavior.\n\n## Evaluate reuse before writing chat UI\n\nFor React hosts, inspect the installed OpenGeni React package before creating replacement components. Its subpaths are composable, and the styled surfaces use scoped compiled CSS plus runtime theme and density tokens. Compare:\n\n- packaged components with customer theme tokens;\n- headless hooks with customer-native components; and\n- a fully custom SDK-driven UI.\n\nChoose based on UX requirements and dependency compatibility, then record why. Styling differences alone are not a reason to skip reusable components if their structure fits. Conversely, do not force a packaged component when the product needs a materially different interaction model.\n\nFor Svelte, SvelteKit, Vue, native mobile, or another non-React frontend, use the product's native component system. Keep the privileged OpenGeni client on a compatible backend boundary. A SvelteKit server route may use the TypeScript SDK directly; a non-JavaScript backend may use the public HTTP contract or a small compatible adapter. The browser still speaks to authenticated product routes.\n\n## Browser/backend split\n\nThe product browser normally sends product-shaped requests to its own same-origin backend. The backend authenticates, resolves the allowed mapping, and calls OpenGeni. Never bundle an organization key into frontend code.\n\nFor live sessions, preserve event sequence, reconnect, replay, and duplicate suppression. The SDK's stream and proxy helpers are preferred where compatible. Treat unknown additive event types as forward-compatible data rather than crashing the UI.\n\nUploads may send bytes directly to a short-lived signed storage URL returned by the trusted flow. That URL is narrow transfer authority, not the OpenGeni API key. Verify storage CORS for every intended browser origin.\n\n## Decide what the user sees\n\nOpenGeni's durable event stream can support different product projections:\n\n- final answer only;\n- assistant messages plus progress and status;\n- selected tool-call summaries;\n- approvals and structured human-input cards; or\n- a detailed operational timeline.\n\nThe customer frontend chooses which event types and fields to render. Hiding an event from the chat view does not remove it from OpenGeni's durable history or from authorized audit readers. Do not promise data erasure or secrecy from presentation filtering.\n\nEven a final-answer-only UI should surface states the user must act on: failure, cancellation, credit or policy denial, approval requests, human-input requests, reconnect status, and a way to retry safely. Avoid presenting tool failures as ordinary assistant prose when product state can represent them more clearly.\n\n## Fit the host product\n\nFollow existing navigation, accessibility, responsive, loading, error, observability, localization, and design-system conventions. Keep OpenGeni IDs behind product-native identifiers. Make the smallest dependency addition that improves correctness.\n\nThe integration should feel native to the customer product while retaining OpenGeni's session semantics. Framework adaptation is expected; protocol reimplementation is not a goal.\n"
47
47
  },
48
48
  {
49
49
  "path": "references/runtime-profile-and-verification.md",
@@ -19,6 +19,11 @@ export type GitHubSkillTreeEntry = Readonly<{
19
19
  }>;
20
20
 
21
21
  export type GitHubSkillSourceClient = Readonly<{
22
+ downloadSnapshot?(
23
+ owner: string,
24
+ repository: string,
25
+ slug: string,
26
+ ): Promise<readonly SkillLibraryFile[]>;
22
27
  resolveCommit(owner: string, repository: string, ref: string): Promise<string>;
23
28
  listTree(
24
29
  owner: string,
@@ -52,6 +57,10 @@ export async function resolveSkillImport(
52
57
  client: GitHubSkillSourceClient,
53
58
  ): Promise<ResolvedSkillImport> {
54
59
  const parsed = parseSkillSource(rawUrl);
60
+ if (parsed.source === "skills_sh" && client.downloadSnapshot) {
61
+ const files = await client.downloadSnapshot(parsed.owner, parsed.repository, parsed.skillSlug!);
62
+ return buildResolvedImport(parsed, files, parsed.skillSlug!, null);
63
+ }
55
64
  const sourceCommit = await client.resolveCommit(parsed.owner, parsed.repository, parsed.ref);
56
65
  if (!gitCommit.test(sourceCommit)) {
57
66
  throw new HTTPException(422, { message: "GitHub returned an invalid source commit" });
@@ -94,6 +103,15 @@ export async function resolveSkillImport(
94
103
  }
95
104
  return { path: relativeSkillPath(entry.path, sourcePath), content };
96
105
  });
106
+ return buildResolvedImport(parsed, files, sourcePath, sourceCommit);
107
+ }
108
+
109
+ function buildResolvedImport(
110
+ parsed: ParsedSkillSource,
111
+ files: readonly SkillLibraryFile[],
112
+ sourcePath: string,
113
+ commit: string | null,
114
+ ): ResolvedSkillImport {
97
115
  let artifact;
98
116
  try {
99
117
  artifact = buildPortableSkillArtifact(files);
@@ -103,8 +121,25 @@ export async function resolveSkillImport(
103
121
  });
104
122
  }
105
123
  const repositoryUrl = `https://github.com/${parsed.owner}/${parsed.repository}`;
106
- const sourceUrl =
107
- sourcePath === "."
124
+ // The existing revision field holds our content digest for registry snapshots;
125
+ // snapshots do not provide a Git commit or a repository-relative folder path.
126
+ const sourceCommit = commit ?? artifact.contentSha256;
127
+ if (
128
+ !commit &&
129
+ artifact.name
130
+ .toLowerCase()
131
+ .replace(/[\s_]+/g, "-")
132
+ .replace(/[^a-z0-9-]/g, "")
133
+ .replace(/-+/g, "-")
134
+ .replace(/^-|-$/g, "") !== parsed.skillSlug!.toLowerCase()
135
+ ) {
136
+ throw new HTTPException(422, {
137
+ message: "The downloaded skill does not match the requested name",
138
+ });
139
+ }
140
+ const sourceUrl = !commit
141
+ ? `https://skills.sh/${parsed.owner}/${parsed.repository}/${parsed.skillSlug}`
142
+ : sourcePath === "."
108
143
  ? `${repositoryUrl}/tree/${sourceCommit}`
109
144
  : `${repositoryUrl}/tree/${sourceCommit}/${encodeGitHubPath(sourcePath)}`;
110
145
  const warnings: string[] = [];
@@ -119,6 +154,7 @@ export async function resolveSkillImport(
119
154
  }
120
155
  return {
121
156
  preview: {
157
+ markdown: artifact.files.find((file) => file.path === "SKILL.md")?.content,
122
158
  source: parsed.source,
123
159
  sourceUrl,
124
160
  repositoryUrl,
@@ -173,6 +173,9 @@ function normalizeResponse(
173
173
  if (!isRecord(skill) || typeof skill.id !== "string" || typeof skill.source !== "string")
174
174
  throw invalid();
175
175
  const parts = skill.id.split("/");
176
+ // Website-hosted skills cannot use our GitHub import flow. Their presence
177
+ // must not discard the supported results in the same response.
178
+ if (parts.length === 2 && skill.source === parts[0]) continue;
176
179
  if (
177
180
  parts.length !== 3 ||
178
181
  !ownerPattern.test(parts[0]!) ||