@agent-native/core 0.185.0 → 0.186.0-nightly-20260923163746

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 (44) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/actions/finalize-recording.ts +35 -1
  3. package/corpus/templates/clips/app/components/player/delete-recording-menu.tsx +5 -1
  4. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +18 -1
  5. package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +2 -2
  6. package/corpus/templates/clips/app/routes/record.tsx +1 -0
  7. package/corpus/templates/clips/changelog/2026-09-23-long-recording-menus-scroll-within-the-visible-space-so-ever.md +6 -0
  8. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +14 -4
  9. package/dist/agent/engine/openai-compatible-endpoint.d.ts +7 -0
  10. package/dist/agent/engine/openai-compatible-endpoint.js +9 -0
  11. package/dist/agent/engine/provider-endpoint-validation.d.ts +8 -1
  12. package/dist/agent/engine/provider-endpoint-validation.js +47 -10
  13. package/dist/agent/engine/registry.js +8 -5
  14. package/dist/cli/skills.d.ts +3 -0
  15. package/dist/cli/skills.js +45 -9
  16. package/dist/client/agent-engine-key.d.ts +7 -0
  17. package/dist/client/agent-engine-key.js +37 -0
  18. package/dist/client/agent-provider-catalog.js +1 -1
  19. package/dist/client/settings/ProviderSetupForm.js +80 -7
  20. package/dist/client/settings/SettingsPanel.js +149 -11
  21. package/dist/client/use-chat-models.js +31 -0
  22. package/dist/collab/routes.d.ts +1 -1
  23. package/dist/collab/struct-routes.d.ts +1 -1
  24. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  25. package/dist/localization/default-messages.d.ts +7 -1
  26. package/dist/localization/default-messages.js +7 -1
  27. package/dist/notifications/routes.d.ts +4 -4
  28. package/dist/observability/routes.d.ts +3 -3
  29. package/dist/org/index.d.ts +1 -1
  30. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  31. package/dist/provider-api/actions/provider-api.d.ts +3 -3
  32. package/dist/resources/handlers.d.ts +1 -1
  33. package/dist/scripts/agent-engines/test-agent-engine.js +9 -7
  34. package/dist/secrets/routes.d.ts +3 -3
  35. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  36. package/dist/server/agent-engine-api-key-route.js +8 -5
  37. package/dist/server/agent-engine-ollama-models-route.d.ts +15 -0
  38. package/dist/server/agent-engine-ollama-models-route.js +105 -0
  39. package/dist/server/core-routes-plugin.d.ts +1 -1
  40. package/dist/server/core-routes-plugin.js +5 -0
  41. package/dist/server/credential-provider.d.ts +13 -0
  42. package/dist/server/credential-provider.js +19 -0
  43. package/dist/server/transcribe-voice.d.ts +1 -1
  44. package/package.json +3 -3
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 9786
34
+ - template files: 9787
@@ -323,8 +323,35 @@ async function failStoredButUnservableRecording(params: {
323
323
  eq(schema.recordings.status, "processing"),
324
324
  ),
325
325
  )
326
- .returning({ id: schema.recordings.id });
326
+ .returning({
327
+ id: schema.recordings.id,
328
+ uploadAttemptId: schema.recordings.uploadAttemptId,
329
+ });
327
330
  if (failed.length !== 1) return false;
331
+ try {
332
+ track(
333
+ "clips_upload_blocking_failure",
334
+ {
335
+ app: "clips",
336
+ template: "clips",
337
+ surface: "media_verification",
338
+ stage: "media_verification",
339
+ outcome: "failed",
340
+ failure_type: "media_verification",
341
+ failure_code: "media_verification_failed",
342
+ output_id: id,
343
+ output_type: "clip",
344
+ recording_id: id,
345
+ recording_attempt_id: id,
346
+ ...(failed[0]?.uploadAttemptId
347
+ ? { upload_attempt_id: failed[0].uploadAttemptId }
348
+ : {}),
349
+ },
350
+ { userId: ownerEmail },
351
+ );
352
+ } catch {
353
+ // coercion-ok: analytics is best-effort and must not change media recovery behavior.
354
+ }
328
355
  const uploadStateRaw = await readAppState(`recording-upload-${id}`).catch(
329
356
  () => null,
330
357
  );
