@m6d/cortex-cli 0.17.0 → 0.19.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.
@@ -1243,22 +1243,163 @@ const manifestWebhookSchema = z.strictObject({
1243
1243
  });
1244
1244
  export type ManifestWebhook = z.input<typeof manifestWebhookSchema>;
1245
1245
 
1246
- /** A collection's name and description in characters. The console caps its editor at the same. */
1247
- export const COLLECTION_LIMITS = { nameLength: 80, descriptionLength: 2_000 } as const;
1248
-
1249
- /** Holds documents, which are uploaded to it apart from the manifest. */
1250
- const manifestCollectionSchema = z.strictObject({
1251
- name: z.string().trim().min(1).max(COLLECTION_LIMITS.nameLength),
1252
- description: z.string().trim().max(COLLECTION_LIMITS.descriptionLength).default(""),
1253
- /** The key of the service every document in the collection belongs to. */
1254
- service: slugSchema.optional(),
1255
- /** Whether answers may name its documents; false keeps their names, sections and pages from the model. */
1256
- citable: z.boolean().default(true),
1257
- audience: audienceSchema,
1258
- overridable: overridable(["description"]),
1246
+ /**
1247
+ * A collection's name and description in characters, and how many documents
1248
+ * a manifest lists in one. The console caps its editor at the same.
1249
+ */
1250
+ export const COLLECTION_LIMITS = {
1251
+ nameLength: 80,
1252
+ descriptionLength: 2_000,
1253
+ documents: 100,
1254
+ } as const;
1255
+
1256
+ /** A knowledge document's name in characters and its size in bytes, however it arrives. */
1257
+ export const DOCUMENT_LIMITS = { nameLength: 255, bytes: 10 * 1024 * 1024 } as const;
1258
+
1259
+ export const DOCX_MIME_TYPE =
1260
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
1261
+
1262
+ /** The content types Cortex parses a knowledge document as. */
1263
+ export const DOCUMENT_MIME_TYPES = [
1264
+ "application/pdf",
1265
+ DOCX_MIME_TYPE,
1266
+ "text/markdown",
1267
+ "text/plain",
1268
+ "text/html",
1269
+ ] as const;
1270
+ export type DocumentMimeType = (typeof DOCUMENT_MIME_TYPES)[number];
1271
+
1272
+ const mimeTypeByExtension = new Map<string, DocumentMimeType>([
1273
+ ["md", "text/markdown"],
1274
+ ["txt", "text/plain"],
1275
+ ["html", "text/html"],
1276
+ ["htm", "text/html"],
1277
+ ["pdf", "application/pdf"],
1278
+ ["docx", DOCX_MIME_TYPE],
1279
+ ]);
1280
+
1281
+ /** The content type a document is parsed as, from the extension its name ends in; none for any other name. */
1282
+ export function documentType(name: string) {
1283
+ const dot = name.lastIndexOf(".");
1284
+ return dot === -1 ? undefined : mimeTypeByExtension.get(name.slice(dot + 1).toLowerCase());
1285
+ }
1286
+
1287
+ /** How many bytes a base64 string decodes to. */
1288
+ function base64Bytes(data: string) {
1289
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
1290
+ return (data.length / 4) * 3 - padding;
1291
+ }
1292
+
1293
+ const documentNameSchema = z.string().trim().min(1).max(DOCUMENT_LIMITS.nameLength);
1294
+
1295
+ /** A document written in the manifest itself; its name's extension says how it is parsed. */
1296
+ const textDocumentSchema = z.strictObject({
1297
+ name: documentNameSchema,
1298
+ text: z
1299
+ .string()
1300
+ .min(1)
1301
+ .refine(
1302
+ (text) => new TextEncoder().encode(text).byteLength <= DOCUMENT_LIMITS.bytes,
1303
+ `larger than ${DOCUMENT_LIMITS.bytes} bytes`,
1304
+ ),
1305
+ });
1306
+
1307
+ /** A file `cortex apply` reads, its path relative to the manifest file; named after the file unless `name` says otherwise. */
1308
+ const pathDocumentSchema = z.strictObject({
1309
+ path: z.string().trim().min(1),
1310
+ name: documentNameSchema.optional(),
1311
+ });
1312
+
1313
+ /** A file Cortex reads on every apply, with no credentials; named after the URL's last segment unless `name` says otherwise. */
1314
+ const urlDocumentSchema = z.strictObject({
1315
+ url: upstreamUrlSchema,
1316
+ name: documentNameSchema.optional(),
1259
1317
  });
1318
+
1319
+ /** A file's bytes in base64, as `cortex apply` sends a `path`. */
1320
+ const dataDocumentSchema = z.strictObject({
1321
+ name: documentNameSchema,
1322
+ data: z
1323
+ .base64()
1324
+ .refine(
1325
+ (data) => base64Bytes(data) <= DOCUMENT_LIMITS.bytes,
1326
+ `larger than ${DOCUMENT_LIMITS.bytes} bytes`,
1327
+ ),
1328
+ });
1329
+
1330
+ /** The name a document goes by: its own, else its file's or its URL's last segment. */
1331
+ export function documentName(document: { name?: string; path?: string; url?: string }) {
1332
+ if (document.name !== undefined) return document.name;
1333
+ if (document.path !== undefined) return document.path.split(/[/\\]/).pop() ?? "";
1334
+ const segment = new URL(document.url ?? "", "https://x").pathname.split("/").pop() ?? "";
1335
+ try {
1336
+ return decodeURIComponent(segment);
1337
+ } catch {
1338
+ return segment;
1339
+ }
1340
+ }
1341
+
1342
+ /** A collection's documents: each name once, and each a name whose extension Cortex parses. */
1343
+ function documentsSchema<TDocument extends z.ZodType<Parameters<typeof documentName>[0]>>(
1344
+ document: TDocument,
1345
+ ) {
1346
+ return z
1347
+ .array(document)
1348
+ .max(COLLECTION_LIMITS.documents)
1349
+ .superRefine((documents, ctx) => {
1350
+ const names = documents.map(documentName);
1351
+ names.forEach((name, index) => {
1352
+ if (documentType(name) === undefined) {
1353
+ ctx.addIssue({
1354
+ code: "custom",
1355
+ path: [index, "name"],
1356
+ message: "a document's name ends in .md, .txt, .html, .htm, .pdf or .docx",
1357
+ });
1358
+ } else if (names.indexOf(name) !== index) {
1359
+ ctx.addIssue({
1360
+ code: "custom",
1361
+ path: [index, "name"],
1362
+ message: "a name repeats",
1363
+ });
1364
+ }
1365
+ });
1366
+ })
1367
+ .default([]);
1368
+ }
1369
+
1370
+ /**
1371
+ * Holds documents. Its `documents` are the manifest's: an apply stores what
1372
+ * is new or changed and retires what it no longer lists, unless
1373
+ * `overridable` offers them, and then a document added or removed in
1374
+ * Cortex stays so until the manifest changes that document too.
1375
+ */
1376
+ function collectionSchema<TDocument extends z.ZodType<Parameters<typeof documentName>[0]>>(
1377
+ document: TDocument,
1378
+ ) {
1379
+ return z.strictObject({
1380
+ name: z.string().trim().min(1).max(COLLECTION_LIMITS.nameLength),
1381
+ description: z.string().trim().max(COLLECTION_LIMITS.descriptionLength).default(""),
1382
+ /** The key of the service every document in the collection belongs to. */
1383
+ service: slugSchema.optional(),
1384
+ /** Whether answers may name its documents; false keeps their names, sections and pages from the model. */
1385
+ citable: z.boolean().default(true),
1386
+ documents: documentsSchema(document),
1387
+ audience: audienceSchema,
1388
+ overridable: overridable(["description", "documents"]),
1389
+ });
1390
+ }
1391
+
1392
+ const manifestDocumentSchema = z.union([textDocumentSchema, pathDocumentSchema, urlDocumentSchema]);
1393
+ export type ManifestDocument = z.input<typeof manifestDocumentSchema>;
1394
+
1395
+ const manifestCollectionSchema = collectionSchema(manifestDocumentSchema);
1260
1396
  export type ManifestCollection = z.input<typeof manifestCollectionSchema>;
1261
1397
 
1398
+ /** A collection as Cortex receives it: each `path` read into `data`. */
1399
+ const resolvedCollectionSchema = collectionSchema(
1400
+ z.union([textDocumentSchema, dataDocumentSchema, urlDocumentSchema]),
1401
+ );
1402
+
1262
1403
  export const AUTH_JWT_SECRET_ALGORITHMS = ["HS256", "HS384", "HS512"] as const;
1263
1404
 
1264
1405
  const issuerSchema = z.string().trim().min(1).max(2048);
@@ -1335,22 +1476,6 @@ export const agentSettingsSchema = z.strictObject({
1335
1476
  /** Off, end users cannot message the agent: only its triggers reach it. */
1336
1477
  messageTrigger: z.boolean().default(true),
1337
1478
  defaultLocale: localeSchema.default(BASE_LOCALE),
1338
- rateLimits: z
1339
- .strictObject({
1340
- allUsers: z
1341
- .strictObject({
1342
- messagesPerMinute: rateLimit(agentRateLimits.messagesPerMinute, "allUsers"),
1343
- tokensPerDay: rateLimit(agentRateLimits.tokensPerDay, "allUsers"),
1344
- })
1345
- .prefault({}),
1346
- perUser: z
1347
- .strictObject({
1348
- messagesPerMinute: rateLimit(agentRateLimits.messagesPerMinute, "perUser"),
1349
- tokensPerDay: rateLimit(agentRateLimits.tokensPerDay, "perUser"),
1350
- })
1351
- .prefault({}),
1352
- })
1353
- .prefault({}),
1354
1479
  context: z
1355
1480
  .strictObject({
1356
1481
  maxContextTokens: setting(agentContext.maxContextTokens),
@@ -1395,9 +1520,34 @@ const manifestAgentSchema = z.strictObject({
1395
1520
  })
1396
1521
  .default({}),
1397
1522
  settings: agentSettingsSchema.prefault({}),
1523
+ /** Messages a minute and model tokens a UTC day, for all users together and for each; null, no limit. */
1524
+ rateLimits: z
1525
+ .strictObject({
1526
+ allUsers: z
1527
+ .strictObject({
1528
+ messagesPerMinute: rateLimit(agentRateLimits.messagesPerMinute, "allUsers"),
1529
+ tokensPerDay: rateLimit(agentRateLimits.tokensPerDay, "allUsers"),
1530
+ })
1531
+ .prefault({}),
1532
+ perUser: z
1533
+ .strictObject({
1534
+ messagesPerMinute: rateLimit(agentRateLimits.messagesPerMinute, "perUser"),
1535
+ tokensPerDay: rateLimit(agentRateLimits.tokensPerDay, "perUser"),
1536
+ })
1537
+ .prefault({}),
1538
+ })
1539
+ .prefault({}),
1398
1540
  suggestions: suggestionsSchema.default(() => byLocale(() => [])),
1399
1541
  greeting: greetingSchema.default(() => byLocale(() => "")),
1400
- overridable: overridable(["name", "prompt", "models", "settings", "suggestions", "greeting"]),
1542
+ overridable: overridable([
1543
+ "name",
1544
+ "prompt",
1545
+ "models",
1546
+ "settings",
1547
+ "rateLimits",
1548
+ "suggestions",
1549
+ "greeting",
1550
+ ]),
1401
1551
  });
1402
1552
  export type ManifestAgent = z.input<typeof manifestAgentSchema>;
1403
1553
 
@@ -1477,6 +1627,15 @@ export const manifestSchema = z.strictObject({
1477
1627
  /** A manifest as written: every field with a default may be left out. */
1478
1628
  export type Manifest = z.input<typeof manifestSchema>;
1479
1629
 
1630
+ /**
1631
+ * A manifest as Cortex receives it: the one written, each document `path`
1632
+ * read into `data` by `cortex apply`, since Cortex cannot read the files
1633
+ * beside it.
1634
+ */
1635
+ export const resolvedManifestSchema = manifestSchema.extend({
1636
+ collections: listOf(MANIFEST_NAME_FIELDS.collections, resolvedCollectionSchema),
1637
+ });
1638
+
1480
1639
  const manifestKindSchema = z.enum(MANIFEST_KINDS);
1481
1640
 
1482
1641
  /**
@@ -1492,11 +1651,17 @@ export const manifestChangesSchema = z.object({
1492
1651
  delete: namesByKindSchema,
1493
1652
  });
1494
1653
 
1495
- /** A field of one entry that both the manifest and a live write changed since the last apply. */
1654
+ /**
1655
+ * A field of one entry that both the manifest and a live write changed since
1656
+ * the last apply. A field holding named parts (an agent's settings, its
1657
+ * greeting and suggestions by locale, a collection's documents by name) is
1658
+ * judged part by part, and `key` names the part.
1659
+ */
1496
1660
  const manifestConflictSchema = z.strictObject({
1497
1661
  kind: manifestKindSchema,
1498
1662
  name: z.string(),
1499
1663
  field: z.string(),
1664
+ key: z.string().optional(),
1500
1665
  });
1501
1666
 
1502
1667
  /** Which value a conflict keeps: the one changed live in Cortex, or the manifest's. */
@@ -1506,9 +1671,10 @@ const conflictSideSchema = z.enum(["live", "manifest"]);
1506
1671
  * What preview and sync take: the manifest, and how its conflicts resolve.
1507
1672
  * A conflict takes its own entry in `resolutions`, else `prefer`; a sync
1508
1673
  * left with one neither answers is refused as `unresolved_conflicts`,
1509
- * naming them. A resolution for a field that no longer conflicts is ignored.
1674
+ * naming them. A resolution answers the conflict with its kind, name, field
1675
+ * and key; one for a field that no longer conflicts is ignored.
1510
1676
  */
1511
- export const manifestApplyInputSchema = manifestSchema.extend({
1677
+ export const manifestApplyInputSchema = resolvedManifestSchema.extend({
1512
1678
  prefer: conflictSideSchema.optional(),
1513
1679
  resolutions: z.array(manifestConflictSchema.extend({ keep: conflictSideSchema })).default([]),
1514
1680
  });
@@ -1,16 +1,68 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
1
3
  import { pathToFileURL } from "node:url";
2
4
 
3
- import { manifestSchema } from "../manifest/index";
5
+ import { isRecord } from "../json/index";
6
+ import { DOCUMENT_LIMITS, documentName, manifestSchema } from "../manifest/index";
4
7
 
5
8
  /**
6
9
  * Imports a manifest file, TypeScript or JSON (Bun imports both), and checks
7
- * its default export against the contract: `written` is the export as
8
- * written, for a caller that lets Cortex fill its own defaults, and
9
- * `parsed` the check. Throws when the file does not import or exports
10
- * nothing. Kept out of the contract's module, which the browser imports too.
10
+ * its default export against the contract: `parsed` is the check, and
11
+ * `written` the export as written, for a caller that lets Cortex fill its
12
+ * own defaults, with each document `path` read, relative to the file, into
13
+ * the `data` Cortex takes (`resolvedManifestSchema`). Throws when the file
14
+ * does not import, exports nothing, or names a document file it cannot
15
+ * read, that is empty, or that is too large. Kept out of the contract's
16
+ * module, which the browser imports too.
11
17
  */
12
18
  export async function loadManifestFile(path: string) {
13
19
  const module = (await import(pathToFileURL(path).href)) as { default?: unknown };
14
20
  if (module.default === undefined) throw new Error("The module has no default export.");
15
- return { written: module.default, parsed: manifestSchema.safeParse(module.default) };
21
+ const parsed = manifestSchema.safeParse(module.default);
22
+ return {
23
+ written: parsed.success
24
+ ? await readDocuments(module.default, dirname(path))
25
+ : module.default,
26
+ parsed,
27
+ };
28
+ }
29
+
30
+ /** `written` with every document `path` read from `directory` into `data`. */
31
+ async function readDocuments(written: unknown, directory: string) {
32
+ if (!isRecord(written) || !Array.isArray(written.collections)) return written;
33
+ const collections = await Promise.all(
34
+ written.collections.map(async (collection: unknown) => {
35
+ if (!isRecord(collection) || !Array.isArray(collection.documents)) return collection;
36
+ const documents = await Promise.all(
37
+ collection.documents.map((document: unknown) =>
38
+ isRecord(document) && typeof document.path === "string"
39
+ ? readDocument(
40
+ String(collection.name),
41
+ document.path,
42
+ document.name,
43
+ directory,
44
+ )
45
+ : document,
46
+ ),
47
+ );
48
+ return { ...collection, documents };
49
+ }),
50
+ );
51
+ return { ...written, collections };
52
+ }
53
+
54
+ /** One document file as `{ name, data }`, named after the file unless `name` says otherwise. */
55
+ async function readDocument(collection: string, path: string, name: unknown, directory: string) {
56
+ const where = `Collection ${collection}: ${path}`;
57
+ const bytes = await readFile(resolve(directory, path)).catch((cause: unknown) => {
58
+ throw new Error(`${where} could not be read.`, { cause });
59
+ });
60
+ if (bytes.byteLength === 0) throw new Error(`${where} is empty.`);
61
+ if (bytes.byteLength > DOCUMENT_LIMITS.bytes) {
62
+ throw new Error(`${where} is larger than ${DOCUMENT_LIMITS.bytes} bytes.`);
63
+ }
64
+ return {
65
+ name: documentName({ name: typeof name === "string" ? name : undefined, path }),
66
+ data: bytes.toString("base64"),
67
+ };
16
68
  }
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
- import { isRecord } from "@cortex/contracts/json";
5
+ import { createManagementClient, MANAGEMENT_BASE_PATH } from "@cortex/contracts/management";
6
+ import type { ManagementTarget } from "@cortex/contracts/management";
6
7
  import { z } from "zod";
7
8
 
8
9
  import { profilesPath } from "./profiles";
@@ -109,7 +110,7 @@ export async function loadOpenApi(url: string) {
109
110
  const path = cachePath(url);
110
111
  const cached = readCache(path);
111
112
  try {
112
- const response = await fetch(`${url}/api/management/openapi.json`, {
113
+ const response = await fetch(`${url}${MANAGEMENT_BASE_PATH}/openapi.json`, {
113
114
  headers: cached ? { "If-None-Match": cached.etag } : {},
114
115
  });
115
116
  if (response.status === 304 && cached)
@@ -159,71 +160,28 @@ export function inputSchemaOf(operation: OpenApiOperation) {
159
160
  }
160
161
 
161
162
  /**
162
- * One management call. A query's input object is spread over the query
163
- * string, one parameter per field, with a scalar written plainly and
164
- * anything else JSON-encoded, which is how the document declares it. A
165
- * mutation posts its input as the JSON body. A refusal throws
166
- * `ManagementError` with the error body's code, message, detail and sentence.
163
+ * Where `profile`'s management calls go; a Cortex that does not answer at
164
+ * all rejects as `UnreachableError`, apart from one that refused.
167
165
  */
168
- export async function callManagement(
169
- profile: { url: string; key: string },
170
- method: "get" | "post",
171
- path: string,
172
- input: unknown,
173
- ) {
174
- const base = `${profile.url}/api/management${path}`;
175
- const url = method === "get" && isRecord(input) ? `${base}${queryString(input)}` : base;
176
- const response = await fetch(url, {
177
- method: method.toUpperCase(),
178
- headers: {
179
- Authorization: `Bearer ${profile.key}`,
180
- ...(method === "post" ? { "Content-Type": "application/json" } : {}),
181
- },
182
- body: method === "post" && input !== undefined ? JSON.stringify(input) : undefined,
183
- }).catch((error: unknown) => {
184
- throw new UnreachableError(error);
185
- });
186
- const body: unknown = await response.json().catch(() => null);
187
- if (!response.ok) {
188
- const { code, message, refusal, cause } = (body ?? {}) as {
189
- code?: string;
190
- message?: string;
191
- refusal?: string;
192
- cause?: unknown;
193
- };
194
- throw new ManagementError(
195
- response.status,
196
- code ?? "error",
197
- message ?? response.statusText,
198
- isRecord(cause) ? cause : undefined,
199
- refusal,
200
- );
201
- }
202
- return body;
203
- }
204
-
205
- function isScalar(value: unknown): value is string | number | boolean | null {
206
- return value === null || ["string", "number", "boolean"].includes(typeof value);
166
+ export function managementTarget(profile: { url: string; key: string }) {
167
+ return {
168
+ ...profile,
169
+ fetch: (url: string, init: RequestInit) =>
170
+ fetch(url, init).catch((error: unknown) => {
171
+ throw new UnreachableError(error);
172
+ }),
173
+ } satisfies ManagementTarget;
207
174
  }
208
175
 
209
- /** A scalar as itself, an array of scalars as a repeated parameter, anything else as JSON, matching what the document declares. */
210
- function queryString(input: Record<string, unknown>) {
211
- const params = new URLSearchParams();
212
- for (const [name, value] of Object.entries(input)) {
213
- if (value === undefined) continue;
214
- if (isScalar(value)) params.set(name, String(value));
215
- else if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) {
216
- for (const item of value) params.append(name, String(item));
217
- } else params.set(name, JSON.stringify(value));
218
- }
219
- const encoded = params.toString();
220
- return encoded === "" ? "" : `?${encoded}`;
176
+ /** The typed client for the calls the CLI makes by name, over `managementTarget`. */
177
+ export function managementClient(profile: { url: string; key: string }) {
178
+ return createManagementClient(managementTarget(profile));
221
179
  }
222
180
 
223
181
  /**
224
182
  * Cortex did not answer at all: refused, reset, unresolvable. `fetch` rejects
225
183
  * only for these, with whatever its runtime throws (Bun's is a plain `Error`
226
- * with a `code`), so `callManagement` names them once here.
184
+ * with a `code`), so `managementTarget` names them once here.
227
185
  */
228
186
  export class UnreachableError extends Error {
229
187
  public constructor(cause: unknown) {
@@ -231,21 +189,3 @@ export class UnreachableError extends Error {
231
189
  this.name = "UnreachableError";
232
190
  }
233
191
  }
234
-
235
- /**
236
- * A refusal; `detail` is what the refusal names, when it names something (an
237
- * entry, the row holding a name), and `refusal` the sentence Cortex words it
238
- * in, when it has one.
239
- */
240
- export class ManagementError extends Error {
241
- public constructor(
242
- public readonly status: number,
243
- public readonly code: string,
244
- message: string,
245
- public readonly detail?: Record<string, unknown>,
246
- public readonly refusal?: string,
247
- ) {
248
- super(message);
249
- this.name = "ManagementError";
250
- }
251
- }
@@ -29,7 +29,7 @@ import { applyPorts, type Ports } from "@/scaffold/ports";
29
29
  * `@m6d/cortex` range below are stamped by scripts/sync-package-versions.ts
30
30
  * before the CLI publishes, so a scaffold pins what was current then.
31
31
  */
32
- const CORTEX_VERSION = "0.17.0";
32
+ const CORTEX_VERSION = "0.18.0";
33
33
 
34
34
  /** This machine as Cortex's containers name it: the one private host the stack's egress policy admits. */
35
35
  export const DOCKER_HOST = "host.docker.internal";
@@ -150,7 +150,7 @@ function packageJson({ name, datasets, setup }: Project) {
150
150
  verify: "bun run format:check && bun run lint && bun run spellcheck && bun run check && bun run audit",
151
151
  },
152
152
  dependencies: {
153
- "@m6d/cortex": "~0.5.0",
153
+ "@m6d/cortex": "~0.6.0",
154
154
  // drizzle 1.0 is in beta; this is the build `@m6d/cortex/drizzle` is tested against.
155
155
  ...(datasets ? { "drizzle-orm": "1.0.0-beta.15-859cf75" } : {}),
156
156
  hono: "^4.12.3",