@m6d/cortex-cli 0.17.0 → 0.18.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 +33 -0
- package/package.json +1 -1
- package/src/commands/apply.ts +16 -6
- package/src/contracts/datasets/dataset-sql.ts +65 -28
- package/src/contracts/manifest/index.ts +173 -16
- package/src/contracts/manifest-file/index.ts +58 -6
package/README.md
CHANGED
|
@@ -225,6 +225,39 @@ or any name such a collision decided (`ambiguous_tool`), since a path the
|
|
|
225
225
|
document adds or drops could move it to another endpoint. It also fails when an
|
|
226
226
|
operation's name is already a listed tool's or another source's.
|
|
227
227
|
|
|
228
|
+
A collection lists its documents. Each is inline `text`, a file `path`
|
|
229
|
+
relative to the manifest file, or a `url` Cortex reads on every apply, with no
|
|
230
|
+
credentials:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
collections: [
|
|
234
|
+
{
|
|
235
|
+
name: "Policies",
|
|
236
|
+
audience: { agents: ["support"] },
|
|
237
|
+
documents: [
|
|
238
|
+
{ name: "faq.md", text: "# FAQ\n\nReturns take 30 days." },
|
|
239
|
+
{ path: "./docs/returns.pdf" },
|
|
240
|
+
{ url: "https://shop.example.com/policies/shipping.html" },
|
|
241
|
+
],
|
|
242
|
+
// overridable: ["documents"],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
A document's name sets its type: `.md`, `.txt`, `.html`, `.htm`, `.pdf` or
|
|
248
|
+
`.docx`. A path or URL document is named after its file unless `name` says
|
|
249
|
+
otherwise. Each is at most 10 MB. `cortex apply` reads paths and sends their
|
|
250
|
+
bytes, so a request carries every inline and path document again; Cortex
|
|
251
|
+
stores only what changed. A document with new bytes replaces the old one,
|
|
252
|
+
which keeps answering until the new one is processed, and one the manifest
|
|
253
|
+
drops is retired.
|
|
254
|
+
|
|
255
|
+
Listing `documents` makes the manifest own them: an apply retires a document
|
|
256
|
+
uploaded in the console, the first apply after upgrading included, unless
|
|
257
|
+
the collection offers `documents` in `overridable`. Offered, a document
|
|
258
|
+
uploaded or removed in Cortex stays so until the manifest changes that same
|
|
259
|
+
document, which `cortex apply` then asks about.
|
|
260
|
+
|
|
228
261
|
The manifest is checked against the contract before anything is sent, then
|
|
229
262
|
sent as written, so Cortex fills its own defaults. It goes to the
|
|
230
263
|
management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
|
package/package.json
CHANGED
package/src/commands/apply.ts
CHANGED
|
@@ -202,9 +202,14 @@ async function sendManifest(options: {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/** The field both sides changed, and its part when the field is judged part by part. */
|
|
206
|
+
function conflictField({ field, key }: Conflict) {
|
|
207
|
+
return key === undefined ? field : `${field} (${key})`;
|
|
208
|
+
}
|
|
209
|
+
|
|
205
210
|
/** How a conflict reads: its entry and the field both sides changed. */
|
|
206
|
-
function conflictLabel(
|
|
207
|
-
return `${kind} ${name}: ${
|
|
211
|
+
function conflictLabel(conflict: Conflict) {
|
|
212
|
+
return `${conflict.kind} ${conflict.name}: ${conflictField(conflict)} changed in both places`;
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
/** Ends a run that does not ask, naming every conflict and how to run `command` again to answer them. */
|
|
@@ -250,8 +255,9 @@ export function findManifest(path: string, command?: string) {
|
|
|
250
255
|
|
|
251
256
|
/**
|
|
252
257
|
* The file's default export, checked against the manifest contract here so
|
|
253
|
-
* a mistake is named before anything is sent, then sent as written
|
|
254
|
-
*
|
|
258
|
+
* a mistake is named before anything is sent, then sent as written, each
|
|
259
|
+
* document `path` read from beside the file: Cortex fills its own defaults,
|
|
260
|
+
* which may be newer than this CLI's.
|
|
255
261
|
*/
|
|
256
262
|
export async function loadManifest(path: string, verbose: boolean) {
|
|
257
263
|
const { written, parsed } = await loadManifestFile(path).catch((error: unknown) =>
|
|
@@ -288,8 +294,12 @@ function printChanges(
|
|
|
288
294
|
rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
|
-
for (const
|
|
292
|
-
|
|
297
|
+
for (const conflict of conflicts) {
|
|
298
|
+
const { kind, name } = conflict;
|
|
299
|
+
rows.set(kind, [
|
|
300
|
+
...(rows.get(kind) ?? []),
|
|
301
|
+
`! ${name} ${conflictField(conflict)} changed in both places`,
|
|
302
|
+
]);
|
|
293
303
|
}
|
|
294
304
|
for (const [kind, lines] of rows) {
|
|
295
305
|
for (const [index, line] of lines.entries()) {
|
|
@@ -57,8 +57,13 @@ type ParsedDatasetSql = {
|
|
|
57
57
|
/** A refusal the model can act on. */
|
|
58
58
|
export class DatasetSqlError extends Error {}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* `upper` is a word's keyword spelling. A quoted identifier and a word after a
|
|
62
|
+
* dot (`plans.from`) have none (`""`), so no keyword check matches them and
|
|
63
|
+
* they always read as names.
|
|
64
|
+
*/
|
|
60
65
|
type Token =
|
|
61
|
-
| { kind: "word"; value: string; upper: string
|
|
66
|
+
| { kind: "word"; value: string; upper: string }
|
|
62
67
|
| { kind: "string"; value: string }
|
|
63
68
|
| { kind: "number"; value: number }
|
|
64
69
|
| { kind: "punct"; value: string }
|
|
@@ -84,6 +89,14 @@ function stringLiteral(sql: string, start: number) {
|
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/** Whether `previous` ends a value, so a minus after it subtracts; after a keyword or an operator it signs the number. */
|
|
93
|
+
function endsValue(previous: Token | undefined) {
|
|
94
|
+
if (previous === undefined) return false;
|
|
95
|
+
if (previous.kind === "punct") return previous.value === ")";
|
|
96
|
+
if (previous.kind !== "word") return true;
|
|
97
|
+
return ["END", "NULL", "TRUE", "FALSE"].includes(previous.upper) || isName(previous);
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
/** Reads the token at `i`: a quoted identifier, a number (negative only where a value is expected), a word or punctuation. */
|
|
88
101
|
function lexeme(sql: string, i: number, previous: Token | undefined) {
|
|
89
102
|
const char = sql[i]!;
|
|
@@ -92,27 +105,13 @@ function lexeme(sql: string, i: number, previous: Token | undefined) {
|
|
|
92
105
|
if (close === -1) throw new DatasetSqlError("A quoted identifier is not closed.");
|
|
93
106
|
const value = sql.slice(i + 1, close);
|
|
94
107
|
return {
|
|
95
|
-
token: {
|
|
96
|
-
kind: "word",
|
|
97
|
-
value,
|
|
98
|
-
upper: value.toUpperCase(),
|
|
99
|
-
quoted: true,
|
|
100
|
-
} satisfies Token,
|
|
108
|
+
token: { kind: "word", value, upper: "" } satisfies Token,
|
|
101
109
|
next: close + 1,
|
|
102
110
|
};
|
|
103
111
|
}
|
|
104
112
|
const rest = sql.slice(i);
|
|
105
113
|
const number = /^-?\d+(\.\d+)?/.exec(rest);
|
|
106
|
-
|
|
107
|
-
const afterValue =
|
|
108
|
-
previous !== undefined &&
|
|
109
|
-
(previous.kind === "punct"
|
|
110
|
-
? previous.value === ")"
|
|
111
|
-
: previous.kind === "word"
|
|
112
|
-
? ["END", "NULL", "TRUE", "FALSE"].includes(previous.upper) || isName(previous)
|
|
113
|
-
: true);
|
|
114
|
-
const signIsOperator = char === "-" && afterValue;
|
|
115
|
-
if (number && !signIsOperator) {
|
|
114
|
+
if (number && !(char === "-" && endsValue(previous))) {
|
|
116
115
|
return {
|
|
117
116
|
token: { kind: "number", value: Number(number[0]) } satisfies Token,
|
|
118
117
|
next: i + number[0].length,
|
|
@@ -120,8 +119,13 @@ function lexeme(sql: string, i: number, previous: Token | undefined) {
|
|
|
120
119
|
}
|
|
121
120
|
const word = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
|
|
122
121
|
if (word) {
|
|
122
|
+
const qualified = previous?.kind === "punct" && previous.value === ".";
|
|
123
123
|
return {
|
|
124
|
-
token: {
|
|
124
|
+
token: {
|
|
125
|
+
kind: "word",
|
|
126
|
+
value: word[0],
|
|
127
|
+
upper: qualified ? "" : word[0].toUpperCase(),
|
|
128
|
+
} satisfies Token,
|
|
125
129
|
next: i + word[0].length,
|
|
126
130
|
};
|
|
127
131
|
}
|
|
@@ -338,12 +342,35 @@ const RESERVED = [
|
|
|
338
342
|
"END",
|
|
339
343
|
];
|
|
340
344
|
|
|
345
|
+
/** Whether a bare word reads as a keyword rather than a name. */
|
|
346
|
+
function isKeyword(upper: string) {
|
|
347
|
+
return RESERVED.includes(upper) || upper in REFUSED_CLAUSES;
|
|
348
|
+
}
|
|
349
|
+
|
|
341
350
|
function isName(token: Token): token is Token & { kind: "word" } {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
return token.kind === "word" && !isKeyword(token.upper);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Words that bare read as a value rather than a column. */
|
|
355
|
+
const VALUE_WORDS = ["TRUE", "FALSE", "CURRENT_DATE", "CURRENT_TIMESTAMP"];
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A column name as a query writes it: double quoted when bare it would read
|
|
359
|
+
* as a keyword or a value (`"from"`), so what the model is shown is what it
|
|
360
|
+
* can send.
|
|
361
|
+
*/
|
|
362
|
+
export function sqlColumnName(name: string) {
|
|
363
|
+
const upper = name.toUpperCase();
|
|
364
|
+
return isKeyword(upper) || VALUE_WORDS.includes(upper) ? `"${name}"` : name;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `message`, and for a keyword where a column belongs, how to write a column spelled like it. */
|
|
368
|
+
function keywordError(token: Token, message: string) {
|
|
369
|
+
const hint =
|
|
370
|
+
token.kind === "word" && isKeyword(token.upper)
|
|
371
|
+
? ` ${token.value} is a SQL keyword: write a column of that name as "${token.value}" or dataset.${token.value}.`
|
|
372
|
+
: "";
|
|
373
|
+
return new DatasetSqlError(`${message}${hint}`);
|
|
347
374
|
}
|
|
348
375
|
|
|
349
376
|
/** A unit written as `'month'`, `month` or `MONTH`. */
|
|
@@ -971,11 +998,21 @@ class Parser {
|
|
|
971
998
|
);
|
|
972
999
|
}
|
|
973
1000
|
const op = (operator.value === "<>" ? "!=" : operator.value) as DatasetFilterOp;
|
|
1001
|
+
const message = `"${shown} ${operator.value}" needs a value on the right.`;
|
|
974
1002
|
if (this.startsTerm()) {
|
|
975
|
-
return {
|
|
1003
|
+
return {
|
|
1004
|
+
kind: "cond",
|
|
1005
|
+
left,
|
|
1006
|
+
op,
|
|
1007
|
+
value: { term: this.term(message) },
|
|
1008
|
+
} satisfies Predicate;
|
|
1009
|
+
}
|
|
1010
|
+
// A keyword here may be a column spelled like one (`x > from`); NULL keeps its own refusal.
|
|
1011
|
+
const next = this.peek();
|
|
1012
|
+
if (next.kind === "word" && next.upper !== "NULL" && isKeyword(next.upper)) {
|
|
1013
|
+
throw keywordError(next, message);
|
|
976
1014
|
}
|
|
977
|
-
|
|
978
|
-
return { kind: "cond", left, op, value } satisfies Predicate;
|
|
1015
|
+
return { kind: "cond", left, op, value: this.literal(message) } satisfies Predicate;
|
|
979
1016
|
}
|
|
980
1017
|
|
|
981
1018
|
/** Whether the next tokens are a value expression rather than a literal: a name, `(`, CASE, or a number followed by an operator. */
|
|
@@ -1174,10 +1211,10 @@ class Parser {
|
|
|
1174
1211
|
return count.value;
|
|
1175
1212
|
}
|
|
1176
1213
|
|
|
1177
|
-
/** `a.b` as one reference; a bare keyword in reference position gets the caller's message. */
|
|
1214
|
+
/** `a.b` as one reference; a bare keyword in reference position gets the caller's message (`keywordError`). */
|
|
1178
1215
|
private reference(message: string) {
|
|
1179
1216
|
const token = this.peek();
|
|
1180
|
-
if (!isName(token)) throw
|
|
1217
|
+
if (!isName(token)) throw keywordError(token, message);
|
|
1181
1218
|
this.take();
|
|
1182
1219
|
let ref = token.value;
|
|
1183
1220
|
while (this.optionalPunct(".")) {
|
|
@@ -1243,22 +1243,163 @@ const manifestWebhookSchema = z.strictObject({
|
|
|
1243
1243
|
});
|
|
1244
1244
|
export type ManifestWebhook = z.input<typeof manifestWebhookSchema>;
|
|
1245
1245
|
|
|
1246
|
-
/**
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
+
),
|
|
1259
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(),
|
|
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);
|
|
@@ -1477,6 +1618,15 @@ export const manifestSchema = z.strictObject({
|
|
|
1477
1618
|
/** A manifest as written: every field with a default may be left out. */
|
|
1478
1619
|
export type Manifest = z.input<typeof manifestSchema>;
|
|
1479
1620
|
|
|
1621
|
+
/**
|
|
1622
|
+
* A manifest as Cortex receives it: the one written, each document `path`
|
|
1623
|
+
* read into `data` by `cortex apply`, since Cortex cannot read the files
|
|
1624
|
+
* beside it.
|
|
1625
|
+
*/
|
|
1626
|
+
export const resolvedManifestSchema = manifestSchema.extend({
|
|
1627
|
+
collections: listOf(MANIFEST_NAME_FIELDS.collections, resolvedCollectionSchema),
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1480
1630
|
const manifestKindSchema = z.enum(MANIFEST_KINDS);
|
|
1481
1631
|
|
|
1482
1632
|
/**
|
|
@@ -1492,11 +1642,17 @@ export const manifestChangesSchema = z.object({
|
|
|
1492
1642
|
delete: namesByKindSchema,
|
|
1493
1643
|
});
|
|
1494
1644
|
|
|
1495
|
-
/**
|
|
1645
|
+
/**
|
|
1646
|
+
* A field of one entry that both the manifest and a live write changed since
|
|
1647
|
+
* the last apply. A field holding named parts (an agent's settings, its
|
|
1648
|
+
* greeting and suggestions by locale, a collection's documents by name) is
|
|
1649
|
+
* judged part by part, and `key` names the part.
|
|
1650
|
+
*/
|
|
1496
1651
|
const manifestConflictSchema = z.strictObject({
|
|
1497
1652
|
kind: manifestKindSchema,
|
|
1498
1653
|
name: z.string(),
|
|
1499
1654
|
field: z.string(),
|
|
1655
|
+
key: z.string().optional(),
|
|
1500
1656
|
});
|
|
1501
1657
|
|
|
1502
1658
|
/** Which value a conflict keeps: the one changed live in Cortex, or the manifest's. */
|
|
@@ -1506,9 +1662,10 @@ const conflictSideSchema = z.enum(["live", "manifest"]);
|
|
|
1506
1662
|
* What preview and sync take: the manifest, and how its conflicts resolve.
|
|
1507
1663
|
* A conflict takes its own entry in `resolutions`, else `prefer`; a sync
|
|
1508
1664
|
* left with one neither answers is refused as `unresolved_conflicts`,
|
|
1509
|
-
* naming them. A resolution
|
|
1665
|
+
* naming them. A resolution answers the conflict with its kind, name, field
|
|
1666
|
+
* and key; one for a field that no longer conflicts is ignored.
|
|
1510
1667
|
*/
|
|
1511
|
-
export const manifestApplyInputSchema =
|
|
1668
|
+
export const manifestApplyInputSchema = resolvedManifestSchema.extend({
|
|
1512
1669
|
prefer: conflictSideSchema.optional(),
|
|
1513
1670
|
resolutions: z.array(manifestConflictSchema.extend({ keep: conflictSideSchema })).default([]),
|
|
1514
1671
|
});
|
|
@@ -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 {
|
|
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: `
|
|
8
|
-
* written, for a caller that lets Cortex fill its
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
}
|