@@ -605,6 +632,7 @@ async function markRecordingReady(params: {
605
632
  finalHeight: number;
606
633
  finalHasAudio: boolean;
607
634
  finalHasCamera: boolean;
635
+ recordingAttemptId?: string | null;
608
636
  existingTitle: string;
609
637
  // Whether a seekable rewrite (MP4 faststart / WebM Cues remux) was already
610
638
  // applied to the uploaded bytes. When false, a best-effort background repair
@@ -623,6 +651,7 @@ async function markRecordingReady(params: {
623
651
  finalHeight,
624
652
  finalHasAudio,
625
653
  finalHasCamera,
654
+ recordingAttemptId,
626
655
  existingTitle,
627
656
  seekableApplied,
628
657
  } = params;
@@ -716,6 +745,8 @@ async function markRecordingReady(params: {
716
745
  template_name: "clips",
717
746
  output_id: id,
718
747
  output_type: "clip",
748
+ recording_attempt_id: id,
749
+ ...(recordingAttemptId ? { upload_attempt_id: recordingAttemptId } : {}),
719
750
  duration_s: Math.round(finalDurationMs / 1000),
720
751
  video_format: videoFormat,
721
752
  has_audio: finalHasAudio,
@@ -864,6 +895,7 @@ async function retryPendingMediaVerification(params: {
864
895
  const db = getDb();
865
896
  const [recording] = await db
866
897
  .select({
898
+ uploadAttemptId: schema.recordings.uploadAttemptId,
867
899
  status: schema.recordings.status,
868
900
  videoUrl: schema.recordings.videoUrl,
869
901
  editsJson: schema.recordings.editsJson,
@@ -919,6 +951,7 @@ async function retryPendingMediaVerification(params: {
919
951
  finalHeight: candidate.finalHeight,
920
952
  finalHasAudio: candidate.finalHasAudio,
921
953
  finalHasCamera: candidate.finalHasCamera,
954
+ recordingAttemptId: recording.uploadAttemptId,
922
955
  seekableApplied: candidate.seekableApplied,
923
956
  });
924
957
  if (result.status === "ready" && result.transitionedToReady) {
@@ -1196,6 +1229,7 @@ export default defineAction({
1196
1229
  finalHeight,
1197
1230
  finalHasAudio,
1198
1231
  finalHasCamera,
1232
+ recordingAttemptId: existing.uploadAttemptId,
1199
1233
  existingTitle: existing.title,
1200
1234
  };
1201
1235
 
@@ -105,7 +105,11 @@ export function RecordingOptionsMenu({
105
105
  </DropdownMenuTrigger>
106
106
  <DropdownMenuContent
107
107
  align="end"
108
- className={showCustomItems ? "w-64" : "w-44"}
108
+ className={
109
+ showCustomItems
110
+ ? "w-64 max-h-[var(--radix-dropdown-menu-content-available-height)] overflow-x-hidden overflow-y-auto"
111
+ : "w-44"
112
+ }
109
113
  onCloseAutoFocus={(event) => {
110
114
  // Opening the AlertDialog while this menu is still tearing down
111
115
  // leaves `pointer-events: none` stuck on <body>: two dismissable
@@ -399,12 +399,27 @@ function isRetryableChunkUploadStatus(status: number): boolean {
399
399
  }
400
400
 
401
401
  function trackClipUploadBlockingFailure(props: Record<string, unknown>): void {
402
+ const recordingId =
403
+ typeof props.recordingId === "string" ? props.recordingId : undefined;
404
+ const uploadAttemptId =
405
+ typeof props.uploadAttemptId === "string"
406
+ ? props.uploadAttemptId
407
+ : undefined;
402
408
  try {
403
409
  trackEvent("clips_upload_blocking_failure", {
410
+ ...props,
404
411
  app: "clips",
405
412
  template: "clips",
406
413
  surface: "web_recorder",
407
- ...props,
414
+ output_id: recordingId,
415
+ output_type: "clip",
416
+ recording_id: recordingId,
417
+ recording_attempt_id: recordingId,
418
+ ...(uploadAttemptId ? { upload_attempt_id: uploadAttemptId } : {}),
419
+ failure_code:
420
+ typeof props.failureKind === "string"
421
+ ? props.failureKind
422
+ : "upload_failed",
408
423
  });
409
424
  } catch {
410
425
  // Analytics should never change recording behavior.
@@ -2623,6 +2638,7 @@ export class RecorderEngine {
2623
2638
  stage: "chunk_upload",
2624
2639
  failureKind: "no_response",
2625
2640
  recordingId: this.opts.recordingId,
2641
+ uploadAttemptId: this.uploadAttemptId,
2626
2642
  chunkIndex: index,
2627
2643
  isFinal: extra.isFinal === true,
2628
2644
  chunkBytes: blob.size,
@@ -2663,6 +2679,7 @@ export class RecorderEngine {
2663
2679
  stage: "chunk_upload",
2664
2680
  failureKind: "http_error",
2665
2681
  recordingId: this.opts.recordingId,
2682
+ uploadAttemptId: this.uploadAttemptId,
2666
2683
  chunkIndex: index,
2667
2684
  isFinal: extra.isFinal === true,
2668
2685
  httpStatus: res.status,
@@ -2347,7 +2347,7 @@ export default function RecordingPage() {
2347
2347
  <DropdownMenuSubTrigger>
2348
2348
  {t("recordingPage.enhanceRecording")}
2349
2349
  </DropdownMenuSubTrigger>
2350
- <DropdownMenuSubContent className="w-56">
2350
+ <DropdownMenuSubContent className="w-56 max-h-[var(--radix-dropdown-menu-content-available-height)] overflow-x-hidden overflow-y-auto">
2351
2351
  <DropdownMenuItem
2352
2352
  disabled={requestTranscript.isPending}
2353
2353
  onSelect={() =>
@@ -2380,7 +2380,7 @@ export default function RecordingPage() {
2380
2380
  <DropdownMenuSubTrigger>
2381
2381
  {t("recordingPage.createFromClip")}
2382
2382
  </DropdownMenuSubTrigger>
2383
- <DropdownMenuSubContent className="w-64">
2383
+ <DropdownMenuSubContent className="w-64 max-h-[var(--radix-dropdown-menu-content-available-height)] overflow-x-hidden overflow-y-auto">
2384
2384
  {WORKFLOW_MENU_ITEMS.map((item) => {
2385
2385
  const menuItem = (
2386
2386
  <DropdownMenuItem
@@ -2112,6 +2112,7 @@ export default function RecordRoute() {
2112
2112
  app_name: "clips",
2113
2113
  template_name: "clips",
2114
2114
  output_id: pendingRef.current?.id,
2115
+ recording_attempt_id: pendingRef.current?.id,
2115
2116
  capture_type:
2116
2117
  recordingMode === "camera"
2117
2118
  ? "camera"
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-09-23
4
+ ---
5
+
6
+ Long recording menus scroll within the visible space so every action stays reachable.
@@ -180,6 +180,8 @@ function expectedDataChunksForFinalPost(
180
180
 
181
181
  function trackUploadBlockingFailure(
182
182
  ownerEmail: string,
183
+ recordingId: string,
184
+ attemptId: string | null,
183
185
  properties: Record<string, unknown>,
184
186
  ): void {
185
187
  try {
@@ -189,6 +191,14 @@ function trackUploadBlockingFailure(
189
191
  app: "clips",
190
192
  template: "clips",
191
193
  surface: "server_upload",
194
+ output_id: recordingId,
195
+ output_type: "clip",
196
+ recording_id: recordingId,
197
+ // Keep this stable for the recording lifecycle; upload_attempt_id
198
+ // disambiguates resumable retries without breaking the join to start.
199
+ recording_attempt_id: recordingId,
200
+ ...(attemptId ? { upload_attempt_id: attemptId } : {}),
201
+ failure_code: properties.failure_code ?? properties.failure_type,
192
202
  ...properties,
193
203
  },
194
204
  { userId: ownerEmail },
@@ -693,7 +703,7 @@ export async function handleRecordingChunk(
693
703
  });
694
704
  if ((result as any)?.status === "failed") {
695
705
  const failure = finalizeResultFailure(result);
696
- trackUploadBlockingFailure(ownerEmail, {
706
+ trackUploadBlockingFailure(ownerEmail, recordingId, attemptId, {
697
707
  stage: "finalize_recording",
698
708
  outcome: failure.outcome,
699
709
  failure_type: failure.failure_type,
@@ -812,7 +822,7 @@ export async function handleRecordingChunk(
812
822
  return acceptedProcessingResponse(event, recordingId, pendingState);
813
823
  }
814
824
  }
815
- trackUploadBlockingFailure(ownerEmail, {
825
+ trackUploadBlockingFailure(ownerEmail, recordingId, attemptId, {
816
826
  stage: "finalize_recording",
817
827
  outcome: "failed",
818
828
  failure_type: classifyTrackingFailure(err),
@@ -1273,7 +1283,7 @@ async function handleResumableChunk(
1273
1283
  );
1274
1284
  if ((result as any)?.status === "failed") {
1275
1285
  const failure = finalizeResultFailure(result);
1276
- trackUploadBlockingFailure(ownerEmail, {
1286
+ trackUploadBlockingFailure(ownerEmail, recordingId, attemptId, {
1277
1287
  stage: "finalize_recording",
1278
1288
  outcome: failure.outcome,
1279
1289
  failure_type: failure.failure_type,
@@ -1380,7 +1390,7 @@ async function handleResumableChunk(
1380
1390
  }
1381
1391
  }
1382
1392
 
1383
- trackUploadBlockingFailure(ownerEmail, {
1393
+ trackUploadBlockingFailure(ownerEmail, recordingId, attemptId, {
1384
1394
  stage: "finalize_recording",
1385
1395
  outcome: "failed",
1386
1396
  failure_type: classifyTrackingFailure(err),
@@ -4,3 +4,10 @@ export declare const OLLAMA_BASE_URL_ENV_VAR = "OLLAMA_BASE_URL";
4
4
  export declare function isCustomOpenAiBaseUrl(value: string | undefined): boolean;
5
5
  export declare function normalizeProviderBaseUrl(value: string): string;
6
6
  export declare function normalizeOpenAiBaseUrl(value: string): string;
7
+ /**
8
+ * Ollama's own API has no `/v1` path — that suffix only applies to its
9
+ * OpenAI-compatibility shim, which this integration doesn't use. Docs for
10
+ * other tools commonly show `/v1`, so users copy-paste it in; strip it
11
+ * silently rather than making them notice and remove it themselves.
12
+ */
13
+ export declare function stripOllamaV1Suffix(value: string): string;
@@ -28,3 +28,12 @@ export function normalizeProviderBaseUrl(value) {
28
28
  export function normalizeOpenAiBaseUrl(value) {
29
29
  return normalizeProviderBaseUrl(value);
30
30
  }
31
+ /**
32
+ * Ollama's own API has no `/v1` path — that suffix only applies to its
33
+ * OpenAI-compatibility shim, which this integration doesn't use. Docs for
34
+ * other tools commonly show `/v1`, so users copy-paste it in; strip it
35
+ * silently rather than making them notice and remove it themselves.
36
+ */
37
+ export function stripOllamaV1Suffix(value) {
38
+ return value.replace(/\/v1$/i, "");
39
+ }
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Validate a provider endpoint before a server-side model request can use it.
3
3
  * `allowPrivate` is reserved for operator-owned deployment configuration; it
4
- * must never be enabled for a user- or agent-supplied URL.
4
+ * must never be enabled for a user- or agent-supplied URL. `allowLocalOllama`
5
+ * additionally requires the caller to have already established this is a
6
+ * trusted, self-hosted context (see `isTrustedSelfHostedRuntime`) — it is
7
+ * never safe to grant purely from the endpoint value itself. `isOllama` says
8
+ * this endpoint is for Ollama specifically (independent of trust level), so a
9
+ * copy-pasted trailing `/v1` — meaningless for Ollama's own API — is silently
10
+ * removed instead of left to confuse the address that gets saved and used.
5
11
  */
6
12
  export declare function validateProviderBaseUrl(value: string, options?: {
7
13
  allowPrivate?: boolean;
8
14
  allowLocalOllama?: boolean;
15
+ isOllama?: boolean;
9
16
  }): Promise<string>;
@@ -1,20 +1,57 @@
1
1
  import { isBlockedExtensionUrlWithDns } from "../../extensions/url-safety.js";
2
- import { normalizeProviderBaseUrl } from "./openai-compatible-endpoint.js";
3
- function isLoopbackOllamaEndpoint(value) {
4
- const hostname = new URL(value).hostname.toLowerCase();
5
- return (hostname === "localhost" ||
6
- hostname === "127.0.0.1" ||
7
- hostname === "::1" ||
8
- hostname === "[::1]");
2
+ import { normalizeProviderBaseUrl, stripOllamaV1Suffix, } from "./openai-compatible-endpoint.js";
3
+ function isPrivateIpv4Lan(a, b, c, d) {
4
+ if (![a, b, c, d].every((part) => part >= 0 && part <= 255))
5
+ return false;
6
+ if (a === 127)
7
+ return true; // loopback
8
+ if (a === 10)
9
+ return true; // 10.0.0.0/8
10
+ if (a === 172 && b >= 16 && b <= 31)
11
+ return true; // 172.16.0.0/12
12
+ if (a === 192 && b === 168)
13
+ return true; // 192.168.0.0/16
14
+ return false;
15
+ }
16
+ /**
17
+ * True for hosts an Ollama server commonly runs on: loopback, or an RFC1918
18
+ * LAN address such as `192.168.1.123`. Deliberately narrower than the
19
+ * generic SSRF private-address check in `url-safety.ts` — link-local
20
+ * addresses (169.254.x.x, which also covers the cloud metadata endpoint) and
21
+ * other reserved ranges stay blocked even for Ollama. Only literal IPs and
22
+ * `localhost` are recognized; an arbitrary hostname that merely resolves to
23
+ * a LAN address is not, so this never depends on a DNS lookup.
24
+ */
25
+ function isLocalNetworkOllamaEndpoint(value) {
26
+ const hostname = new URL(value).hostname
27
+ .toLowerCase()
28
+ .replace(/^\[|\]$/g, "");
29
+ if (hostname === "localhost" || hostname === "::1")
30
+ return true;
31
+ const parts = hostname.split(".");
32
+ if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {
33
+ const [a, b, c, d] = parts.map(Number);
34
+ return isPrivateIpv4Lan(a, b, c, d);
35
+ }
36
+ return false;
9
37
  }
10
38
  /**
11
39
  * Validate a provider endpoint before a server-side model request can use it.
12
40
  * `allowPrivate` is reserved for operator-owned deployment configuration; it
13
- * must never be enabled for a user- or agent-supplied URL.
41
+ * must never be enabled for a user- or agent-supplied URL. `allowLocalOllama`
42
+ * additionally requires the caller to have already established this is a
43
+ * trusted, self-hosted context (see `isTrustedSelfHostedRuntime`) — it is
44
+ * never safe to grant purely from the endpoint value itself. `isOllama` says
45
+ * this endpoint is for Ollama specifically (independent of trust level), so a
46
+ * copy-pasted trailing `/v1` — meaningless for Ollama's own API — is silently
47
+ * removed instead of left to confuse the address that gets saved and used.
14
48
  */
15
49
  export async function validateProviderBaseUrl(value, options = {}) {
16
- const normalized = normalizeProviderBaseUrl(value);
17
- const allowLocalOllama = options.allowLocalOllama === true && isLoopbackOllamaEndpoint(normalized);
50
+ let normalized = normalizeProviderBaseUrl(value);
51
+ if (options.isOllama)
52
+ normalized = stripOllamaV1Suffix(normalized);
53
+ const allowLocalOllama = options.allowLocalOllama === true &&
54
+ isLocalNetworkOllamaEndpoint(normalized);
18
55
  if (!options.allowPrivate &&
19
56
  !allowLocalOllama &&
20
57
  (await isBlockedExtensionUrlWithDns(normalized))) {
@@ -12,7 +12,7 @@ import { getAppConfig } from "../../app-config/index.js";
12
12
  import { getUserLabs } from "../../labs/store.js";
13
13
  import { BUILDER_OAUTH_SCOPE, hasBuilderOAuthSession, resolveBuilderOAuthRequestAccess, } from "../../server/builder-oauth.js";
14
14
  import { hasChatGPTSubscriptionCredential } from "../../server/chatgpt-subscription-oauth.js";
15
- import { assertCredentialStoreReadable, canUseDeployCredentialFallbackForRequest, getBuilderCredentialAuthFailure, getProviderCredentialAuthFailure, prefetchSecrets, readDeployCredentialEnv, resolveBuilderCredentialsDetailed, resolveBuilderGatewayCredentialsDetailed, resolveSecret, } from "../../server/credential-provider.js";
15
+ import { assertCredentialStoreReadable, canUseDeployCredentialFallbackForRequest, getBuilderCredentialAuthFailure, getProviderCredentialAuthFailure, isTrustedSelfHostedRuntime, prefetchSecrets, readDeployCredentialEnv, resolveBuilderCredentialsDetailed, resolveBuilderGatewayCredentialsDetailed, resolveSecret, } from "../../server/credential-provider.js";
16
16
  import { getRequestOrgId, getRequestContext, getRequestUserEmail, } from "../../server/request-context.js";
17
17
  import { getSetting } from "../../settings/store.js";
18
18
  import { getAgentAppModelDefaultForCurrentRequest } from "../app-model-defaults.js";
@@ -625,11 +625,13 @@ async function resolveProviderBaseUrl(envVar) {
625
625
  const deployValue = canUseDeployCredentialFallbackForRequest(envVar)
626
626
  ? readDeployCredentialEnv(envVar)
627
627
  : undefined;
628
+ const isOllama = envVar === OLLAMA_BASE_URL_ENV_VAR;
628
629
  if (!raw) {
629
630
  if (!deployValue)
630
631
  return undefined;
631
632
  return validateProviderBaseUrl(deployValue, {
632
633
  allowPrivate: true,
634
+ isOllama,
633
635
  });
634
636
  }
635
637
  return raw
@@ -639,8 +641,8 @@ async function resolveProviderBaseUrl(envVar) {
639
641
  // allowance as the explicit deploy-only branch above without extending
640
642
  // it to user-, org-, or workspace-scoped endpoint values.
641
643
  allowPrivate: deployValue !== undefined && raw === deployValue,
642
- allowLocalOllama: envVar === OLLAMA_BASE_URL_ENV_VAR &&
643
- process.env.NODE_ENV !== "production",
644
+ allowLocalOllama: isOllama && isTrustedSelfHostedRuntime(),
645
+ isOllama,
644
646
  })
645
647
  : undefined;
646
648
  }
@@ -815,9 +817,10 @@ async function engineCreateConfigForEntry(entry, apiKey, extra, credentialResolu
815
817
  }
816
818
  if (entry.name === "ai-sdk:openai" || entry.name === "ai-sdk:ollama") {
817
819
  if (typeof safeExtra.baseURL === "string" && safeExtra.baseUrl == null) {
820
+ const isOllama = entry.name === "ai-sdk:ollama";
818
821
  safeExtra.baseUrl = await validateProviderBaseUrl(safeExtra.baseURL, {
819
- allowLocalOllama: entry.name === "ai-sdk:ollama" &&
820
- process.env.NODE_ENV !== "production",
822
+ allowLocalOllama: isOllama && isTrustedSelfHostedRuntime(),
823
+ isOllama,
821
824
  });
822
825
  }
823
826
  if (safeExtra.baseUrl == null) {
@@ -189,6 +189,7 @@ export type SkillsCatalogMode = "agent-native" | "all";
189
189
  export interface PublicSkillCatalogEntry {
190
190
  name: string;
191
191
  description?: string;
192
+ installerGroup?: string;
192
193
  }
193
194
  interface ConnectSpinner {
194
195
  start(message?: string): void;
@@ -263,7 +264,9 @@ interface SkillsClientPromptContext {
263
264
  installsMcp: boolean;
264
265
  }
265
266
  interface SkillsTargetPromptContext {
267
+ message?: string;
266
268
  initialTargets: string[];
269
+ required?: boolean;
267
270
  options: Array<{
268
271
  value: string;
269
272
  label: string;
@@ -1808,6 +1808,7 @@ function publicSkillEntries(options) {
1808
1808
  .map((entry) => ({
1809
1809
  name: entry.name.trim().toLowerCase(),
1810
1810
  description: entry.description,
1811
+ installerGroup: entry.installerGroup?.trim().toLowerCase(),
1811
1812
  }))
1812
1813
  .filter((entry) => {
1813
1814
  if (!entry.name || isKnownSkill(entry.name) || seen.has(entry.name)) {
@@ -1822,11 +1823,16 @@ function publicSkillNames(options) {
1822
1823
  return new Set(publicSkillEntries(options).map((entry) => entry.name));
1823
1824
  }
1824
1825
  function publicSkillPromptOptions(options) {
1825
- return publicSkillEntries(options).map((entry) => ({
1826
- value: entry.name,
1827
- label: entry.name,
1828
- hint: entry.description ?? "Public skill from the BuilderIO skills catalog.",
1829
- }));
1826
+ return publicSkillEntries(options).map((entry) => {
1827
+ const description = entry.description ?? "Public skill from the BuilderIO skills catalog.";
1828
+ return {
1829
+ value: entry.name,
1830
+ label: entry.name,
1831
+ hint: entry.installerGroup === entry.name
1832
+ ? `${description} Select to choose the full group or a subset.`
1833
+ : description,
1834
+ };
1835
+ });
1830
1836
  }
1831
1837
  function skillPromptOptions(options = {}) {
1832
1838
  return [
@@ -1999,11 +2005,12 @@ async function promptForUpdateInstructions() {
1999
2005
  async function promptForSkills(context) {
2000
2006
  const clack = await import("@clack/prompts");
2001
2007
  const result = await clack.multiselect({
2002
- message: "Which Agent-Native skills do you want to install?\n" +
2003
- " (space toggles, enter confirms)",
2008
+ message: context.message ??
2009
+ "Which Agent-Native skills do you want to install?\n" +
2010
+ " (space toggles, enter confirms)",
2004
2011
  options: context.options,
2005
2012
  initialValues: context.initialTargets,
2006
- required: true,
2013
+ required: context.required ?? true,
2007
2014
  });
2008
2015
  if (clack.isCancel(result)) {
2009
2016
  clack.cancel("Cancelled.");
@@ -2149,7 +2156,36 @@ async function resolveSkillTargets(parsed, options) {
2149
2156
  });
2150
2157
  if (!selected || selected.length === 0)
2151
2158
  return null;
2152
- return resolveSelectedSkillTargets(selected, options);
2159
+ const publicEntries = publicSkillEntries(options);
2160
+ const selectedTargets = new Set(selected);
2161
+ for (const group of publicEntries.filter((entry) => entry.installerGroup === entry.name && selected.includes(entry.name))) {
2162
+ const members = publicEntries.filter((entry) => entry.installerGroup === group.name && entry.name !== group.name);
2163
+ if (members.length === 0)
2164
+ continue;
2165
+ const groupLabel = group.name.replace(/^./, (character) => character.toUpperCase());
2166
+ const groupSelection = await prompt({
2167
+ message: `Which ${groupLabel} skills do you want to install?\n` +
2168
+ " (all selected; deselect any you want to skip)",
2169
+ initialTargets: members.map((entry) => entry.name),
2170
+ required: false,
2171
+ options: members.map((entry) => ({
2172
+ value: entry.name,
2173
+ label: entry.name,
2174
+ hint: entry.description ??
2175
+ "Public skill from the BuilderIO skills catalog.",
2176
+ })),
2177
+ });
2178
+ if (!groupSelection)
2179
+ return null;
2180
+ const memberNames = new Set(members.map((entry) => entry.name));
2181
+ for (const memberName of memberNames)
2182
+ selectedTargets.delete(memberName);
2183
+ for (const memberName of groupSelection) {
2184
+ if (memberNames.has(memberName))
2185
+ selectedTargets.add(memberName);
2186
+ }
2187
+ }
2188
+ return resolveSelectedSkillTargets([...selectedTargets], options);
2153
2189
  }
2154
2190
  export function parseSkillsArgs(argv) {
2155
2191
  const first = argv[0];
@@ -53,6 +53,13 @@ export declare function saveAgentEngineApiKey({ provider, key, apiKey, scope, }:
53
53
  * endpoint URLs. Values are stored server-side in scoped secrets.
54
54
  */
55
55
  export declare function saveAgentEngineProviderSettings({ provider, key, apiKey, baseUrl, clearBaseUrl, }: SaveAgentEngineProviderSettingsOptions): Promise<void>;
56
+ /**
57
+ * List the models an Ollama server actually has installed, via its native
58
+ * `/api/tags` endpoint. Pass `baseUrl` to check a server before it's saved
59
+ * (e.g. while the user is still typing the endpoint); omit it to use the
60
+ * saved endpoint. Throws a readable Error if the server can't be reached.
61
+ */
62
+ export declare function fetchOllamaModels(baseUrl?: string): Promise<string[]>;
56
63
  /**
57
64
  * Select the provider and model for the next conversation. This is separate
58
65
  * from saving credentials so keyless local providers such as Ollama can use
@@ -201,6 +201,43 @@ export async function saveAgentEngineProviderSettings({ provider, key, apiKey, b
201
201
  }
202
202
  dispatchConfiguredChanged();
203
203
  }
204
+ /**
205
+ * List the models an Ollama server actually has installed, via its native
206
+ * `/api/tags` endpoint. Pass `baseUrl` to check a server before it's saved
207
+ * (e.g. while the user is still typing the endpoint); omit it to use the
208
+ * saved endpoint. Throws a readable Error if the server can't be reached.
209
+ */
210
+ export async function fetchOllamaModels(baseUrl) {
211
+ const trimmed = baseUrl?.trim() ?? "";
212
+ const path = trimmed
213
+ ? `/_agent-native/agent-engine/ollama-models?baseUrl=${encodeURIComponent(trimmed)}`
214
+ : "/_agent-native/agent-engine/ollama-models";
215
+ const response = await fetch(agentNativePath(path), {
216
+ credentials: "include",
217
+ });
218
+ const text = await response.text();
219
+ let payload;
220
+ try {
221
+ payload = text.trim() ? JSON.parse(text) : undefined;
222
+ }
223
+ catch {
224
+ throw new Error(`Could not read the Ollama models response (HTTP ${response.status}).`);
225
+ }
226
+ if (!response.ok) {
227
+ const message = payload &&
228
+ typeof payload === "object" &&
229
+ typeof payload.error === "string"
230
+ ? payload.error
231
+ : `Could not list Ollama models (HTTP ${response.status}).`;
232
+ throw new Error(message);
233
+ }
234
+ const models = payload && typeof payload === "object"
235
+ ? payload.models
236
+ : undefined;
237
+ return Array.isArray(models)
238
+ ? models.filter((model) => typeof model === "string")
239
+ : [];
240
+ }
204
241
  /**
205
242
  * Select the provider and model for the next conversation. This is separate
206
243
  * from saving credentials so keyless local providers such as Ollama can use
@@ -72,7 +72,7 @@ export const AGENT_PROVIDER_CATALOG = PROVIDER_ORDER.map((id) => {
72
72
  ? {
73
73
  supportsEndpoint: true,
74
74
  endpointPlaceholder: isOllama
75
- ? "http://localhost:11434"
75
+ ? "http://localhost:11434 or local network address like http://192.168.1.123:11434"
76
76
  : "https://gateway.example/v1",
77
77
  }
78
78
  : {}),