@m6d/cortex-cli 0.2.0 → 0.4.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.
@@ -32,9 +32,11 @@ export const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
32
32
  export const MAX_ATTACHMENTS_PER_MESSAGE = 5;
33
33
 
34
34
  export const ATTACHMENT_STATUSES = ["pending", "described", "failed", "skipped"] as const;
35
+ export type AttachmentStatus = (typeof ATTACHMENT_STATUSES)[number];
35
36
 
36
37
  /** Who put the file in the thread: the user through the composer, or a tool mid-turn. */
37
38
  export const ATTACHMENT_ORIGINS = ["upload", "tool"] as const;
39
+ export type AttachmentOrigin = (typeof ATTACHMENT_ORIGINS)[number];
38
40
 
39
41
  export function checkAttachmentPolicy(contentType: string, sizeBytes: number) {
40
42
  if (!ATTACHMENT_MIME_TYPES.some((type) => type === contentType)) return "type";
@@ -42,10 +44,6 @@ export function checkAttachmentPolicy(contentType: string, sizeBytes: number) {
42
44
  return "ok";
43
45
  }
44
46
 
45
- export type AttachmentStatus = (typeof ATTACHMENT_STATUSES)[number];
46
-
47
- export type AttachmentOrigin = (typeof ATTACHMENT_ORIGINS)[number];
48
-
49
47
  export type AttachmentSummary = {
50
48
  id: string;
51
49
  filename: string;
@@ -190,7 +188,7 @@ export function toUiMessage<TPart>(message: CortexMessage<TPart>) {
190
188
  * parked client tool calls on a continuation request. The server owns the
191
189
  * transcript (a continuation sends `messages: []`), so the answered assistant
192
190
  * message itself never crosses the wire — the SDK's park-boundary snapshot
193
- * does not preserve its id, and round-tripping it duplicated the stored
191
+ * does not preserve its id, and round-tripping it would duplicate the stored
194
192
  * message.
195
193
  */
196
194
  export const CLIENT_TOOL_ANSWERS_KEY = "clientToolAnswers";
@@ -206,6 +204,15 @@ export type ClientToolAnswer = {
206
204
  state: "complete" | "error" | "approved" | "denied";
207
205
  };
208
206
 
207
+ /**
208
+ * The id a parked tool call's decision is filed under: the one on its
209
+ * approval record, or `approval_<toolCallId>`, which is how the SDK names
210
+ * every approval it requests, for a part that carries no record.
211
+ */
212
+ export function approvalIdOf(part: { id: string; approval?: { id: string } }) {
213
+ return part.approval?.id ?? `approval_${part.id}`;
214
+ }
215
+
209
216
  /**
210
217
  * Response of `POST /chat/:threadId/tools/:toolCallId/initiate`. `embed:
211
218
  * false` means the pending call is not a CC client tool — the widget
@@ -84,12 +84,12 @@ export function expandDomains(domains: Record<string, DomainDef>) {
84
84
  }
85
85
 
86
86
  function createOwnershipMaps(domains: Record<string, DomainDef>) {
87
- const ownership: OwnershipMaps = {
87
+ const ownership = {
88
88
  concepts: new WeakMap<ConceptDef, string>(),
89
89
  endpoints: new WeakMap<EndpointDef, string>(),
90
90
  services: new WeakMap<ServiceDef, string>(),
91
91
  rules: new WeakMap<RuleDef, string>(),
92
- };
92
+ } satisfies OwnershipMaps;
93
93
 
94
94
  for (const [domainKey, domain] of Object.entries(domains)) {
95
95
  for (const concept of domain.concepts ?? []) {
@@ -138,7 +138,7 @@ function includeConcept(options: {
138
138
  const { domainKey, concept, ownership, collections, pendingConcepts, pendingEndpoints } =
139
139
  options;
140
140
 
141
- includeRule(domainKey, concept.governedBy ?? [], ownership, collections);
141
+ includeRules(domainKey, concept.governedBy ?? [], ownership, collections);
142
142
 
143
143
  if (shouldAttach(ownership.concepts, concept, domainKey)) {
144
144
  pushUnique(collections.concepts, concept);
@@ -182,7 +182,7 @@ function includeEndpoint(options: {
182
182
  pushUnique(collections.endpoints, endpoint);
183
183
  }
184
184
 
185
- includeRule(domainKey, endpoint.governedBy ?? [], ownership, collections);
185
+ includeRules(domainKey, endpoint.governedBy ?? [], ownership, collections);
186
186
 
187
187
  // An endpoint reaches concepts three ways and other endpoints one way, but the
188
188
  // handling is identical: take what this domain owns, and queue whatever is new.
@@ -240,7 +240,7 @@ function includeService(options: {
240
240
  pushUnique(collections.services, service);
241
241
  }
242
242
 
243
- includeRule(domainKey, service.governedBy ?? [], ownership, collections);
243
+ includeRules(domainKey, service.governedBy ?? [], ownership, collections);
244
244
 
245
245
  if (
246
246
  shouldAttach(ownership.concepts, service.belongsTo, domainKey) &&
@@ -250,7 +250,7 @@ function includeService(options: {
250
250
  }
251
251
  }
252
252
 
253
- function includeRule(
253
+ function includeRules(
254
254
  domainKey: string,
255
255
  rules: readonly RuleDef[],
256
256
  ownership: OwnershipMaps,
@@ -281,8 +281,6 @@ function referencesConcept(endpoint: EndpointDef, concept: ConceptDef) {
281
281
  return (
282
282
  endpoint.queries?.includes(concept) ||
283
283
  endpoint.mutates?.includes(concept) ||
284
- endpoint.returns?.some(function (returned) {
285
- return returned.concept === concept;
286
- })
284
+ endpoint.returns?.some((returned) => returned.concept === concept)
287
285
  );
288
286
  }
@@ -24,7 +24,7 @@ import type {
24
24
  ServiceDef,
25
25
  } from "@cortex/contracts/graph";
26
26
 
27
- export type GeneratedCypher = {
27
+ type GeneratedCypher = {
28
28
  nodes: string[];
29
29
  edges: string[];
30
30
  };
package/src/graph/seed.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from "@cortex/contracts/graph";
13
13
 
14
14
  import { expandDomains } from "./expand-domains";
15
- import { generateCypher, type GeneratedCypher } from "./generate-cypher";
15
+ import { generateCypher } from "./generate-cypher";
16
16
  import { validateDomain } from "./validate";
17
17
 
18
18
  type SeedEmbedding = {
@@ -95,7 +95,6 @@ async function generateEmbeddings(
95
95
  embeddingConfig: SeedEmbedding,
96
96
  s: GraphSchema,
97
97
  ) {
98
- // 1. Create the vector index (idempotent)
99
98
  await client.query(
100
99
  `CREATE VECTOR INDEX ${s.conceptEmbeddingsIndex} IF NOT EXISTS
101
100
  FOR (c:${s.label.concept}) ON (c.${s.prop.embedding})
@@ -105,7 +104,6 @@ async function generateEmbeddings(
105
104
  }}`,
106
105
  );
107
106
 
108
- // 2. Fetch all concepts
109
107
  const concepts = (await client.query(
110
108
  `MATCH (c:${s.label.concept}) RETURN c.${s.prop.name} AS name, c.${s.prop.description} AS description`,
111
109
  )) as { name: string; description: string }[];
@@ -114,10 +112,8 @@ async function generateEmbeddings(
114
112
  return { concepts: 0, dimension: embeddingConfig.dimension };
115
113
  }
116
114
 
117
- // 3. Generate embeddings for every concept in one request
118
115
  const embeddings = await embeddingConfig.embed(concepts.map((c) => c.description));
119
116
 
120
- // 4. Store the embeddings in the graph
121
117
  await Promise.all(
122
118
  concepts.map((concept, idx) =>
123
119
  client.query(
@@ -134,7 +130,6 @@ async function generateEmbeddings(
134
130
  export async function seedGraph(config: SeedGraphConfig, domains: Record<string, DomainDef>) {
135
131
  const expandedDomains = expandDomains(domains);
136
132
 
137
- // Validate all domains
138
133
  const validationErrors = Object.entries(expandedDomains).flatMap(([name, domain]) =>
139
134
  validateDomain(domain).map((err) => `[${name}] ${err}`),
140
135
  );
@@ -148,11 +143,9 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
148
143
  const client = config.neo4j;
149
144
  const targets = Object.entries(expandedDomains);
150
145
 
151
- // Generate Cypher for all target domains
152
- const allCypher: [string, GeneratedCypher][] = targets.map(([name, data]) => [
153
- name,
154
- generateCypher(data, config.schema),
155
- ]);
146
+ const allCypher = targets.map(
147
+ ([name, data]) => [name, generateCypher(data, config.schema)] as const,
148
+ );
156
149
 
157
150
  // Stamped before anything else is written, so an interrupted seed still
158
151
  // leaves the graph labelled with the schema it was written against.
@@ -169,10 +162,9 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
169
162
  const allEdges = allCypher.flatMap(([domain, c]) => tag(domain, c.edges));
170
163
  const edges = await runPhase(client, allEdges);
171
164
 
172
- // Embeddings for every concept.
173
165
  const embeddings = await generateEmbeddings(client, config.embedding, config.schema);
174
166
 
175
- const domainResults: Record<string, DomainSeedResult> = Object.fromEntries(
167
+ const domainResults = Object.fromEntries(
176
168
  targets.map(([name]) => {
177
169
  const failures = [
178
170
  ...(nodes.failures.get(name) ?? []),
@@ -185,7 +177,7 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
185
177
  edges: edges.succeeded.get(name) ?? 0,
186
178
  failed: failures.length,
187
179
  failures,
188
- },
180
+ } satisfies DomainSeedResult,
189
181
  ];
190
182
  }),
191
183
  );
@@ -27,7 +27,7 @@ export type Database = "postgres" | "mssql";
27
27
  // sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
28
28
  // them to that.
29
29
  export const DEPENDENCIES = {
30
- "@m6d/cortex-server": "~0.2.0",
30
+ "@m6d/cortex-server": "~0.4.0",
31
31
  "@tanstack/ai": "^0.55.0",
32
32
  "@tanstack/ai-openai": "^0.22.8",
33
33
  hono: "^4.12.3",
@@ -50,10 +50,10 @@ const DEV_DEPENDENCIES = {
50
50
 
51
51
  // Postgres is the one we run, so it gets a scaffolded port. MSSQL is normally an
52
52
  // existing instance nobody moved, which is why it keeps 1433 and has no service.
53
- const CONNECTION_STRING: Record<Database, string> = {
53
+ const CONNECTION_STRING = {
54
54
  postgres: "postgres://postgres:postgres@localhost:{{port.postgres}}/cortex",
55
55
  mssql: "Server=localhost,1433;Database=cortex;User Id=sa;Password=your-password;Encrypt=false",
56
- };
56
+ } satisfies Record<Database, string>;
57
57
 
58
58
  function packageJson(name: string) {
59
59
  return `${JSON.stringify(
@@ -89,7 +89,7 @@ function packageJson(name: string) {
89
89
  * defaulted, and the other two are what you reach for once a real problem shows
90
90
  * up — noisy graph retrieval, a cheap-model path — not on day one.
91
91
  */
92
- const NEVER_SCAFFOLDED: [note: string, config: string][] = [
92
+ const NEVER_SCAFFOLDED = [
93
93
  [
94
94
  "A cheaper, faster model for auxiliary passes. Same shape as `model`.",
95
95
  `fastModel: {
@@ -112,7 +112,7 @@ const NEVER_SCAFFOLDED: [note: string, config: string][] = [
112
112
  recentMessagesToKeep: 6,
113
113
  },`,
114
114
  ],
115
- ];
115
+ ] satisfies [note: string, config: string][];
116
116
 
117
117
  /** Fragments are authored at zero indentation and placed by these two. */
118
118
  function indented(block: string) {
@@ -132,9 +132,7 @@ function cortexConfig(database: Database, features: Feature[]) {
132
132
  const fragment = (feature: Feature) =>
133
133
  block(FEATURES[feature].note, FEATURES[feature].config, features.includes(feature));
134
134
 
135
- const on = FEATURE_NAMES.filter((feature) => features.includes(feature))
136
- .map(fragment)
137
- .join("");
135
+ const on = features.map(fragment).join("");
138
136
  const off = FEATURE_NAMES.filter((feature) => !features.includes(feature))
139
137
  .map(fragment)
140
138
  .join("");
@@ -167,9 +165,10 @@ ${on === "" ? "" : `${on}\n`} agents: {
167
165
  assistant: defineAgent({
168
166
  systemPrompt: "You are a helpful assistant.",
169
167
  // Applications that serve datasets for grouped questions (see the
170
- // README's "Dataset hosts"): the host applies its own access rules,
171
- // the agent only picks which datasets to expose and pin.
172
- // datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", pinned: ["orders"] }],
168
+ // README's "Dataset hosts"): the host applies its own access rules
169
+ // under the run's token the list below forwards, the agent only
170
+ // picks which datasets to expose and pin.
171
+ // datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", credentials: [{ header: "X-End-User-Token", credential: { kind: "run-token" } }], pinned: ["orders"] }],
173
172
  }),
174
173
  },
175
174
 
@@ -191,9 +190,7 @@ function hasServices(database: Database, features: Feature[]) {
191
190
  }
192
191
 
193
192
  function env(database: Database, features: Feature[]) {
194
- const blocks = FEATURE_NAMES.filter((feature) => features.includes(feature)).map(
195
- (feature) => `\n${FEATURES[feature].env}\n`,
196
- );
193
+ const blocks = features.map((feature) => `\n${FEATURES[feature].env}\n`);
197
194
 
198
195
  // Only the URLs pointing at scaffolded services carry a chosen port, and those
199
196
  // are exactly the ones docker-compose.yml publishes.
@@ -247,7 +244,7 @@ function dockerCompose(name: string, database: Database, features: Feature[]) {
247
244
  volumes.push("postgres-data");
248
245
  }
249
246
 
250
- for (const feature of FEATURE_NAMES.filter((feature) => features.includes(feature))) {
247
+ for (const feature of features) {
251
248
  const { compose, volume } = FEATURES[feature];
252
249
  if (compose === undefined) continue;
253
250
  services.push(compose);
@@ -278,12 +275,6 @@ ${services.join("\n\n")}
278
275
  ${volumeBlock}`;
279
276
  }
280
277
 
281
- /**
282
- * `--knowledge` scaffolds the directory `domainsDir` defaults to, which is what
283
- * makes the convention true by construction: `cortex swagger sync` finds it with
284
- * no config key.
285
- */
286
-
287
278
  function cspellConfig() {
288
279
  return `${JSON.stringify(
289
280
  {
@@ -336,12 +327,10 @@ function cspellConfig() {
336
327
  }
337
328
 
338
329
  function readme(name: string, database: Database, features: Feature[], compose: boolean) {
339
- const enabled = FEATURE_NAMES.filter((feature) => features.includes(feature));
340
-
341
330
  return `# ${name}
342
331
 
343
332
  A Cortex server: one agent, a ${database} database, and an OpenAI-compatible model.${
344
- enabled.length === 0 ? "" : ` Also on: ${enabled.join(", ")}.`
333
+ features.length === 0 ? "" : ` Also on: ${features.join(", ")}.`
345
334
  }
346
335
 
347
336
  ## Getting started
@@ -381,12 +370,16 @@ pre-commit hook or CI job should run. Each is also available on its own: \`forma
381
370
  `;
382
371
  }
383
372
 
384
- /** Relative path → contents. Nothing here touches the filesystem. */
385
- export function scaffoldFiles(name: string, database: Database, features: Feature[], ports: Ports) {
373
+ /**
374
+ * Relative path → contents. Nothing here touches the filesystem. Every
375
+ * generator lists features in catalogue order, whatever order they came in.
376
+ */
377
+ export function scaffoldFiles(name: string, database: Database, selected: Feature[], ports: Ports) {
378
+ const features = FEATURE_NAMES.filter((feature) => selected.includes(feature));
386
379
  const envFile = env(database, features);
387
380
  const compose = dockerCompose(name, database, features);
388
381
 
389
- const files: Record<string, string> = {
382
+ const files = {
390
383
  "cortex.config.ts": cortexConfig(database, features),
391
384
  "index.ts": indexTs,
392
385
  "package.json": packageJson(name),
@@ -400,8 +393,10 @@ export function scaffoldFiles(name: string, database: Database, features: Featur
400
393
  "cspell.json": cspellConfig(),
401
394
  ".fallowrc.jsonc": fallowrc,
402
395
  ...(compose === undefined ? {} : { "docker-compose.yml": compose }),
396
+ // The directory `domainsDir` defaults to, which makes the convention true
397
+ // by construction: `cortex swagger sync` finds it with no config key.
403
398
  ...(features.includes("knowledge") ? { "src/domains/README.md": domainsReadme } : {}),
404
- };
399
+ } satisfies Record<string, string>;
405
400
 
406
401
  // One pass at the end, over everything: no generator holds a port number of
407
402
  // its own, so compose, .env and the README cannot disagree about one.
@@ -1,31 +1,18 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { Endpoint, HttpMethod, Prop } from "./types";
4
- import {
5
- dedupe,
6
- deref,
7
- isObj,
8
- obj,
9
- pickContent,
10
- schemaType,
11
- toProp,
12
- toProps,
13
- } from "./openapi-schema";
3
+ import { isRecord } from "@cortex/contracts/json";
4
+ import type { Endpoint, HttpMethod, Obj } from "./types";
5
+ import { dedupe, deref, obj, pickContent, schemaType, toProp, toProps } from "./openapi-schema";
14
6
  import { parseResponse } from "./parse-response";
15
7
  import { withGeneratedBlock } from "./generated-block";
16
8
 
17
9
  type ExtractEndpointsOptions = {
18
10
  swaggerUrls: string[];
19
11
  domainsDir: string;
20
- write?: boolean;
12
+ write: boolean;
21
13
  };
22
14
 
23
- const METHODS: Array<[string, HttpMethod]> = [
24
- ["get", "GET"],
25
- ["post", "POST"],
26
- ["put", "PUT"],
27
- ["delete", "DELETE"],
28
- ];
15
+ const METHODS = ["GET", "POST", "PUT", "DELETE"] as const satisfies readonly HttpMethod[];
29
16
 
30
17
  /**
31
18
  * ASP.NET conventions the extractor normalizes away so an `.endpoint.ts` path
@@ -44,78 +31,89 @@ function toCamelCase(s: string) {
44
31
  .join(".");
45
32
  }
46
33
 
34
+ /** `Root` for a dotted parameter like `Root.Options`; undefined for a plain one. */
35
+ function dottedRoot(name: string) {
36
+ const dot = name.indexOf(".");
37
+ return dot < 0 ? undefined : name.slice(0, dot);
38
+ }
39
+
47
40
  function normalizePath(raw: string) {
48
41
  let p = raw.startsWith("/") ? raw : `/${raw}`;
49
42
  p = p.replace(/\{([^}:?]+)(?::[^}]+)?\??\}/g, "{$1}").replace(API_PREFIX, "") || "/";
50
43
  return p.length > 1 ? p.replace(/\/+$/, "") : p;
51
44
  }
52
45
 
53
- function walkFiles(dir: string, accept: (filePath: string) => boolean) {
46
+ function walkFiles(dir: string) {
54
47
  if (!fs.existsSync(dir)) return [];
55
48
  const out: string[] = [];
56
49
  function walk(d: string) {
57
50
  for (const e of fs.readdirSync(d, { withFileTypes: true })) {
58
51
  const p = path.join(d, e.name);
59
52
  if (e.isDirectory()) walk(p);
60
- else if (accept(p)) out.push(p);
53
+ else out.push(p);
61
54
  }
62
55
  }
63
56
  walk(dir);
64
57
  return out;
65
58
  }
66
59
 
60
+ /**
61
+ * The `method` and `path` an endpoint file declares, read the way the server's
62
+ * own file walk reads them.
63
+ */
64
+ export function declaredRoute(source: string) {
65
+ return {
66
+ method: source.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2],
67
+ path: source.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2],
68
+ };
69
+ }
70
+
67
71
  function endpointFiles(root: string) {
68
72
  const out = new Map<string, string>();
69
- for (const filePath of walkFiles(root, (p) => p.endsWith(".endpoint.ts"))) {
70
- const c = fs.readFileSync(filePath, "utf8");
71
- const method = c.match(/^\s*method:\s*(['"])([^'"]+)\1/m)?.[2];
72
- const route = c.match(/^\s*path:\s*(['"])([^'"]+)\1/m)?.[2];
73
+ for (const filePath of walkFiles(root).filter((p) => p.endsWith(".endpoint.ts"))) {
74
+ const { method, path: route } = declaredRoute(fs.readFileSync(filePath, "utf8"));
73
75
  if (method && route) out.set(`${method}:${normalizePath(route)}`, filePath);
74
76
  }
75
77
  return out;
76
78
  }
77
79
 
78
- function extractEndpoint(
79
- doc: unknown,
80
- route: string,
81
- method: HttpMethod,
82
- pathItemIn: unknown,
83
- operationIn: unknown,
84
- ) {
85
- const pathItem = obj(pathItemIn);
86
- const operation = obj(operationIn);
87
-
80
+ /**
81
+ * Path and query parameters, path-level first, with each `JsonNode` parameter
82
+ * collapsed back to one object.
83
+ */
84
+ function extractParams(doc: unknown, pathItem: Obj, operation: Obj) {
88
85
  const rawParams = [
89
86
  ...(Array.isArray(pathItem.parameters) ? (pathItem.parameters as unknown[]) : []),
90
87
  ...(Array.isArray(operation.parameters) ? (operation.parameters as unknown[]) : []),
91
88
  ]
92
- .map((x) => deref(doc, x))
93
- .filter((x) => typeof x.in === "string" && ["path", "query"].includes(x.in))
94
- .filter((x) => typeof x.name === "string");
89
+ .map((param) => deref(doc, param))
90
+ .filter(
91
+ (param): param is Obj & { name: string; in: string } =>
92
+ typeof param.name === "string" &&
93
+ typeof param.in === "string" &&
94
+ ["path", "query"].includes(param.in),
95
+ );
95
96
 
96
97
  const jsonNodeRoots = new Set<string>();
97
- for (const x of rawParams) {
98
- const name = String(x.name);
99
- if (!name.includes(".")) continue;
100
- const root = name.split(".")[0]!;
101
- const rest = name.slice(root.length + 1);
102
- if (JSON_NODE_MEMBER.test(rest)) jsonNodeRoots.add(root);
98
+ for (const { name } of rawParams) {
99
+ const root = dottedRoot(name);
100
+ if (root !== undefined && JSON_NODE_MEMBER.test(name.slice(root.length + 1))) {
101
+ jsonNodeRoots.add(root);
102
+ }
103
103
  }
104
104
 
105
- const params = dedupe(
105
+ return dedupe(
106
106
  rawParams
107
- .filter((x) => {
108
- const name = String(x.name);
109
- if (!name.includes(".")) return true;
110
- const root = name.split(".")[0]!;
111
- return !jsonNodeRoots.has(root);
107
+ .filter((param) => {
108
+ const root = dottedRoot(param.name);
109
+ return root === undefined || !jsonNodeRoots.has(root);
112
110
  })
113
- .map((x) =>
111
+ .map((param) =>
114
112
  toProp(
115
113
  doc,
116
- toCamelCase(String(x.name)),
117
- x.schema,
118
- String(x.in) === "path" || x.required === true,
114
+ toCamelCase(param.name),
115
+ param.schema,
116
+ param.in === "path" || param.required === true,
119
117
  ),
120
118
  )
121
119
  .concat(
@@ -126,41 +124,46 @@ function extractEndpoint(
126
124
  })),
127
125
  ),
128
126
  );
127
+ }
129
128
 
130
- const body: Prop[] = [];
129
+ /** An object body contributes its own fields; anything else is one `items` or `body` field. */
130
+ function extractBody(doc: unknown, operation: Obj) {
131
131
  const requestBody = deref(doc, operation.requestBody);
132
- const requestContent = pickContent(requestBody.content);
133
- if (requestContent) {
134
- const s = deref(doc, requestContent.schema);
135
- const t = schemaType(s);
136
- if (t === "object") body.push(...toProps(doc, s));
137
- else if (t === "array") body.push(toProp(doc, "items", s, requestBody.required === true));
138
- else body.push(toProp(doc, "body", s, requestBody.required === true));
139
- }
132
+ const content = pickContent(requestBody.content);
133
+ if (!content) return [];
134
+ const schema = deref(doc, content.schema);
135
+ const type = schemaType(schema);
136
+ if (type === "object") return toProps(doc, schema);
137
+ const name = type === "array" ? "items" : "body";
138
+ return [toProp(doc, name, schema, requestBody.required === true)];
139
+ }
140
140
 
141
- const parsed = parseResponse(doc, operation.responses);
141
+ function extractEndpoint(
142
+ doc: unknown,
143
+ route: string,
144
+ method: HttpMethod,
145
+ pathItem: Obj,
146
+ operation: Obj,
147
+ ) {
142
148
  const normalizedPath = normalizePath(route);
143
-
144
149
  return {
145
150
  key: `${method}:${normalizedPath}`,
146
151
  method,
147
152
  path: normalizedPath,
148
- params,
149
- body: dedupe(body),
150
- response: dedupe(parsed.response),
151
- responseKind: parsed.responseKind,
152
- successStatus: parsed.successStatus,
153
- errorStatuses: parsed.errorStatuses,
153
+ params: extractParams(doc, pathItem, operation),
154
+ body: extractBody(doc, operation),
155
+ ...parseResponse(doc, operation.responses),
154
156
  } satisfies Endpoint;
155
157
  }
156
158
 
157
159
  function parseSwaggerEndpoints(swagger: unknown) {
158
160
  const out = new Map<string, Endpoint>();
159
161
  const doc = obj(swagger);
160
- for (const [route, pathItem] of Object.entries(obj(doc.paths))) {
161
- for (const [openApiMethod, method] of METHODS) {
162
- const operation = obj(pathItem)[openApiMethod];
163
- if (!isObj(operation)) continue;
162
+ for (const [route, pathItemIn] of Object.entries(obj(doc.paths))) {
163
+ const pathItem = obj(pathItemIn);
164
+ for (const method of METHODS) {
165
+ const operation = pathItem[method.toLowerCase()];
166
+ if (!isRecord(operation)) continue;
164
167
  const endpoint = extractEndpoint(doc, route, method, pathItem, operation);
165
168
  out.set(endpoint.key, endpoint);
166
169
  }
@@ -217,11 +220,11 @@ function sameModuloFormatting(a: string, b: string) {
217
220
  }
218
221
 
219
222
  export async function extractEndpoints(options: ExtractEndpointsOptions) {
220
- const { swaggerUrls, domainsDir, write = false } = options;
223
+ const { swaggerUrls, domainsDir, write } = options;
221
224
 
222
225
  // Merge every document first: a file missing from one swagger doc may be
223
226
  // matched by another. Last write wins on duplicate keys, so per-agent docs
224
- // deliberately override the server-level one (collectSwaggerUrls order).
227
+ // deliberately override the server-level one (`swaggerUrls` lists it first).
225
228
  const extracted = new Map<string, Endpoint>();
226
229
  for (const swaggerUrl of swaggerUrls) {
227
230
  for (const [key, endpoint] of parseSwaggerEndpoints(await fetchJson(swaggerUrl))) {
@@ -3,25 +3,27 @@
3
3
  * a schema into the flat property list the generator emits. Nothing here knows what
4
4
  * the properties are for.
5
5
  */
6
+ import { isRecord } from "@cortex/contracts/json";
6
7
  import type { Obj, Prop } from "./types";
7
8
 
8
9
  const MAX_DEPTH = 8;
9
10
 
10
- export function isObj(v: unknown): v is Obj {
11
- return typeof v === "object" && v !== null && !Array.isArray(v);
12
- }
13
-
14
11
  export function obj(v: unknown) {
15
- return isObj(v) ? v : {};
12
+ return isRecord(v) ? v : {};
16
13
  }
17
14
 
18
15
  function strings(v: unknown) {
19
16
  return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
20
17
  }
21
18
 
19
+ /** The first prop of each name. */
22
20
  export function dedupe(items: Prop[]) {
23
21
  const seen = new Set<string>();
24
- return items.filter((x) => (seen.has(x.name) ? false : (seen.add(x.name), true)));
22
+ return items.filter((prop) => {
23
+ if (seen.has(prop.name)) return false;
24
+ seen.add(prop.name);
25
+ return true;
26
+ });
25
27
  }
26
28
 
27
29
  function resolvePointer(doc: unknown, ref: string) {
@@ -29,14 +31,14 @@ function resolvePointer(doc: unknown, ref: string) {
29
31
  let cur: unknown = doc;
30
32
  for (const part of ref.slice(2).split("/")) {
31
33
  const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
32
- if (!isObj(cur) || !(key in cur)) return null;
34
+ if (!isRecord(cur) || !(key in cur)) return null;
33
35
  cur = cur[key];
34
36
  }
35
37
  return cur;
36
38
  }
37
39
 
38
40
  export function deref(doc: unknown, v: unknown) {
39
- if (!isObj(v)) return {};
41
+ if (!isRecord(v)) return {};
40
42
  let x = v;
41
43
  const seen = new Set<string>();
42
44
  while (typeof x.$ref === "string") {
@@ -52,14 +54,14 @@ export function schemaType(s: Obj) {
52
54
  ? (s.type as unknown[]).find((x) => typeof x === "string" && x !== "null")
53
55
  : s.type;
54
56
  if (typeof t === "string") return t;
55
- if (isObj(s.properties)) return "object";
56
- if (isObj(s.items)) return "array";
57
+ if (isRecord(s.properties)) return "object";
58
+ if (isRecord(s.items)) return "array";
57
59
  return "";
58
60
  }
59
61
 
60
62
  function isJsonNodeRef(v: unknown) {
61
63
  return (
62
- isObj(v) &&
64
+ isRecord(v) &&
63
65
  typeof v.$ref === "string" &&
64
66
  /\/System\.Text\.Json\.Nodes\.JsonNode$/.test(v.$ref)
65
67
  );
@@ -149,13 +151,18 @@ export function toProp(
149
151
  return { name, required: req, type: scalarType(s) };
150
152
  }
151
153
 
154
+ /**
155
+ * The schema under `application/json`, else any JSON type, else the first
156
+ * type, along with every type offered.
157
+ */
152
158
  export function pickContent(contentIn: unknown) {
153
159
  const content = obj(contentIn);
154
160
  const mimes = Object.keys(content);
155
- if (!mimes.length) return null;
161
+ const [first] = mimes;
162
+ if (first === undefined) return null;
156
163
  const mime =
157
164
  mimes.find((x) => x.toLowerCase() === "application/json") ??
158
165
  mimes.find((x) => x.toLowerCase().includes("json")) ??
159
- mimes[0];
160
- return { schema: obj(content[mime!]).schema, mimes };
166
+ first;
167
+ return { schema: obj(content[mime]).schema, mimes };
161
168
  }