@modelprofile.com/flexharness 10.16.0 → 10.17.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.
Files changed (38) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.d.ts +3 -0
  3. package/dist_ts/classes.flexharness.js +36 -15
  4. package/dist_ts/classes.stores.js +40 -8
  5. package/dist_ts/classes.toolcatalog.d.ts +9 -0
  6. package/dist_ts/classes.toolcatalog.js +98 -8
  7. package/dist_ts/interfaces.d.ts +9 -1
  8. package/dist_ts/interfaces.js +4 -2
  9. package/dist_ts/utils.storerules.d.ts +5 -0
  10. package/dist_ts/utils.storerules.js +10 -1
  11. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +27 -2
  12. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +2 -0
  13. package/dist_ts_stores_nosqldb/classes.recordstore.js +7 -1
  14. package/dist_ts_typedrequest/catalog.d.ts +28 -0
  15. package/dist_ts_typedrequest/catalog.js +79 -0
  16. package/dist_ts_typedrequest/describe.d.ts +10 -0
  17. package/dist_ts_typedrequest/describe.js +189 -0
  18. package/dist_ts_typedrequest/index.d.ts +3 -0
  19. package/dist_ts_typedrequest/index.js +4 -0
  20. package/dist_ts_typedrequest/interfaces.d.ts +107 -0
  21. package/dist_ts_typedrequest/interfaces.js +2 -0
  22. package/dist_ts_typedrequest/plugins.d.ts +7 -0
  23. package/dist_ts_typedrequest/plugins.js +11 -0
  24. package/package.json +14 -4
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/classes.flexharness.ts +40 -14
  27. package/ts/classes.stores.ts +49 -6
  28. package/ts/classes.toolcatalog.ts +90 -7
  29. package/ts/interfaces.ts +10 -2
  30. package/ts/utils.storerules.ts +15 -0
  31. package/ts_stores_nosqldb/classes.nosqldbstores.ts +29 -0
  32. package/ts_stores_nosqldb/classes.recordstore.ts +8 -0
  33. package/ts_typedrequest/catalog.ts +91 -0
  34. package/ts_typedrequest/describe.ts +211 -0
  35. package/ts_typedrequest/index.ts +3 -0
  36. package/ts_typedrequest/interfaces.ts +124 -0
  37. package/ts_typedrequest/plugins.ts +15 -0
  38. package/ts_typedrequest/readme.md +66 -0
