broapp 0.1.0 → 0.3.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.
- package/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +109 -0
- package/src/ai/host/create-ai.ts +266 -0
- package/src/ai/host/fake.ts +230 -0
- package/src/ai/host/from-contract.ts +105 -0
- package/src/ai/host/index.ts +37 -0
- package/src/ai/host/registry.ts +232 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +540 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +95 -0
- package/src/ai/react/AiChat.tsx +242 -0
- package/src/ai/react/AiSettings.tsx +228 -0
- package/src/ai/react/ai.css +166 -0
- package/src/ai/react/index.tsx +38 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +317 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +247 -0
- package/src/ai/shared/index.ts +19 -0
- package/src/ai/shared/types.check.ts +71 -0
- package/src/ai/shared/types.ts +147 -0
- package/src/host/app.ts +183 -36
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +25 -0
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +99 -2
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +66 -2
- package/src/shared/index.ts +14 -3
- package/src/shared/schema.ts +141 -28
package/src/shared/contract.ts
CHANGED
|
@@ -17,12 +17,28 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { Infer, Schema } from './schema.ts';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* What an operation or stream does to the world.
|
|
22
|
+
*
|
|
23
|
+
* `read` changes nothing. `write` changes data inside the application's data
|
|
24
|
+
* directory. `external` reaches outside it: the network, other files, a
|
|
25
|
+
* spawned process, mail. The gate decides from this and from who is asking
|
|
26
|
+
* whether a call runs, waits for a person, or is refused. A route that does
|
|
27
|
+
* not say is treated as `write`, which asks a person before an agent may
|
|
28
|
+
* run it and lets the owner's own click through.
|
|
29
|
+
*/
|
|
30
|
+
export type Effect = 'read' | 'write' | 'external';
|
|
31
|
+
|
|
32
|
+
/** The three strings an `effect` may be, for validation at definition time. */
|
|
33
|
+
const EFFECTS: readonly Effect[] = ['read', 'write', 'external'];
|
|
34
|
+
|
|
20
35
|
/** One unary operation: JSON in, JSON out. */
|
|
21
36
|
export interface OperationSpec<I = unknown, O = unknown> {
|
|
22
37
|
readonly input: Schema<I>;
|
|
23
38
|
readonly output: Schema<O>;
|
|
24
39
|
/** Shown in generated documentation and in the developer panel. */
|
|
25
40
|
readonly summary?: string;
|
|
41
|
+
readonly effect?: Effect;
|
|
26
42
|
}
|
|
27
43
|
|
|
28
44
|
/**
|
|
@@ -37,6 +53,12 @@ export interface StreamSpec<P = unknown, E = unknown> {
|
|
|
37
53
|
readonly params: Schema<P>;
|
|
38
54
|
readonly event: Schema<E>;
|
|
39
55
|
readonly summary?: string;
|
|
56
|
+
readonly effect?: Effect;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The effect a route declares, or the conservative default. */
|
|
60
|
+
export function effectOf(spec: { readonly effect?: Effect }): Effect {
|
|
61
|
+
return spec.effect ?? 'write';
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
/** The operation and stream tables an application declares. */
|
|
@@ -94,8 +116,9 @@ export type StreamEvent<C extends AnyContract, K extends StreamName<C>> = Infer<
|
|
|
94
116
|
|
|
95
117
|
/**
|
|
96
118
|
* A route name is `group.member`. Brobridge resolves a unary call by splitting
|
|
97
|
-
* on the
|
|
98
|
-
*
|
|
119
|
+
* on the *last* `.` and looking the group up in its service registry, and it
|
|
120
|
+
* refuses to expose a service whose name contains a dot — so both halves must
|
|
121
|
+
* be present and neither may itself contain one.
|
|
99
122
|
*/
|
|
100
123
|
const ROUTE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
101
124
|
|
|
@@ -122,6 +145,20 @@ export function defineContract<const C extends ContractShape>(shape: C): Contrac
|
|
|
122
145
|
);
|
|
123
146
|
}
|
|
124
147
|
}
|
|
148
|
+
// An `effect` that is not one of the three words would silently become the
|
|
149
|
+
// conservative default at the gate, which reads as a working declaration
|
|
150
|
+
// while meaning nothing. A typo is refused where it was written instead.
|
|
151
|
+
for (const [route, spec] of [
|
|
152
|
+
...Object.entries(shape.operations),
|
|
153
|
+
...Object.entries(shape.streams),
|
|
154
|
+
] as readonly (readonly [string, { readonly effect?: unknown }])[]) {
|
|
155
|
+
const effect = spec.effect;
|
|
156
|
+
if (effect !== undefined && !(EFFECTS as readonly unknown[]).includes(effect)) {
|
|
157
|
+
throw new TypeError(
|
|
158
|
+
`route ${JSON.stringify(route)} declares effect ${JSON.stringify(effect)}, which must be "read", "write" or "external"`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
125
162
|
const clash = operations.find((route) => streams.includes(route));
|
|
126
163
|
if (clash !== undefined) {
|
|
127
164
|
throw new TypeError(`route ${JSON.stringify(clash)} is declared as both an operation and a stream`);
|
|
@@ -132,3 +169,63 @@ export function defineContract<const C extends ContractShape>(shape: C): Contrac
|
|
|
132
169
|
routes: { operations, streams },
|
|
133
170
|
};
|
|
134
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Combine two contracts into one. Used in the browser so one client can
|
|
175
|
+
* speak an application's contract and Broapp's AI contract over one
|
|
176
|
+
* connection. Throws if any route name appears in both.
|
|
177
|
+
*/
|
|
178
|
+
export function mergeContracts<A extends AnyContract, B extends AnyContract>(
|
|
179
|
+
a: A,
|
|
180
|
+
b: B,
|
|
181
|
+
): Contract<{
|
|
182
|
+
operations: ShapeOf<A>['operations'] & ShapeOf<B>['operations'];
|
|
183
|
+
streams: ShapeOf<A>['streams'] & ShapeOf<B>['streams'];
|
|
184
|
+
}> {
|
|
185
|
+
// A clash is checked across all four tables, not table by table: a name that
|
|
186
|
+
// is an operation on one side and a stream on the other is just as
|
|
187
|
+
// unresolvable as a duplicate operation, because Brobridge dispatches on the
|
|
188
|
+
// route name alone.
|
|
189
|
+
const names = new Set<string>([...a.routes.operations, ...a.routes.streams]);
|
|
190
|
+
for (const route of [...b.routes.operations, ...b.routes.streams]) {
|
|
191
|
+
if (names.has(route)) throw new TypeError(`route ${JSON.stringify(route)} is declared by both contracts`);
|
|
192
|
+
}
|
|
193
|
+
const operations = { ...a.operations, ...b.operations };
|
|
194
|
+
const streams = { ...a.streams, ...b.streams };
|
|
195
|
+
return {
|
|
196
|
+
operations,
|
|
197
|
+
streams,
|
|
198
|
+
routes: { operations: Object.keys(operations), streams: Object.keys(streams) },
|
|
199
|
+
} as Contract<{
|
|
200
|
+
operations: ShapeOf<A>['operations'] & ShapeOf<B>['operations'];
|
|
201
|
+
streams: ShapeOf<A>['streams'] & ShapeOf<B>['streams'];
|
|
202
|
+
}>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The route groups Broapp reserves for itself.
|
|
207
|
+
*
|
|
208
|
+
* `ai` belongs to the AI layer and `autoapp` to Autoapp's own host routes.
|
|
209
|
+
* Both are mounted as a second host app on the same bridge as the
|
|
210
|
+
* application's, so a name that appeared in both route tables would be
|
|
211
|
+
* unresolvable.
|
|
212
|
+
*/
|
|
213
|
+
export const RESERVED_GROUPS: readonly string[] = ['ai', 'autoapp'];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Throws if a contract declares a route in a reserved group.
|
|
217
|
+
*
|
|
218
|
+
* This is not checked in `defineContract`, because Broapp's own contracts are
|
|
219
|
+
* built with `defineContract` and have to be allowed their groups. It is
|
|
220
|
+
* checked where an *application* contract enters the host instead.
|
|
221
|
+
*/
|
|
222
|
+
export function assertNoReservedRoutes(contract: AnyContract): void {
|
|
223
|
+
for (const route of [...contract.routes.operations, ...contract.routes.streams]) {
|
|
224
|
+
const { group } = splitRoute(route);
|
|
225
|
+
if (RESERVED_GROUPS.includes(group)) {
|
|
226
|
+
throw new TypeError(
|
|
227
|
+
`route ${JSON.stringify(route)} uses the group ${JSON.stringify(group)}, which is reserved for Broapp`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a person has left to answer a question.
|
|
3
|
+
*
|
|
4
|
+
* Shared rather than written twice because the same countdown appears wherever
|
|
5
|
+
* an approval is shown — an approvals strip in an application's tab, a confirm
|
|
6
|
+
* card in a chat panel — and two formatters would eventually disagree about
|
|
7
|
+
* what "one minute left" looks like. It is pure arithmetic over a timestamp:
|
|
8
|
+
* nothing here decides anything, and the gate's own timer is what actually
|
|
9
|
+
* refuses a question nobody answered.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Below this, a question is close enough to running out to say so loudly. */
|
|
13
|
+
export const URGENT_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
/** Milliseconds until `expiresAt`, never negative. */
|
|
16
|
+
export function remainingMs(expiresAt: number, now: number = Date.now()): number {
|
|
17
|
+
return Math.max(0, expiresAt - now);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The time left as `m:ss`.
|
|
22
|
+
*
|
|
23
|
+
* Rounded up, so a question with 600 ms left reads `0:01` rather than `0:00`:
|
|
24
|
+
* a countdown that says zero while the button still works is a countdown
|
|
25
|
+
* people stop believing.
|
|
26
|
+
*/
|
|
27
|
+
export function countdown(expiresAt: number, now: number = Date.now()): string {
|
|
28
|
+
const seconds = Math.ceil(remainingMs(expiresAt, now) / 1000);
|
|
29
|
+
return `${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True while a question is nearly out of time, and false once it is out. */
|
|
33
|
+
export function isUrgent(expiresAt: number, now: number = Date.now()): boolean {
|
|
34
|
+
const left = remainingMs(expiresAt, now);
|
|
35
|
+
return left > 0 && left < URGENT_MS;
|
|
36
|
+
}
|
package/src/shared/errors.ts
CHANGED
|
@@ -80,6 +80,60 @@ export class PublicError extends Error {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* True when `error` is a {@link PublicError}, including one from another copy
|
|
85
|
+
* of this module.
|
|
86
|
+
*
|
|
87
|
+
* `instanceof` is not enough, and the reason is architectural rather than
|
|
88
|
+
* pedantic. An Autoapp release bundles its own copy of `broapp`, while the
|
|
89
|
+
* child runtime that supervises it has the copy compiled into the launcher —
|
|
90
|
+
* so a `PublicError` thrown by the gate in one and caught by `runOperation` in
|
|
91
|
+
* the other is a different class object with the same shape. Reducing it to
|
|
92
|
+
* "internal error" would silently turn every deliberate refusal, the preview
|
|
93
|
+
* policy's included, into a mystery.
|
|
94
|
+
*
|
|
95
|
+
* The check is deliberately narrow: the name, and a `code` from the known set.
|
|
96
|
+
* Nothing an untrusted value could set by accident.
|
|
97
|
+
*/
|
|
98
|
+
export function isPublicError(error: unknown): error is PublicError {
|
|
99
|
+
// No `instanceof PublicError` fast path. It would be true for one of the two
|
|
100
|
+
// copies and false for the other, and a check whose answer depends on which
|
|
101
|
+
// bundle asked is the bug this function exists to fix — including in the
|
|
102
|
+
// reader's mind, the next time somebody copies this shape.
|
|
103
|
+
if (!(error instanceof Error) || error.name !== 'PublicError') return false;
|
|
104
|
+
const code: unknown = (error as { code?: unknown }).code;
|
|
105
|
+
return typeof code === 'string' && (PUBLIC_CODES as readonly string[]).includes(code);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* True when `error` is a {@link BroappError}, including one from another copy
|
|
110
|
+
* of this module.
|
|
111
|
+
*
|
|
112
|
+
* Same reason as {@link isPublicError}. `fromTransportError` is shared code:
|
|
113
|
+
* the browser calls it inside one bundle, and the Autoapp child runtime calls
|
|
114
|
+
* it on an error that came out of a release's own bundle.
|
|
115
|
+
*/
|
|
116
|
+
function isBroappError(error: unknown): error is BroappError {
|
|
117
|
+
if (!(error instanceof Error) || error.name !== 'BroappError') return false;
|
|
118
|
+
const code: unknown = (error as { code?: unknown }).code;
|
|
119
|
+
return typeof code === 'string' && (PUBLIC_CODES as readonly string[]).includes(code);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* True when `error` is Brobridge's cancellation, from any copy of Brobridge.
|
|
124
|
+
*
|
|
125
|
+
* A release bundles its own, so the class identity is not shared. `ErrorCode`
|
|
126
|
+
* is a set of string constants rather than a symbol, which is what makes the
|
|
127
|
+
* value comparison meaningful across copies.
|
|
128
|
+
*/
|
|
129
|
+
function isCancelled(error: unknown): boolean {
|
|
130
|
+
return (
|
|
131
|
+
error instanceof Error &&
|
|
132
|
+
error.name === 'BridgeError' &&
|
|
133
|
+
(error as { code?: unknown }).code === ErrorCode.CANCELLED
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
83
137
|
/** The message every unhandled host failure becomes, on both sides. */
|
|
84
138
|
export const INTERNAL_ERROR_MESSAGE = 'The application could not complete that operation.';
|
|
85
139
|
|
|
@@ -105,7 +159,7 @@ export class BroappError extends Error {
|
|
|
105
159
|
* for a user to read.
|
|
106
160
|
*/
|
|
107
161
|
export function fromTransportError(error: unknown): BroappError {
|
|
108
|
-
if (error
|
|
162
|
+
if (isBroappError(error)) return error;
|
|
109
163
|
const message = error instanceof Error ? error.message : '';
|
|
110
164
|
if (message.startsWith(MARKER)) {
|
|
111
165
|
const space = message.indexOf(' ');
|
|
@@ -118,12 +172,22 @@ export function fromTransportError(error: unknown): BroappError {
|
|
|
118
172
|
);
|
|
119
173
|
}
|
|
120
174
|
}
|
|
121
|
-
if (error
|
|
175
|
+
if (isCancelled(error)) {
|
|
122
176
|
return new BroappError('rejected', 'The operation was cancelled.', error);
|
|
123
177
|
}
|
|
124
178
|
return new BroappError('internal', INTERNAL_ERROR_MESSAGE, error);
|
|
125
179
|
}
|
|
126
180
|
|
|
181
|
+
/**
|
|
182
|
+
* True when an error already carries a message written for the browser.
|
|
183
|
+
*
|
|
184
|
+
* Anything else is a host-side failure whose message may name a path, a query
|
|
185
|
+
* or a token, and must be reduced before it leaves the host.
|
|
186
|
+
*/
|
|
187
|
+
export function isPublicBridgeError(error: unknown): boolean {
|
|
188
|
+
return error instanceof Error && error.message.startsWith(MARKER);
|
|
189
|
+
}
|
|
190
|
+
|
|
127
191
|
/** Convenience constructors, so a handler reads as prose. */
|
|
128
192
|
export const publicError = {
|
|
129
193
|
invalidInput: (message: string): PublicError => new PublicError('invalid_input', message),
|
package/src/shared/index.ts
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
* description and the types derived from it. That is what makes it safe for
|
|
6
6
|
* the browser bundle to follow.
|
|
7
7
|
*/
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
assertNoReservedRoutes,
|
|
10
|
+
defineContract,
|
|
11
|
+
effectOf,
|
|
12
|
+
mergeContracts,
|
|
13
|
+
RESERVED_GROUPS,
|
|
14
|
+
splitRoute,
|
|
15
|
+
} from './contract.ts';
|
|
9
16
|
export type {
|
|
10
17
|
AnyContract,
|
|
11
18
|
Contract,
|
|
12
19
|
ContractShape,
|
|
20
|
+
Effect,
|
|
13
21
|
ShapeOf,
|
|
14
22
|
OperationInput,
|
|
15
23
|
OperationName,
|
|
@@ -21,16 +29,19 @@ export type {
|
|
|
21
29
|
StreamSpec,
|
|
22
30
|
} from './contract.ts';
|
|
23
31
|
|
|
24
|
-
export { s, ValidationError } from './schema.ts';
|
|
25
|
-
export type { Infer, Issue, Result, Schema } from './schema.ts';
|
|
32
|
+
export { isValidationError, s, ValidationError } from './schema.ts';
|
|
33
|
+
export type { Infer, InferObject, Issue, JsonSchema, Result, Schema } from './schema.ts';
|
|
26
34
|
|
|
27
35
|
export {
|
|
28
36
|
BroappError,
|
|
29
37
|
INTERNAL_ERROR_MESSAGE,
|
|
30
38
|
PublicError,
|
|
31
39
|
fromTransportError,
|
|
40
|
+
isPublicError,
|
|
32
41
|
publicError,
|
|
33
42
|
} from './errors.ts';
|
|
34
43
|
export type { PublicErrorCode } from './errors.ts';
|
|
35
44
|
|
|
36
45
|
export { encodeEvent, MAX_EVENT_BYTES, NdjsonDecoder } from './ndjson.ts';
|
|
46
|
+
|
|
47
|
+
export { countdown, isUrgent, remainingMs, URGENT_MS } from './countdown.ts';
|
package/src/shared/schema.ts
CHANGED
|
@@ -35,6 +35,22 @@ export class ValidationError extends Error {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* True when `error` is a {@link ValidationError}, including one from another
|
|
40
|
+
* copy of this module.
|
|
41
|
+
*
|
|
42
|
+
* `instanceof` is not enough for the same reason it is not enough for
|
|
43
|
+
* `PublicError`: an Autoapp release bundles its own copy of `broapp`, so a
|
|
44
|
+
* schema built in one bundle and parsed by code in another throws a different
|
|
45
|
+
* class object with the same shape. Reducing that to "invalid input" with no
|
|
46
|
+
* message would tell a caller nothing about what was actually wrong.
|
|
47
|
+
*/
|
|
48
|
+
export function isValidationError(error: unknown): error is ValidationError {
|
|
49
|
+
return (
|
|
50
|
+
error instanceof Error && error.name === 'ValidationError' && Array.isArray((error as { issues?: unknown }).issues)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
function formatPath(path: IssuePath): string {
|
|
39
55
|
let out = '';
|
|
40
56
|
for (const segment of path) {
|
|
@@ -49,6 +65,15 @@ export type Result<T> =
|
|
|
49
65
|
| { readonly ok: true; readonly value: T }
|
|
50
66
|
| { readonly ok: false; readonly issues: readonly Issue[] };
|
|
51
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A JSON Schema document, as a plain object.
|
|
70
|
+
*
|
|
71
|
+
* Broapp emits a draft 2020-12 subset — enough for a language model provider
|
|
72
|
+
* to describe a tool's arguments, and nothing more. It is deliberately not a
|
|
73
|
+
* typed tree: every consumer so far hands it straight to a provider as JSON.
|
|
74
|
+
*/
|
|
75
|
+
export type JsonSchema = Record<string, unknown>;
|
|
76
|
+
|
|
52
77
|
/** A runtime schema for one JSON value. */
|
|
53
78
|
export interface Schema<T> {
|
|
54
79
|
/** Discriminates a Broapp schema from a foreign one at runtime. */
|
|
@@ -57,6 +82,8 @@ export interface Schema<T> {
|
|
|
57
82
|
check(value: unknown, path?: IssuePath): Result<T>;
|
|
58
83
|
/** Validate, or throw {@link ValidationError}. */
|
|
59
84
|
parse(value: unknown): T;
|
|
85
|
+
/** A JSON Schema (draft 2020-12 subset) describing what `parse` accepts. */
|
|
86
|
+
toJsonSchema(): JsonSchema;
|
|
60
87
|
/** Phantom marker; never present at runtime. */
|
|
61
88
|
readonly _type?: T;
|
|
62
89
|
}
|
|
@@ -64,7 +91,28 @@ export interface Schema<T> {
|
|
|
64
91
|
/** The TypeScript type a schema accepts. */
|
|
65
92
|
export type Infer<S> = S extends Schema<infer T> ? T : never;
|
|
66
93
|
|
|
67
|
-
|
|
94
|
+
/** Collapse an intersection back into one object type, keeping `?` modifiers. */
|
|
95
|
+
type Flatten<T> = { [K in keyof T]: T[K] };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What an object schema accepts.
|
|
99
|
+
*
|
|
100
|
+
* A field wrapped in `s.optional` may be left out entirely, so its key is
|
|
101
|
+
* optional here rather than merely admitting `undefined`. Without this an
|
|
102
|
+
* `s.optional` field would still have to be spelled out at every construction
|
|
103
|
+
* site, which is the opposite of what the wrapper says.
|
|
104
|
+
*/
|
|
105
|
+
export type InferObject<F> = Flatten<
|
|
106
|
+
{ [K in keyof F as undefined extends Infer<F[K]> ? never : K]: Infer<F[K]> } & {
|
|
107
|
+
[K in keyof F as undefined extends Infer<F[K]> ? K : never]?: Infer<F[K]>;
|
|
108
|
+
}
|
|
109
|
+
>;
|
|
110
|
+
|
|
111
|
+
function schema<T>(
|
|
112
|
+
kind: string,
|
|
113
|
+
check: (value: unknown, path: IssuePath) => Result<T>,
|
|
114
|
+
toJsonSchema: () => JsonSchema,
|
|
115
|
+
): Schema<T> {
|
|
68
116
|
const self: Schema<T> = {
|
|
69
117
|
kind,
|
|
70
118
|
check: (value, path = []) => check(value, path),
|
|
@@ -73,10 +121,25 @@ function schema<T>(kind: string, check: (value: unknown, path: IssuePath) => Res
|
|
|
73
121
|
if (outcome.ok) return outcome.value;
|
|
74
122
|
throw new ValidationError(outcome.issues);
|
|
75
123
|
},
|
|
124
|
+
toJsonSchema,
|
|
76
125
|
};
|
|
77
126
|
return self;
|
|
78
127
|
}
|
|
79
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Assemble a JSON Schema object, dropping every keyword whose option was not
|
|
131
|
+
* given. An explicit `minLength: undefined` disappears from `JSON.stringify`
|
|
132
|
+
* but is still a key at runtime, and a provider that enumerates keywords would
|
|
133
|
+
* see it — so the key is never created in the first place.
|
|
134
|
+
*/
|
|
135
|
+
function keywords(entries: Record<string, unknown>): JsonSchema {
|
|
136
|
+
const out: JsonSchema = {};
|
|
137
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
138
|
+
if (value !== undefined) out[key] = value;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
80
143
|
function fail<T = never>(path: IssuePath, message: string): Result<T> {
|
|
81
144
|
return { ok: false, issues: [{ path, message }] };
|
|
82
145
|
}
|
|
@@ -118,7 +181,15 @@ export const s = {
|
|
|
118
181
|
if (!anchored.test(value)) return fail(path, 'does not match the required format');
|
|
119
182
|
}
|
|
120
183
|
return { ok: true, value };
|
|
121
|
-
}
|
|
184
|
+
},
|
|
185
|
+
() =>
|
|
186
|
+
keywords({
|
|
187
|
+
type: 'string',
|
|
188
|
+
minLength: options.min,
|
|
189
|
+
maxLength: options.max,
|
|
190
|
+
pattern: options.pattern?.source,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
122
193
|
},
|
|
123
194
|
|
|
124
195
|
number(options: NumberOptions = {}): Schema<number> {
|
|
@@ -134,30 +205,46 @@ export const s = {
|
|
|
134
205
|
return fail(path, `expected <= ${String(options.max)}`);
|
|
135
206
|
}
|
|
136
207
|
return { ok: true, value };
|
|
137
|
-
}
|
|
208
|
+
},
|
|
209
|
+
() =>
|
|
210
|
+
keywords({
|
|
211
|
+
type: options.int === true ? 'integer' : 'number',
|
|
212
|
+
minimum: options.min,
|
|
213
|
+
maximum: options.max,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
138
216
|
},
|
|
139
217
|
|
|
140
218
|
boolean(): Schema<boolean> {
|
|
141
|
-
return schema(
|
|
142
|
-
|
|
219
|
+
return schema(
|
|
220
|
+
'boolean',
|
|
221
|
+
(value, path) =>
|
|
222
|
+
typeof value === 'boolean' ? { ok: true, value } : fail(path, 'expected a boolean'),
|
|
223
|
+
() => ({ type: 'boolean' }),
|
|
143
224
|
);
|
|
144
225
|
},
|
|
145
226
|
|
|
146
227
|
literal<const T extends string | number | boolean>(expected: T): Schema<T> {
|
|
147
|
-
return schema(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
228
|
+
return schema(
|
|
229
|
+
'literal',
|
|
230
|
+
(value, path) =>
|
|
231
|
+
value === expected
|
|
232
|
+
? { ok: true, value: expected }
|
|
233
|
+
: fail(path, `expected ${JSON.stringify(expected)}`),
|
|
234
|
+
() => ({ const: expected }),
|
|
151
235
|
);
|
|
152
236
|
},
|
|
153
237
|
|
|
154
238
|
/** A closed set of string values. */
|
|
155
239
|
enum<const T extends readonly string[]>(values: T): Schema<T[number]> {
|
|
156
240
|
const allowed = new Set<string>(values);
|
|
157
|
-
return schema(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
241
|
+
return schema(
|
|
242
|
+
'enum',
|
|
243
|
+
(value, path) =>
|
|
244
|
+
typeof value === 'string' && allowed.has(value)
|
|
245
|
+
? { ok: true, value: value as T[number] }
|
|
246
|
+
: fail(path, `expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}`),
|
|
247
|
+
() => ({ type: 'string', enum: [...values] }),
|
|
161
248
|
);
|
|
162
249
|
},
|
|
163
250
|
|
|
@@ -178,7 +265,15 @@ export const s = {
|
|
|
178
265
|
else issues.push(...outcome.issues);
|
|
179
266
|
}
|
|
180
267
|
return issues.length > 0 ? { ok: false, issues } : { ok: true, value: out };
|
|
181
|
-
}
|
|
268
|
+
},
|
|
269
|
+
() =>
|
|
270
|
+
keywords({
|
|
271
|
+
type: 'array',
|
|
272
|
+
items: item.toJsonSchema(),
|
|
273
|
+
minItems: options.min,
|
|
274
|
+
maxItems: options.max,
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
182
277
|
},
|
|
183
278
|
|
|
184
279
|
/**
|
|
@@ -188,9 +283,7 @@ export const s = {
|
|
|
188
283
|
* handler contains only what the schema named, so a property smuggled in by
|
|
189
284
|
* a caller cannot reach application code by accident.
|
|
190
285
|
*/
|
|
191
|
-
object<F extends Record<string, Schema<unknown>>>(
|
|
192
|
-
fields: F,
|
|
193
|
-
): Schema<{ [K in keyof F]: Infer<F[K]> }> {
|
|
286
|
+
object<F extends Record<string, Schema<unknown>>>(fields: F): Schema<InferObject<F>> {
|
|
194
287
|
const entries = Object.entries(fields);
|
|
195
288
|
return schema('object', (value, path) => {
|
|
196
289
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -208,30 +301,50 @@ export const s = {
|
|
|
208
301
|
}
|
|
209
302
|
return issues.length > 0
|
|
210
303
|
? { ok: false, issues }
|
|
211
|
-
: { ok: true, value: out as
|
|
212
|
-
}
|
|
304
|
+
: { ok: true, value: out as InferObject<F> };
|
|
305
|
+
},
|
|
306
|
+
() => ({
|
|
307
|
+
type: 'object',
|
|
308
|
+
properties: Object.fromEntries(entries.map(([key, field]) => [key, field.toJsonSchema()])),
|
|
309
|
+
// An optional field is one the object may leave out, so optionality is
|
|
310
|
+
// expressed here rather than inside the field's own schema.
|
|
311
|
+
required: entries.filter(([, field]) => field.kind !== 'optional').map(([key]) => key),
|
|
312
|
+
additionalProperties: false,
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
213
315
|
},
|
|
214
316
|
|
|
215
317
|
/** A value that may be absent or `undefined`. */
|
|
216
318
|
optional<T>(inner: Schema<T>): Schema<T | undefined> {
|
|
217
|
-
return schema<T | undefined>(
|
|
218
|
-
|
|
319
|
+
return schema<T | undefined>(
|
|
320
|
+
'optional',
|
|
321
|
+
(value, path) => (value === undefined ? { ok: true, value: undefined } : inner.check(value, path)),
|
|
322
|
+
// JSON Schema has no "optional" keyword; the enclosing object omits the
|
|
323
|
+
// key from `required` instead.
|
|
324
|
+
() => inner.toJsonSchema(),
|
|
219
325
|
);
|
|
220
326
|
},
|
|
221
327
|
|
|
222
328
|
/** A value that may be `null`. */
|
|
223
329
|
nullable<T>(inner: Schema<T>): Schema<T | null> {
|
|
224
|
-
return schema<T | null>(
|
|
225
|
-
|
|
330
|
+
return schema<T | null>(
|
|
331
|
+
'nullable',
|
|
332
|
+
(value, path) => (value === null ? { ok: true, value: null } : inner.check(value, path)),
|
|
333
|
+
() => ({ anyOf: [inner.toJsonSchema(), { type: 'null' }] }),
|
|
226
334
|
);
|
|
227
335
|
},
|
|
228
336
|
|
|
229
337
|
/** Nothing at all. The input type of an operation that takes no argument. */
|
|
230
338
|
void(): Schema<void> {
|
|
231
|
-
return schema(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
339
|
+
return schema(
|
|
340
|
+
'void',
|
|
341
|
+
(value, path) =>
|
|
342
|
+
value === undefined || value === null
|
|
343
|
+
? { ok: true, value: undefined }
|
|
344
|
+
: fail(path, 'expected no value'),
|
|
345
|
+
// A provider that asks for a tool's arguments wants an object, and an
|
|
346
|
+
// operation that takes nothing takes an empty one.
|
|
347
|
+
() => ({ type: 'object', properties: {}, additionalProperties: false }),
|
|
235
348
|
);
|
|
236
349
|
},
|
|
237
350
|
|
|
@@ -243,6 +356,6 @@ export const s = {
|
|
|
243
356
|
* data is untrusted.
|
|
244
357
|
*/
|
|
245
358
|
unknown(): Schema<unknown> {
|
|
246
|
-
return schema('unknown', (value) => ({ ok: true, value }));
|
|
359
|
+
return schema('unknown', (value) => ({ ok: true, value }), () => ({}));
|
|
247
360
|
},
|
|
248
361
|
} as const;
|