@moxxy/sdk 0.37.2 → 0.39.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.
@@ -17,12 +17,40 @@
17
17
  * depend on, so the two ends can never drift.
18
18
  */
19
19
 
20
+ import { z } from 'zod';
21
+
20
22
  /** The byte that brackets a prompt marker. Absent from normal CLI output. */
21
23
  const NUL = '\u0000';
22
24
 
23
25
  /** Tag distinguishing a login-prompt marker from any other NUL-bracketed run. */
24
26
  const PROMPT_TAG = 'moxxy.login.prompt';
25
27
 
28
+ /** Tag distinguishing a browser-authorization URL from normal login output. */
29
+ const AUTH_URL_TAG = 'moxxy.login.auth_url';
30
+
31
+ const LoginAuthUrlSchema = z
32
+ .string()
33
+ .max(8192)
34
+ .refine((value) => {
35
+ try {
36
+ const url = new URL(value);
37
+ return (
38
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
39
+ url.username === '' &&
40
+ url.password === ''
41
+ );
42
+ } catch {
43
+ return false;
44
+ }
45
+ }, 'must be an http(s) URL without embedded credentials');
46
+
47
+ const LoginAuthUrlMarkerSchema = z
48
+ .object({
49
+ tag: z.literal(AUTH_URL_TAG),
50
+ url: LoginAuthUrlSchema,
51
+ })
52
+ .strict();
53
+
26
54
  export interface LoginPromptRequest {
27
55
  /** The question to show the user, verbatim from `ctx.prompt`. */
28
56
  readonly question: string;
@@ -33,7 +61,8 @@ export interface LoginPromptRequest {
33
61
  /** One decoded item from a login subprocess's stdout stream. */
34
62
  export type LoginStreamItem =
35
63
  | { readonly type: 'output'; readonly text: string }
36
- | { readonly type: 'prompt'; readonly prompt: LoginPromptRequest };
64
+ | { readonly type: 'prompt'; readonly prompt: LoginPromptRequest }
65
+ | { readonly type: 'auth_url'; readonly url: string };
37
66
 
38
67
  /**
39
68
  * Serialize a prompt request as a NUL-bracketed marker for stdout. The
@@ -59,6 +88,28 @@ export function decodeLoginPrompt(segment: string): LoginPromptRequest | null {
59
88
  }
60
89
  }
61
90
 
91
+ /**
92
+ * Serialize the browser authorization URL for a GUI host. Validation happens
93
+ * before a marker reaches stdout: a provider-controlled URL may only be a
94
+ * credential-free HTTP(S) URL, never a shell-adjacent custom/file scheme.
95
+ */
96
+ export function encodeLoginAuthUrl(url: string): string {
97
+ const marker = LoginAuthUrlMarkerSchema.parse({ tag: AUTH_URL_TAG, url });
98
+ return NUL + JSON.stringify(marker) + NUL;
99
+ }
100
+
101
+ /** Decode and validate one inter-NUL authorization-URL marker. */
102
+ export function decodeLoginAuthUrl(segment: string): string | null {
103
+ if (!segment.startsWith('{')) return null;
104
+ try {
105
+ const parsed: unknown = JSON.parse(segment);
106
+ const marker = LoginAuthUrlMarkerSchema.safeParse(parsed);
107
+ return marker.success ? marker.data.url : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
62
113
  /**
63
114
  * Streaming scanner for a login subprocess's stdout. Feed it chunks; it
64
115
  * returns the ordered output / prompt items decoded so far, holding back any
@@ -84,9 +135,16 @@ export function createLoginStreamScanner(): {
84
135
  if (open > 0) out.push({ type: 'output', text: buf.slice(0, open) });
85
136
  const segment = buf.slice(open + 1, close);
86
137
  const prompt = decodeLoginPrompt(segment);
138
+ const authUrl = prompt ? null : decodeLoginAuthUrl(segment);
87
139
  // A NUL-bracketed run that isn't a valid marker is anomalous; surface
88
140
  // its inner text as output rather than swallowing it.
89
- out.push(prompt ? { type: 'prompt', prompt } : { type: 'output', text: segment });
141
+ out.push(
142
+ prompt
143
+ ? { type: 'prompt', prompt }
144
+ : authUrl !== null
145
+ ? { type: 'auth_url', url: authUrl }
146
+ : { type: 'output', text: segment },
147
+ );
90
148
  buf = buf.slice(close + 1);
91
149
  }
92
150
  // Flush any plain text that precedes a still-incomplete marker (or all
@@ -152,3 +152,57 @@ describe('resolveProviderTools', () => {
152
152
  expect(resolveProviderTools([webSearch], models, 'catalog-drift').tools).toEqual([webSearch]);
153
153
  });
154
154
  });
155
+
156
+ /**
157
+ * Field descriptions are the only place a tool author can say what a parameter
158
+ * means, and the model is the audience. They were dropped on the floor: every
159
+ * `.describe()` in every moxxy tool reached the provider as nothing at all,
160
+ * which is how a model ends up inventing `tab_id: "current"` for a field whose
161
+ * description reads "Omit for the active tab".
162
+ */
163
+ describe('zodToJsonSchema — descriptions', () => {
164
+ it('carries a field description through to the model', () => {
165
+ const out = zodToJsonSchema(z.object({ a: z.string().describe('Co to jest a') })) as {
166
+ properties: Record<string, Record<string, unknown>>;
167
+ };
168
+
169
+ expect(out.properties.a).toEqual({ type: 'string', description: 'Co to jest a' });
170
+ });
171
+
172
+ it('keeps a description that sits under a wrapper', () => {
173
+ expect(zodToJsonSchema(z.string().describe('opis').optional())).toEqual({
174
+ type: 'string',
175
+ description: 'opis',
176
+ });
177
+ });
178
+
179
+ it('lets the outermost description win when a wrapper carries one too', () => {
180
+ const schema = z.string().describe('wewnetrzny').optional().describe('zewnetrzny');
181
+
182
+ expect(zodToJsonSchema(schema)).toEqual({ type: 'string', description: 'zewnetrzny' });
183
+ });
184
+
185
+ it('describes a whole object, an enum and an array element too', () => {
186
+ const out = zodToJsonSchema(
187
+ z
188
+ .object({
189
+ mode: z.enum(['a', 'b']).describe('Tryb'),
190
+ tags: z.array(z.string()).describe('Etykiety'),
191
+ })
192
+ .describe('Cale wejscie'),
193
+ ) as { description: string; properties: Record<string, Record<string, unknown>> };
194
+
195
+ expect(out.description).toBe('Cale wejscie');
196
+ expect(out.properties.mode?.description).toBe('Tryb');
197
+ expect(out.properties.tags?.description).toBe('Etykiety');
198
+ });
199
+
200
+ it('adds no description key when the author wrote none', () => {
201
+ expect(zodToJsonSchema(z.string())).toEqual({ type: 'string' });
202
+ expect(zodToJsonSchema(z.object({ a: z.string() }))).toEqual({
203
+ type: 'object',
204
+ properties: { a: { type: 'string' } },
205
+ required: ['a'],
206
+ });
207
+ });
208
+ });
@@ -80,7 +80,29 @@ export function toFriendlyError(
80
80
  */
81
81
  const MAX_SCHEMA_DEPTH = 32;
82
82
 
83
+ /**
84
+ * Convert, then re-attach the author's `.describe()` text.
85
+ *
86
+ * A field description is the only place a tool author gets to say what a
87
+ * parameter means, and the model is the audience — dropping it leaves the
88
+ * model guessing at values (`tab_id: "current"` for a field documented as
89
+ * "omit for the active tab"). `description` is standard JSON Schema, so every
90
+ * provider either uses it or ignores it.
91
+ *
92
+ * Doing it here rather than at each of the dozen returns below also fixes the
93
+ * wrappers for free: `.describe()` may sit on the ZodOptional/ZodEffects OR on
94
+ * the type inside it, and each level of the recursion re-enters through this
95
+ * function. The outermost wins, because it applies last.
96
+ */
83
97
  export function zodToJsonSchema(schema: unknown, depth = 0): unknown {
98
+ const json = convertSchema(schema, depth);
99
+ const description = (schema as { _def?: { description?: string } } | null)?._def?.description;
100
+ if (typeof description !== 'string' || !description) return json;
101
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) return json;
102
+ return { ...json, description };
103
+ }
104
+
105
+ function convertSchema(schema: unknown, depth: number): unknown {
84
106
  if (depth > MAX_SCHEMA_DEPTH) return {};
85
107
  const s = schema as { _def?: { typeName?: string }; toJSON?: () => unknown };
86
108
  // Only honor a pre-serialized schema's `toJSON` for NON-zod objects (a plain
package/src/provider.ts CHANGED
@@ -160,6 +160,16 @@ export interface ModelDescriptor {
160
160
  export interface LLMProvider {
161
161
  readonly name: string;
162
162
  readonly models: ReadonlyArray<ModelDescriptor>;
163
+ /**
164
+ * Optional provider-owned metadata preparation for a dynamically selected
165
+ * model. The runtime awaits it before it calculates context budgets, so a
166
+ * provider whose catalog is discovered at runtime can publish the exact
167
+ * descriptor without trusting a client-supplied context-window value.
168
+ */
169
+ prepareModel?(
170
+ model: string,
171
+ options?: { readonly signal?: AbortSignal },
172
+ ): Promise<void>;
163
173
  stream(req: ProviderRequest): AsyncIterable<ProviderEvent>;
164
174
  countTokens(req: Pick<ProviderRequest, 'model' | 'messages' | 'system' | 'tools'>): Promise<number>;
165
175
  }
@@ -202,6 +212,18 @@ export interface ProviderAuthContext {
202
212
  * whether they're running inside a wizard or a one-shot command.
203
213
  */
204
214
  readonly write: (chunk: string) => void;
215
+ /**
216
+ * True when a GUI host owns browser opening. OAuth implementations still
217
+ * build the loopback URL and report it through {@link onAuthUrl}, but must not
218
+ * invoke their process-local browser helper.
219
+ */
220
+ readonly noOpen?: boolean;
221
+ /**
222
+ * Browser authorization URL produced by a loopback flow. GUI hosts relay it
223
+ * over their authenticated transport, validate it again, and open it using
224
+ * their native browser API.
225
+ */
226
+ readonly onAuthUrl?: (url: string) => void;
205
227
  /**
206
228
  * Optional single-line input prompt. Present when the host has an
207
229
  * interactive TTY; absent in headless runs. Flows that need the user to
@@ -305,6 +327,12 @@ export interface ProviderOAuthStatus {
305
327
  export interface ProviderDef {
306
328
  readonly name: string;
307
329
  readonly models: ReadonlyArray<ModelDescriptor>;
330
+ /**
331
+ * True when the provider can return its current model catalog at runtime.
332
+ * Hosts use this capability signal to offer live discovery instead of
333
+ * presenting the provider's build-time fallback catalog as authoritative.
334
+ */
335
+ readonly supportsLiveModelDiscovery?: boolean;
308
336
  createClient(config: Record<string, unknown>): LLMProvider;
309
337
  /**
310
338
  * Optional provider-owned credential/config resolver. Hosts call this before
package/src/server.ts CHANGED
@@ -73,3 +73,14 @@ export {
73
73
  MOXXY_WS_SUBPROTOCOL,
74
74
  MOXXY_WS_BEARER_PROTOCOL_PREFIX,
75
75
  } from './channel-auth.js';
76
+ export {
77
+ execExecutableTargetSync,
78
+ executableCandidates,
79
+ findExecutable,
80
+ resolveExecutableTarget,
81
+ spawnExecutableTarget,
82
+ type ExecutableSearchOptions,
83
+ type ExecutableTarget,
84
+ type SafeExecFileSyncOptions,
85
+ type SafeSpawnOptions,
86
+ } from './process-invocation.js';