@@ -907,10 +907,13 @@ class FlexToolCatalogCore {
907
907
  if (cached) return cached;
908
908
  let rendered: IRenderedDeclaration;
909
909
  if (entry.declaration !== undefined) {
910
- rendered = { text: entry.declaration.trim(), types: this.scanTypes(entry.declaration) };
911
- // A host's declaration text does not tell parameters from the result: every type it names is
912
- // declared with it.
913
- this.methodTypeSets.set(entry.path, { input: rendered.types, output: [] });
910
+ // A host's declaration names its parameters' types in its parameter list and its result's after
911
+ // it; text that is no plain function declaration declares every type it names with it.
912
+ const parts = signatureParts(entry.declaration, entry.name);
913
+ const input = this.scanTypes(parts?.params ?? entry.declaration);
914
+ const output = parts ? this.scanTypes(parts.result) : [];
915
+ rendered = { text: entry.declaration.trim(), types: [...new Set([...input, ...output])].sort(compareCodePoints) };
916
+ this.methodTypeSets.set(entry.path, { input, output });
914
917
  } else {
915
918
  const inputWriter = new SchemaDeclarationWriter(this.scopeContext(entry.inputScope));
916
919
  const members = inputWriter.objectMembers(entry.input.json);
@@ -944,9 +947,10 @@ class FlexToolCatalogCore {
944
947
  return rendered;
945
948
  }
946
949
 
950
+ /** The catalog types a declaration's code names; its comments and string literals name none. */
947
951
  private scanTypes(text: string, self?: string): string[] {
948
952
  const found = new Set<string>();
949
- for (const match of text.matchAll(identifierScanPattern)) {
953
+ for (const match of codeOf(text).matchAll(identifierScanPattern)) {
950
954
  if (match[0] !== self && this.types.has(match[0])) found.add(match[0]);
951
955
  }
952
956
  return [...found].sort(compareCodePoints);
@@ -1045,6 +1049,16 @@ export class FlexToolCatalog<TScope = unknown> {
1045
1049
  private readonly describeTopMatch: boolean,
1046
1050
  ) {}
1047
1051
 
1052
+ /**
1053
+ * The identifier a name from elsewhere, such as a file or an API's method, is declared under as a
1054
+ * path segment or a type: every other character `_`, `_` before a leading digit and after a
1055
+ * reserved word (`export-jobs` becomes `export_jobs`, `delete` becomes `delete_`). The catalog
1056
+ * maps type names that are no identifiers the same way.
1057
+ */
1058
+ public static identifierOf(name: string): string {
1059
+ return typeIdentifierOf(name);
1060
+ }
1061
+
1048
1062
  /**
1049
1063
  * Prepares `catalog`. Preparing the same object again returns the cached preparation, so a host
1050
1064
  * that returns one catalog object for every run pays for it once.
@@ -1290,7 +1304,7 @@ export class FlexToolCatalog<TScope = unknown> {
1290
1304
  const unknownTypes = requestedTypes.filter((name) => identifierOf(name) === undefined).sort(compareCodePoints);
1291
1305
  const notes = [
1292
1306
  ...unknownMethods.map((path) => {
1293
- const suggestions = closeMatches(path, this.methods);
1307
+ const suggestions = closeMatches(path, this.listedMethods());
1294
1308
  return `Unknown method ${path}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ' Find methods with search_tools.'}`;
1295
1309
  }),
1296
1310
  ...unknownTypes.map((name) => {
@@ -1336,7 +1350,7 @@ export class FlexToolCatalog<TScope = unknown> {
1336
1350
  error: {
1337
1351
  error: 'unknown_method',
1338
1352
  method: path,
1339
- didYouMean: isNamespace ? [] : closeMatches(path, this.methods),
1353
+ didYouMean: isNamespace ? [] : closeMatches(path, this.listedMethods()),
1340
1354
  hint: isNamespace
1341
1355
  ? `${path} is a namespace; list it with search_tools({"namespace":"${path}"}).`
1342
1356
  : 'Find methods with search_tools.',
@@ -1393,6 +1407,11 @@ export class FlexToolCatalog<TScope = unknown> {
1393
1407
  return { method: input.method, params: input.params === undefined ? {} : input.params };
1394
1408
  }
1395
1409
 
1410
+ /** The method paths search and suggestions may name: built-ins such as `results.get` stay out. */
1411
+ private listedMethods(): string[] {
1412
+ return this.methods.filter((path) => !this.core.unlisted.has(path));
1413
+ }
1414
+
1396
1415
  private isAdmitted(path: string): boolean {
1397
1416
  return this.admitted === undefined || this.admitted.has(path);
1398
1417
  }
@@ -1780,6 +1799,70 @@ export const catalogToolInputSchema = <TInput>(definition: IFlexCatalogToolDefin
1780
1799
  });
1781
1800
  };
1782
1801
 
1802
+ /** Declaration text with its comments and the contents of its string literals blanked. */
1803
+ const codeOf = (text: string): string => {
1804
+ let code = '';
1805
+ for (let index = 0; index < text.length; index++) {
1806
+ const char = text[index];
1807
+ if (char === '/' && text[index + 1] === '/') {
1808
+ const end = text.indexOf('\n', index);
1809
+ index = (end === -1 ? text.length : end) - 1;
1810
+ code += ' ';
1811
+ } else if (char === '/' && text[index + 1] === '*') {
1812
+ const end = text.indexOf('*/', index + 2);
1813
+ index = end === -1 ? text.length : end + 1;
1814
+ code += ' ';
1815
+ } else if (char === '"' || char === "'" || char === '`') {
1816
+ let end = index + 1;
1817
+ while (end < text.length && text[end] !== char) end += text[end] === '\\' ? 2 : 1;
1818
+ code += `${char}${char}`;
1819
+ index = end;
1820
+ } else {
1821
+ code += char;
1822
+ }
1823
+ }
1824
+ return code;
1825
+ };
1826
+
1827
+ /** The index of the bracket that closes the one at `open`, or -1. */
1828
+ const closingBracket = (code: string, open: number): number => {
1829
+ const pairs: Record<string, string> = { '(': ')', '<': '>', '{': '}', '[': ']' };
1830
+ const stack: string[] = [];
1831
+ for (let index = open; index < code.length; index++) {
1832
+ const char = code[index];
1833
+ if (char === '=' && code[index + 1] === '>') {
1834
+ index++;
1835
+ } else if (pairs[char] !== undefined && (char !== '<' || stack.length === 0 || stack[stack.length - 1] === '>')) {
1836
+ stack.push(pairs[char]);
1837
+ } else if (char === stack[stack.length - 1]) {
1838
+ stack.pop();
1839
+ if (stack.length === 0) return index;
1840
+ }
1841
+ }
1842
+ return -1;
1843
+ };
1844
+
1845
+ /**
1846
+ * A host method declaration's parameter list and the result after it, as code without comments, or
1847
+ * undefined where the text is no plain `function name(…): …` declaration.
1848
+ */
1849
+ const signatureParts = (declaration: string, name: string): { params: string; result: string } | undefined => {
1850
+ const code = codeOf(declaration);
1851
+ const start = new RegExp(`\\bfunction\\s+${name}\\s*`).exec(code);
1852
+ if (!start) return undefined;
1853
+ let open = start.index + start[0].length;
1854
+ if (code[open] === '<') {
1855
+ const typeParameters = closingBracket(code, open);
1856
+ if (typeParameters === -1) return undefined;
1857
+ open = typeParameters + 1;
1858
+ while (/\s/.test(code[open] ?? '')) open++;
1859
+ }
1860
+ if (code[open] !== '(') return undefined;
1861
+ const close = closingBracket(code, open);
1862
+ if (close === -1) return undefined;
1863
+ return { params: code.slice(open + 1, close), result: code.slice(close + 1) };
1864
+ };
1865
+
1783
1866
  /** A root-level method declaration gets `declare`, as a namespace member does not need it. */
1784
1867
  const withDeclare = (text: string): string => {
1785
1868
  const lines = text.split('\n');
package/ts/interfaces.ts CHANGED
@@ -688,6 +688,8 @@ export const FLEX_RESULT_METHOD = 'results.get';
688
688
  export const FLEX_RESULT_LIMITS = Object.freeze({
689
689
  /** The most of one output a result stores; beyond it the stored value is truncated. */
690
690
  maxStoredBytes: 8 * 1024 * 1024,
691
+ /** The results a session keeps; a new one beyond them deletes the oldest. */
692
+ maxResultsPerSession: 32,
691
693
  /** The size of the summary the model gets in place of the output. */
692
694
  summaryBytes: 2 * 1024,
693
695
  });
@@ -720,7 +722,7 @@ export const FLEX_TOOL_CATALOG_LIMITS = Object.freeze({
720
722
  maxIdentifierCharacters: 128,
721
723
  maxSummaryCharacters: 120,
722
724
  maxDescriptionBytes: 16 * 1024,
723
- maxDeclarationBytes: 16 * 1024,
725
+ maxDeclarationBytes: 64 * 1024,
724
726
  maxTags: 16,
725
727
  maxTagCharacters: 48,
726
728
  maxAliases: 16,
@@ -1432,11 +1434,17 @@ export interface IFlexToolJobStoreProvider {
1432
1434
 
1433
1435
  /**
1434
1436
  * Tool outputs over the output budget, stored whole for the session that produced them. A result
1435
- * is written once under its handle and read by `results.get`.
1437
+ * is written once under its handle and read by `results.get`; FlexHarness keeps a session's latest
1438
+ * `FLEX_RESULT_LIMITS.maxResultsPerSession` results and deletes older ones. A FlexHarness writes a
1439
+ * session's results one operation at a time, so a store need not order concurrent writes of one
1440
+ * session.
1436
1441
  */
1437
1442
  export interface IFlexResultStore {
1438
1443
  save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void>;
1439
1444
  load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined>;
1445
+ /** The handles of the session's results, oldest first. */
1446
+ list(storageKey: string, sessionId: string): Promise<string[]>;
1447
+ delete(storageKey: string, sessionId: string, handle: string): Promise<void>;
1440
1448
  deleteSession(storageKey: string, sessionId: string): Promise<void>;
1441
1449
  }
1442
1450
 
@@ -175,3 +175,18 @@ export function assertSameArchiveEvents(
175
175
  throw new Error(`Archive "${next.archiveId}" already exists with different events.`);
176
176
  }
177
177
  }
178
+
179
+ /** The index of a session's stored results: their handles, oldest first. */
180
+ export interface IFlexResultIndex {
181
+ handles: string[];
182
+ }
183
+
184
+ export function validateResultIndex(value: unknown): IFlexResultIndex {
185
+ assertRecord(value, '$resultIndex');
186
+ assertExactKeys(value, ['handles'], '$resultIndex');
187
+ const handles = value.handles;
188
+ if (!Array.isArray(handles) || !handles.every((handle) => typeof handle === 'string')) {
189
+ throw new FlexHarnessStoreFormatError('$resultIndex.handles must list handles.');
190
+ }
191
+ return { handles: [...handles] };
192
+ }
@@ -47,6 +47,7 @@ import {
47
47
  validateExpectedRevision,
48
48
  validateIdentifier,
49
49
  validateProjectManagementWriteContext,
50
+ validateResultIndex,
50
51
  validateSnapshotSave,
51
52
  } from '../ts/utils.storerules.js';
52
53
  import { FlexRecordStore, type IFlexRecordWrite } from './classes.recordstore.js';
@@ -431,6 +432,11 @@ export interface INoSqlFlexHarnessStoresOptions {
431
432
  operationTimeoutMs?: number;
432
433
  }
433
434
 
435
+ /**
436
+ * One record per result and an index record of the session's handles, oldest first, changed with
437
+ * compare-and-swap. A result whose handle the index lost to a failed write stays until its session
438
+ * is deleted.
439
+ */
434
440
  class NoSqlResultStore implements IFlexResultStore {
435
441
  constructor(private readonly records: FlexRecordStore) {}
436
442
 
@@ -446,6 +452,7 @@ class NoSqlResultStore implements IFlexResultStore {
446
452
  },
447
453
  contention: conflict,
448
454
  });
455
+ await this.changeIndex(storageKey, sessionId, (handles) => [...handles, handle]);
449
456
  }
450
457
 
451
458
  public load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined> {
@@ -453,9 +460,31 @@ class NoSqlResultStore implements IFlexResultStore {
453
460
  return this.records.read({ domain: 'result', storageKey, sessionId, entryId: handle }, validateResult);
454
461
  }
455
462
 
463
+ public async list(storageKey: string, sessionId: string): Promise<string[]> {
464
+ const index = await this.records.read({ domain: 'result', storageKey, sessionId }, validateResultIndex);
465
+ return index?.handles ?? [];
466
+ }
467
+
468
+ public async delete(storageKey: string, sessionId: string, handle: string): Promise<void> {
469
+ validateIdentifier(handle, 'handle');
470
+ await this.changeIndex(storageKey, sessionId, (handles) => handles.filter((entry) => entry !== handle));
471
+ await this.records.delete({ domain: 'result', storageKey, sessionId, entryId: handle });
472
+ }
473
+
456
474
  public deleteSession(storageKey: string, sessionId: string): Promise<void> {
457
475
  return this.records.deleteOwner(storageKey, sessionId, ['result']);
458
476
  }
477
+
478
+ /** The session's index record: the result records' handles, without an entry of its own. */
479
+ private changeIndex(storageKey: string, sessionId: string, change: (handles: string[]) => string[]): Promise<void> {
480
+ const contention = () => new Error(`The result index of session "${sessionId}" is contended by concurrent writers.`);
481
+ return this.records.mutate({ domain: 'result', storageKey, sessionId }, {
482
+ validate: validateResultIndex,
483
+ // The write token fences concurrent writers; the index keeps no revision of its own.
484
+ decide: (current) => ({ revision: 1, value: { handles: change(current?.handles ?? []) } }),
485
+ contention,
486
+ });
487
+ }
459
488
  }
460
489
 
461
490
  function validateResult(value: unknown): TJsonValue {
@@ -297,6 +297,13 @@ export class FlexRecordStore {
297
297
  throw mutation.contention(actualRevision);
298
298
  }
299
299
 
300
+ /** Deletes one record and its chunks. */
301
+ public async delete(address: IFlexRecordAddress): Promise<void> {
302
+ await this.prepare();
303
+ const resolved = this.resolve(address);
304
+ await this.deleteInTransaction(resolved.path, { recordId: resolved.recordId, domain: resolved.domain });
305
+ }
306
+
300
307
  /** Deletes every record and chunk one session owns in the given domains. */
301
308
  public async deleteOwner(
302
309
  storageKey: string,
@@ -324,6 +331,7 @@ export class FlexRecordStore {
324
331
  private async deleteInTransaction(
325
332
  path: string,
326
333
  selector:
334
+ | { recordId: string; domain: TFlexRecordDomain }
327
335
  | { ownerKey: string; domain: { $in: TFlexRecordDomain[] } }
328
336
  | { namespaceKey: string; domain: TFlexRecordDomain },
329
337
  ): Promise<void> {
@@ -0,0 +1,91 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IFlexCatalogMethod, IFlexCatalogRunContext, IFlexToolCatalog } from '@modelprofile.com/flexharness';
3
+ import type {
4
+ IFlexTypedRequestCatalogOptions,
5
+ IFlexTypedRequestMethod,
6
+ IFlexTypedRequestSurface,
7
+ } from './interfaces.js';
8
+
9
+ /**
10
+ * A typed request the router refused: the `text` and `data` of its error, as a client of the API
11
+ * would receive them. A host's `externalErrorProjector` decides what of it the model sees.
12
+ */
13
+ export class FlexTypedRequestError extends Error {
14
+ constructor(
15
+ /** The typed method: `getVoucher`. */
16
+ public readonly method: string,
17
+ /** The error's text, such as a `TypedResponseError`'s message. */
18
+ public readonly text: string,
19
+ /** The error's data. */
20
+ public readonly data?: unknown,
21
+ ) {
22
+ super(`${method} was refused: ${text}`);
23
+ this.name = 'FlexTypedRequestError';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The tool catalog of a typedrequest surface: every included method becomes a catalog method of
29
+ * its path, declared as its contract writes it and validated against its schema, and runs through
30
+ * `router.routeAndAddResponse()` in process, its request the model's parameters with the omitted
31
+ * properties `request` fills. A refusal rejects with `FlexTypedRequestError`.
32
+ */
33
+ export function catalogFromTypedRequests<TScope = unknown>(
34
+ surface: IFlexTypedRequestSurface,
35
+ options: IFlexTypedRequestCatalogOptions<TScope>,
36
+ ): IFlexToolCatalog<TScope> {
37
+ if (typeof options?.router?.routeAndAddResponse !== 'function') {
38
+ throw new plugins.FlexHarnessValidationError('catalogFromTypedRequests needs a router with routeAndAddResponse().');
39
+ }
40
+ const aliases = options.aliases ?? {};
41
+ const known = new Set(surface.methods.map((method) => method.method));
42
+ const unknown = Object.keys(aliases).filter((method) => !known.has(method));
43
+ if (unknown.length > 0) {
44
+ throw new plugins.FlexHarnessValidationError(`The surface has no method ${unknown.join(', ')} to give aliases.`);
45
+ }
46
+ if (surface.omit.length > 0 && !options.request) {
47
+ throw new plugins.FlexHarnessValidationError(
48
+ `The surface leaves ${surface.omit.join(', ')} out of every request; the options' request() must fill them.`,
49
+ );
50
+ }
51
+ const include = options.include ?? ((method: IFlexTypedRequestMethod) => !method.deprecated);
52
+ const { router } = options;
53
+ const call = async (
54
+ method: IFlexTypedRequestMethod,
55
+ params: Record<string, unknown>,
56
+ context: IFlexCatalogRunContext<TScope>,
57
+ ): Promise<unknown> => {
58
+ const filled = options.request ? await options.request(method, context) : {};
59
+ const localData = options.localData ? await options.localData(method, context) : undefined;
60
+ const answer = await router.routeAndAddResponse(
61
+ {
62
+ method: method.method,
63
+ request: { ...params, ...filled },
64
+ response: {},
65
+ correlation: { id: plugins.crypto.randomUUID(), phase: 'request' },
66
+ },
67
+ { ...(localData ? { trustedLocalData: localData } : {}), trustedAbortSignal: context.abortSignal },
68
+ );
69
+ if (!answer) throw new FlexTypedRequestError(method.method, 'The router answered nothing.');
70
+ if (answer.error) throw new FlexTypedRequestError(method.method, answer.error.text, answer.error.data);
71
+ return answer.response;
72
+ };
73
+ return {
74
+ methods: surface.methods.filter((method) => include(method)).map((method): IFlexCatalogMethod<TScope> => ({
75
+ method: method.path,
76
+ summary: method.summary,
77
+ ...(method.description ? { description: method.description } : {}),
78
+ ...(aliases[method.method] ? { aliases: aliases[method.method] } : {}),
79
+ inputSchema: method.inputSchema,
80
+ outputSchema: method.outputSchema,
81
+ declaration: method.declaration,
82
+ run: (params: Record<string, unknown>, context) => call(method, params, context),
83
+ ...(options.onCall
84
+ ? { onCall: (params: Record<string, unknown>, context: IFlexCatalogRunContext<TScope>) => options.onCall!(method, params, context) }
85
+ : {}),
86
+ })),
87
+ types: surface.types,
88
+ ...(options.vocabulary ? { vocabulary: options.vocabulary } : {}),
89
+ ...(options.onProposals ? { onProposals: options.onProposals } : {}),
90
+ };
91
+ }
@@ -0,0 +1,211 @@
1
+ import * as plugins from './plugins.js';
2
+ import type {
3
+ IFlexTypedRequestMethod,
4
+ IFlexTypedRequestSurface,
5
+ IFlexTypedRequestSurfaceOptions,
6
+ TFlexTypedRequestSchema,
7
+ } from './interfaces.js';
8
+
9
+ const componentsPrefix = '#/components/schemas/';
10
+ const definitionsPrefix = '#/$defs/';
11
+ const maxSummaryCharacters = 120;
12
+
13
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
14
+ typeof value === 'object' && value !== null && !Array.isArray(value);
15
+
16
+ /** A schema whose references name `#/$defs/<type>` instead of the OpenAPI components. */
17
+ const withDefinitionRefs = (value: unknown): unknown => {
18
+ if (Array.isArray(value)) return value.map(withDefinitionRefs);
19
+ if (!isRecord(value)) return value;
20
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [
21
+ key,
22
+ key === '$ref' && typeof child === 'string' && child.startsWith(componentsPrefix)
23
+ ? `${definitionsPrefix}${child.slice(componentsPrefix.length)}`
24
+ : withDefinitionRefs(child),
25
+ ]));
26
+ };
27
+
28
+ /** The type a `#/$defs/<type>` reference names. */
29
+ const referencedType = (schema: TFlexTypedRequestSchema): string | undefined =>
30
+ typeof schema.$ref === 'string' && schema.$ref.startsWith(definitionsPrefix)
31
+ ? schema.$ref.slice(definitionsPrefix.length)
32
+ : undefined;
33
+
34
+ /**
35
+ * A request schema without the omitted properties: an object loses them, and a reference to a
36
+ * named type that holds one becomes the type's own schema without them.
37
+ */
38
+ const withoutOmitted = (
39
+ schema: TFlexTypedRequestSchema,
40
+ omit: ReadonlySet<string>,
41
+ types: Readonly<Record<string, TFlexTypedRequestSchema>>,
42
+ ): TFlexTypedRequestSchema => {
43
+ const target = referencedType(schema);
44
+ if (target !== undefined) {
45
+ const named = types[target];
46
+ return named !== undefined && holdsOmitted(named, omit, types, new Set([target]))
47
+ ? withoutOmitted(named, omit, types)
48
+ : schema;
49
+ }
50
+ const stripped: TFlexTypedRequestSchema = { ...schema };
51
+ if (isRecord(stripped.properties)) {
52
+ stripped.properties = Object.fromEntries(Object.entries(stripped.properties).filter(([key]) => !omit.has(key)));
53
+ }
54
+ if (Array.isArray(stripped.required)) {
55
+ stripped.required = stripped.required.filter((key) => typeof key !== 'string' || !omit.has(key));
56
+ }
57
+ for (const key of ['allOf', 'anyOf', 'oneOf'] as const) {
58
+ const members = stripped[key];
59
+ if (Array.isArray(members)) {
60
+ stripped[key] = members.map((member) => isRecord(member) ? withoutOmitted(member, omit, types) : member);
61
+ }
62
+ }
63
+ return stripped;
64
+ };
65
+
66
+ /** Whether a schema has an omitted property, itself or through the schemas it composes or refers to. */
67
+ const holdsOmitted = (
68
+ schema: TFlexTypedRequestSchema,
69
+ omit: ReadonlySet<string>,
70
+ types: Readonly<Record<string, TFlexTypedRequestSchema>>,
71
+ seen: Set<string>,
72
+ ): boolean => {
73
+ if (isRecord(schema.properties) && Object.keys(schema.properties).some((key) => omit.has(key))) return true;
74
+ const target = referencedType(schema);
75
+ if (target !== undefined) {
76
+ if (seen.has(target) || types[target] === undefined) return false;
77
+ seen.add(target);
78
+ return holdsOmitted(types[target], omit, types, seen);
79
+ }
80
+ return (['allOf', 'anyOf', 'oneOf'] as const).some((key) => Array.isArray(schema[key])
81
+ && (schema[key] as unknown[]).some((member) => isRecord(member) && holdsOmitted(member, omit, types, seen)));
82
+ };
83
+
84
+ /** Whether a call may leave out every parameter: its schema requires none. */
85
+ const requiresNothing = (
86
+ schema: TFlexTypedRequestSchema,
87
+ types: Readonly<Record<string, TFlexTypedRequestSchema>>,
88
+ ): boolean => {
89
+ const target = referencedType(schema);
90
+ if (target !== undefined) return types[target] === undefined || requiresNothing(types[target], types);
91
+ if (Array.isArray(schema.required) && schema.required.length > 0) return false;
92
+ return !Array.isArray(schema.allOf)
93
+ || schema.allOf.every((member) => !isRecord(member) || requiresNothing(member, types));
94
+ };
95
+
96
+ /** The named types a schema refers to. */
97
+ const referencesIn = (value: unknown, found: Set<string>): Set<string> => {
98
+ if (Array.isArray(value)) {
99
+ for (const entry of value) referencesIn(entry, found);
100
+ } else if (isRecord(value)) {
101
+ const target = referencedType(value);
102
+ if (target !== undefined) found.add(target);
103
+ for (const child of Object.values(value)) referencesIn(child, found);
104
+ }
105
+ return found;
106
+ };
107
+
108
+ /** The first sentence of a contract's JSDoc, its tags left out, within the summary's length. */
109
+ const summaryOf = (documentation: string | undefined, method: string): string => {
110
+ const prose = (documentation ?? '').split(/\n(?=@)/)[0].replace(/\s+/g, ' ').trim();
111
+ const sentence = prose.split(/(?<=[.!?])\s/)[0] ?? '';
112
+ if (!sentence) return method;
113
+ return [...sentence].length <= maxSummaryCharacters
114
+ ? sentence
115
+ : `${[...sentence].slice(0, maxSummaryCharacters - 1).join('')}…`;
116
+ };
117
+
118
+ /** A JSDoc block of the given lines; `*\/` in them cannot end it early. */
119
+ const docBlock = (lines: string[]): string => {
120
+ const text = lines.join('\n').replace(/\*\//g, '*\\/');
121
+ return `/**\n${text.split('\n').map((line) => (line ? ` * ${line}` : ' *')).join('\n')}\n */\n`;
122
+ };
123
+
124
+ /** The area of a contract when the options name none: its file's base name, an `index` file's folder. */
125
+ const defaultArea = (file: string): string => {
126
+ const parsed = plugins.path.posix.parse(file);
127
+ return parsed.name === 'index' ? plugins.path.posix.basename(parsed.dir) || parsed.name : parsed.name;
128
+ };
129
+
130
+ /**
131
+ * Reads a typedrequest API's contracts from their TypeScript sources: each typed method's path
132
+ * (`<area>.<method>`), its summary and JSDoc, its request and response as JSON Schema for
133
+ * validation, and its TypeScript declaration as the contract writes it, for the model to read; and
134
+ * the named types they reach, each with its schema, its declaration, or both. Needs the optional
135
+ * peer `@api.global/typedopenapi`. Run it at build time and keep the JSON, or at startup where the
136
+ * sources are at hand.
137
+ */
138
+ export async function describeTypedRequests(options: IFlexTypedRequestSurfaceOptions): Promise<IFlexTypedRequestSurface> {
139
+ let typedopenapi: Awaited<ReturnType<typeof plugins.importTypedOpenApi>>;
140
+ try {
141
+ typedopenapi = await plugins.importTypedOpenApi();
142
+ } catch (error) {
143
+ throw new plugins.FlexHarnessValidationError(
144
+ 'describeTypedRequests needs the optional peer @api.global/typedopenapi (0.8.0 or later).',
145
+ { cause: error },
146
+ );
147
+ }
148
+ const omit = [...new Set(options.omit ?? [])].sort();
149
+ const source = {
150
+ tsconfig: options.tsconfig,
151
+ contracts: options.contracts,
152
+ ...(options.contractNamePattern ? { contractNamePattern: options.contractNamePattern } : {}),
153
+ };
154
+ const schemaSet = typedopenapi.generateContractSchemas(source);
155
+ const declarationSet = typedopenapi.generateContractDeclarations({ ...source, omit });
156
+ const schemas = withDefinitionRefs(schemaSet.schemas) as Record<string, TFlexTypedRequestSchema>;
157
+ const declarations = new Map(declarationSet.contracts.map((contract) => [contract.name, contract]));
158
+ const omitted = new Set(omit);
159
+
160
+ const paths = new Set<string>();
161
+ const methods: IFlexTypedRequestMethod[] = schemaSet.contracts.map((contract) => {
162
+ const declared = declarations.get(contract.name);
163
+ if (!declared) {
164
+ throw new plugins.FlexHarnessValidationError(`@api.global/typedopenapi declared no contract ${contract.name}, whose schemas it generated.`);
165
+ }
166
+ const area = options.area ? options.area({ name: contract.name, method: contract.method, file: declared.file }) : defaultArea(declared.file);
167
+ const name = plugins.FlexToolCatalog.identifierOf(contract.method);
168
+ const base = `${plugins.FlexToolCatalog.identifierOf(area)}.${name}`;
169
+ let path = base;
170
+ for (let suffix = 2; paths.has(path); suffix++) path = `${base}_${suffix}`;
171
+ paths.add(path);
172
+ const inputSchema = withoutOmitted(withDefinitionRefs(contract.request) as TFlexTypedRequestSchema, omitted, schemas);
173
+ const docLines = [
174
+ ...(declared.documentation ? [declared.documentation] : []),
175
+ ...(declared.request.documentation ? [`@param params ${declared.request.documentation}`] : []),
176
+ ...(declared.response.documentation ? [`@returns ${declared.response.documentation}`] : []),
177
+ ];
178
+ const functionName = path.slice(path.lastIndexOf('.') + 1);
179
+ const optional = requiresNothing(inputSchema, schemas) ? '?' : '';
180
+ return {
181
+ method: contract.method,
182
+ path,
183
+ summary: summaryOf(declared.documentation, contract.method),
184
+ ...(declared.documentation ? { description: declared.documentation } : {}),
185
+ ...(contract.deprecated ? { deprecated: true } : {}),
186
+ inputSchema,
187
+ outputSchema: withDefinitionRefs(contract.response) as TFlexTypedRequestSchema,
188
+ declaration: `${docLines.length ? docBlock(docLines) : ''}function ${functionName}(params${optional}: ${declared.request.type}): Promise<${declared.response.type}>;`,
189
+ };
190
+ }).sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
191
+
192
+ // Only the schemas the methods still reach: an omitted property's types are gone with it.
193
+ const reached = new Set<string>();
194
+ const pending = [...referencesIn(methods.map((method) => [method.inputSchema, method.outputSchema]), new Set())];
195
+ while (pending.length) {
196
+ const name = pending.pop()!;
197
+ if (reached.has(name) || schemas[name] === undefined) continue;
198
+ reached.add(name);
199
+ pending.push(...referencesIn(schemas[name], new Set()));
200
+ }
201
+ const names = [...new Set([...reached, ...Object.keys(declarationSet.types)])].sort();
202
+ return {
203
+ methods,
204
+ types: names.map((name) => ({
205
+ name,
206
+ ...(reached.has(name) ? { schema: schemas[name] } : {}),
207
+ ...(declarationSet.types[name] !== undefined ? { declaration: declarationSet.types[name] } : {}),
208
+ })),
209
+ omit,
210
+ };
211
+ }
@@ -0,0 +1,3 @@
1
+ export * from './interfaces.js';
2
+ export { describeTypedRequests } from './describe.js';
3
+ export { FlexTypedRequestError, catalogFromTypedRequests } from './catalog.js';