@m6d/cortex-cli 0.14.0 → 0.15.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 CHANGED
@@ -143,8 +143,16 @@ touched, and a name one of them holds fails the apply.
143
143
  cortex apply # ./cortex.manifest.ts
144
144
  cortex apply deploy/manifest.json --dry-run
145
145
  cortex apply -p prod
146
+ cortex apply --prefer live # keep every live edit the manifest also changed
146
147
  ```
147
148
 
149
+ An entry's `overridable` lists the fields people may change in Cortex, such
150
+ as an agent's `prompt`. An apply keeps those edits. When the manifest changes
151
+ a field that was also edited live, `cortex apply` asks which to keep for
152
+ each one. `--prefer live` or `--prefer manifest` answers them all. Without a
153
+ terminal and without `--prefer`, the apply changes nothing and lists the
154
+ conflicts; `--dry-run` marks each with `!`.
155
+
148
156
  The path defaults to exactly `./cortex.manifest.ts` in the directory you run
149
157
  in. TypeScript and JSON load the same way: the file's default export is the
150
158
  manifest. `defineManifest` from `@m6d/cortex` types one as you write it:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,19 +1,38 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { relative, resolve } from "node:path";
3
3
 
4
+ import { Option } from "@commander-js/extra-typings";
4
5
  import type { Command } from "@commander-js/extra-typings";
5
- import { appliedManifestSchema, manifestChangesSchema } from "@cortex/contracts/manifest";
6
+ import { isRecord } from "@cortex/contracts/json";
7
+ import {
8
+ appliedManifestSchema,
9
+ manifestApplyInputSchema,
10
+ manifestPreviewSchema,
11
+ } from "@cortex/contracts/manifest";
6
12
  import { loadManifestFile } from "@cortex/contracts/manifest-file";
7
13
  import { z } from "zod";
8
14
 
9
- import { callManagement } from "@/management/client";
10
- import { callOrFail, requireProfile } from "@/commands/management";
15
+ import { callManagement, ManagementError } from "@/management/client";
16
+ import { failCall, requireProfile } from "@/commands/management";
17
+ import { askChoice } from "@/ui/prompts";
11
18
  import * as report from "@/ui/report";
12
19
 
13
20
  // Discovery is this exact name in the cwd, with no upward walk, so the
14
21
  // argument's default is the discovery rule.
15
22
  export const DEFAULT_MANIFEST = "./cortex.manifest.ts";
16
23
 
24
+ /** Which value a field both the manifest and a live write changed keeps. */
25
+ const SIDES = manifestApplyInputSchema.shape.prefer.unwrap().options;
26
+ type Side = (typeof SIDES)[number];
27
+ type Resolution = z.input<typeof manifestApplyInputSchema>["resolutions"];
28
+ type Conflict = z.infer<typeof manifestPreviewSchema>["conflicts"][number];
29
+
30
+ /** A sync refused for fields both sides changed that nothing answered. */
31
+ const unresolvedSchema = z.object({
32
+ error: z.literal("unresolved_conflicts"),
33
+ conflicts: manifestPreviewSchema.shape.conflicts,
34
+ });
35
+
17
36
  const CHANGES = ["create", "update", "delete"] as const;
18
37
  const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
19
38
  (typeof CHANGES)[number],
@@ -23,7 +42,9 @@ const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
23
42
  /**
24
43
  * `cortex apply [path]`: makes Cortex match a manifest in one
25
44
  * transaction, through the management API with the profile's key.
26
- * `--dry-run` answers what would change and changes nothing.
45
+ * `--dry-run` answers what would change and changes nothing. A field both
46
+ * the manifest and a live edit in Cortex changed is asked about, unless
47
+ * `--prefer` answers every one.
27
48
  */
28
49
  export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
29
50
  const command = program
@@ -31,7 +52,13 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
31
52
  .helpGroup("Cortex:")
32
53
  .description("Make Cortex match a manifest")
33
54
  .argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
34
- .option("--dry-run", "Show what would change, and change nothing");
55
+ .option("--dry-run", "Show what would change, and change nothing")
56
+ .addOption(
57
+ new Option(
58
+ "--prefer <side>",
59
+ "Keep the live or the manifest value of every field both changed, without asking",
60
+ ).choices(SIDES),
61
+ );
35
62
 
36
63
  command.action(async function (path, options) {
37
64
  const { verbose = false, profile: profileName } = program.opts();
@@ -43,15 +70,26 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
43
70
  report.input("Manifest", label(file));
44
71
  report.input("Cortex", profile.url);
45
72
 
46
- const { changes, triggerSecrets } = await applyManifest({ profile, file, dryRun, verbose });
73
+ const { changes, triggerSecrets, conflicts } = await applyManifest({
74
+ profile,
75
+ file,
76
+ dryRun,
77
+ verbose,
78
+ prefer: options.prefer,
79
+ interactive: process.stdin.isTTY === true,
80
+ });
47
81
  const created = countNames(changes.create);
48
82
  const updated = countNames(changes.update);
49
83
  const deleted = countNames(changes.delete);
84
+ const conflicted =
85
+ conflicts.length > 0
86
+ ? `, ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`
87
+ : "";
50
88
  report.footer(
51
- created + updated + deleted === 0
89
+ created + updated + deleted + conflicts.length === 0
52
90
  ? "no changes"
53
91
  : dryRun
54
- ? `would create ${created}, update ${updated}, delete ${deleted}`
92
+ ? `would create ${created}, update ${updated}, delete ${deleted}${conflicted}`
55
93
  : `created ${created}, updated ${updated}, deleted ${deleted}`,
56
94
  );
57
95
  printTriggerSecrets(triggerSecrets);
@@ -60,33 +98,36 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
60
98
 
61
99
  /**
62
100
  * Loads the manifest at `file` and sends it, a preview under `dryRun`, then
63
- * prints its changes one row per entry. Ends the run on an invalid file, a
64
- * refusal, or a summary this CLI cannot read. `cortex apply` and `cortex
65
- * setup` both apply through it; each prints the trigger secrets it returns.
101
+ * prints its changes one row per entry, and a preview's conflicts. Ends the
102
+ * run on an invalid file, a refusal, or a summary this CLI cannot read.
103
+ * `cortex apply` and `cortex setup` both apply through it; each prints the
104
+ * trigger secrets it returns.
66
105
  */
67
106
  export async function applyManifest(options: {
68
107
  profile: ReturnType<typeof requireProfile>;
69
108
  file: string;
70
109
  dryRun: boolean;
71
110
  verbose: boolean;
111
+ prefer?: Side;
112
+ interactive: boolean;
72
113
  }) {
73
114
  const { profile, file, dryRun, verbose } = options;
74
- const manifest = await loadManifest(file, verbose);
75
- const answer = await callOrFail(profile, () =>
76
- callManagement(
77
- profile,
78
- "post",
79
- dryRun ? "/manifests/preview" : "/manifests/sync",
80
- manifest,
115
+ const answer = await sendManifest({
116
+ ...options,
117
+ // What to run again: `cortex apply`, with the path unless it is the default.
118
+ command: ["cortex apply", ...(label(file) === DEFAULT_MANIFEST ? [] : [label(file)])].join(
119
+ " ",
81
120
  ),
82
- );
121
+ manifest: await loadManifest(file, verbose),
122
+ path: dryRun ? "/manifests/preview" : "/manifests/sync",
123
+ });
83
124
  // The secrets are read apart from the changes: a sync has committed by
84
125
  // now, and a summary this CLI is too old to read must not lose them.
85
126
  const { triggerSecrets = {} } = appliedManifestSchema
86
127
  .pick({ triggerSecrets: true })
87
128
  .partial()
88
129
  .parse(answer);
89
- const applied = manifestChangesSchema.safeParse(answer);
130
+ const applied = manifestPreviewSchema.partial({ conflicts: true }).safeParse(answer);
90
131
  if (!applied.success) {
91
132
  printTriggerSecrets(triggerSecrets);
92
133
  report.fail({
@@ -97,8 +138,68 @@ export async function applyManifest(options: {
97
138
  ],
98
139
  });
99
140
  }
100
- printChanges(applied.data);
101
- return { changes: applied.data, triggerSecrets };
141
+ const { conflicts = [], ...changes } = applied.data;
142
+ printChanges(changes, conflicts);
143
+ return { changes, triggerSecrets, conflicts };
144
+ }
145
+
146
+ /**
147
+ * Sends the manifest until nothing in it is left unanswered. A sync refused
148
+ * for fields both the manifest and a live edit changed asks about each one
149
+ * when it can, then goes again with the answers; one that cannot ask ends
150
+ * the run naming them. Any other refusal ends the run as well.
151
+ */
152
+ async function sendManifest(options: {
153
+ profile: ReturnType<typeof requireProfile>;
154
+ path: string;
155
+ command: string;
156
+ manifest: Record<string, unknown>;
157
+ prefer?: Side;
158
+ interactive: boolean;
159
+ }) {
160
+ const { profile, path, command, manifest, prefer, interactive } = options;
161
+ const resolutions: Resolution = [];
162
+ for (;;) {
163
+ try {
164
+ return await callManagement(profile, "post", path, {
165
+ ...manifest,
166
+ prefer,
167
+ resolutions,
168
+ });
169
+ } catch (error) {
170
+ const refused =
171
+ error instanceof ManagementError
172
+ ? unresolvedSchema.safeParse(error.detail)
173
+ : undefined;
174
+ if (!refused?.success) return failCall(profile, error);
175
+ if (!interactive) failUnresolved(refused.data.conflicts, command);
176
+ for (const conflict of refused.data.conflicts) {
177
+ const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
178
+ { value: "live", label: "Keep live" },
179
+ { value: "manifest", label: "Use manifest" },
180
+ ]);
181
+ resolutions.push({ ...conflict, keep });
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ /** How a conflict reads: its entry and the field both sides changed. */
188
+ function conflictLabel({ kind, name, field }: Conflict) {
189
+ return `${kind} ${name}: ${field} changed in both places`;
190
+ }
191
+
192
+ /** Ends a run that cannot ask, naming every conflict and how to run `command` again to answer them. */
193
+ function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
194
+ const count = conflicts.length;
195
+ return report.fail({
196
+ what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
197
+ blocks: [
198
+ conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
199
+ "Keep one side for all of them, or run in a terminal to choose each:",
200
+ ` ${command} --prefer live\n ${command} --prefer manifest`,
201
+ ],
202
+ });
102
203
  }
103
204
 
104
205
  /** How a manifest path prints: relative to the cwd, `./`-prefixed. */
@@ -140,17 +241,23 @@ async function loadManifest(path: string, verbose: boolean) {
140
241
  verbose,
141
242
  }),
142
243
  );
143
- if (!parsed.success) {
244
+ if (!parsed.success || !isRecord(written)) {
144
245
  report.fail({
145
246
  what: `${label(path)} is not a valid manifest`,
146
- blocks: [z.prettifyError(parsed.error)],
247
+ blocks: parsed.success ? [] : [z.prettifyError(parsed.error)],
147
248
  });
148
249
  }
149
250
  return written;
150
251
  }
151
252
 
152
- /** One row per changed entry under its kind: `+` created, `~` updated, `-` deleted. */
153
- function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, string[]>>) {
253
+ /**
254
+ * One row per changed entry under its kind: `+` created, `~` updated, `-`
255
+ * deleted, and `!` a field both the manifest and a live edit changed.
256
+ */
257
+ function printChanges(
258
+ changes: Record<(typeof CHANGES)[number], Record<string, string[]>>,
259
+ conflicts: ReadonlyArray<Conflict>,
260
+ ) {
154
261
  const rows = new Map<string, string[]>();
155
262
  for (const change of CHANGES) {
156
263
  for (const [kind, names] of Object.entries(changes[change])) {
@@ -158,6 +265,9 @@ function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, s
158
265
  rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
159
266
  }
160
267
  }
268
+ for (const { kind, name, field } of conflicts) {
269
+ rows.set(kind, [...(rows.get(kind) ?? []), `! ${name} ${field} changed in both places`]);
270
+ }
161
271
  for (const [kind, lines] of rows) {
162
272
  for (const [index, line] of lines.entries()) {
163
273
  report.row(index === 0 ? kind : "", line, 14);
@@ -356,6 +356,13 @@ function declareCommand(
356
356
  return command;
357
357
  }
358
358
 
359
+ /** What a publish went ahead despite, such as a prompt naming a tool without an @-mention. */
360
+ function warningsOf(published: unknown) {
361
+ return isRecord(published) && Array.isArray(published.warnings)
362
+ ? published.warnings.filter((warning): warning is string => typeof warning === "string")
363
+ : [];
364
+ }
365
+
359
366
  /**
360
367
  * A write to a publishable router is a draft: publish it now, or say how to.
361
368
  * Fields Cortex reads live are already in effect and are named as such,
@@ -378,10 +385,11 @@ async function publishAfterWrite(
378
385
  );
379
386
  }
380
387
  if (publish) {
381
- await callOrFail(profile, () =>
388
+ const published = await callOrFail(profile, () =>
382
389
  callManagement(profile, publisher.method, publisher.path, { id }),
383
390
  );
384
391
  options.note(`Published ${id}.`);
392
+ for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
385
393
  } else {
386
394
  options.note(
387
395
  `${live ? "The rest is" : "Saved as"} a draft. Add --publish, or run: ${command}`,
@@ -135,6 +135,7 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
135
135
  file,
136
136
  dryRun: false,
137
137
  verbose,
138
+ interactive,
138
139
  });
139
140
  report.footer(`live · chat at ${profile.url}/playground`);
140
141
  printTriggerSecrets(triggerSecrets);
@@ -15,7 +15,13 @@ import type {
15
15
  DatasetQueryInput,
16
16
  DatasetRelation,
17
17
  } from "./datasets";
18
- import { DATASET_BUCKETS, DATASET_DATE_PARTS, describeOperand, groupKeyName } from "./datasets";
18
+ import {
19
+ DATASET_BUCKETS,
20
+ DATASET_DATE_PARTS,
21
+ describeOperand,
22
+ groupKeyName,
23
+ unknownColumnError,
24
+ } from "./datasets";
19
25
 
20
26
  /*
21
27
  * The SQL a model writes against a dataset, and how it becomes the wire
@@ -1658,6 +1664,11 @@ class Sources {
1658
1664
  `"${qualifier}" is a link table; read its columns from the datasets it links.`,
1659
1665
  );
1660
1666
  }
1667
+ // Checked here, where the query's own dataset name is known: the wire
1668
+ // reference names the relation path instead.
1669
+ if (!source.dataset.columns.some((c) => c.name === column)) {
1670
+ throw new DatasetSqlError(unknownColumnError(source.dataset, column));
1671
+ }
1661
1672
  return source.path ? `${source.path}.${column}` : column;
1662
1673
  }
1663
1674
 
@@ -1747,10 +1758,8 @@ class Sources {
1747
1758
  `"${column}" is a column of ${owners.map((o) => o.dataset.name).join(" and ")}; qualify it as dataset.column.`,
1748
1759
  );
1749
1760
  }
1750
- const joined = this.paths.length ? " or of the joined datasets" : "";
1751
- throw new DatasetSqlError(
1752
- `"${column}" is not a column of ${this.root.dataset.name}${joined}.`,
1753
- );
1761
+ const joined = this.paths.length ? " Nor does any joined dataset." : "";
1762
+ throw new DatasetSqlError(unknownColumnError(this.root.dataset, column) + joined);
1754
1763
  }
1755
1764
 
1756
1765
  /** Every readable dataset once, whatever it is named as. */
@@ -2466,10 +2475,18 @@ function calendarDate(now: Date, timeZone: string | undefined) {
2466
2475
  }
2467
2476
  }
2468
2477
 
2478
+ /** The longest statement a query may be: room for any query a model writes, and a bound on the work one costs. */
2479
+ export const DATASET_SQL_MAX_LENGTH = 8_000;
2480
+
2469
2481
  /** Parses one SELECT against the catalog into the wire query; throws {@link DatasetSqlError} with what to write instead. */
2470
2482
  export function parseDatasetSql(sql: string, catalog: DatasetCatalog, options: ParseOptions) {
2471
2483
  const trimmed = sql.trim();
2472
2484
  if (!trimmed) throw new DatasetSqlError("The query is empty.");
2485
+ if (trimmed.length > DATASET_SQL_MAX_LENGTH) {
2486
+ throw new DatasetSqlError(
2487
+ `The query is longer than ${DATASET_SQL_MAX_LENGTH} characters; ask for less at once.`,
2488
+ );
2489
+ }
2473
2490
  const today = calendarDate(options.now, options.timeZone);
2474
2491
  return compile(catalog, new Parser(tokenize(trimmed), today).statement());
2475
2492
  }
@@ -25,11 +25,22 @@ export const datasetColumnRefSchema = z.string().regex(DATASET_COLUMN_REF_PATTER
25
25
  export const DATASET_COLUMN_TYPES = ["string", "number", "boolean", "date"] as const;
26
26
  export type DatasetColumnType = (typeof DATASET_COLUMN_TYPES)[number];
27
27
 
28
- export const datasetColumnSchema = z.object({
29
- name: datasetIdentifierSchema,
30
- type: z.enum(DATASET_COLUMN_TYPES),
31
- description: z.string().max(200).default(""),
32
- });
28
+ /**
29
+ * One column. `values`, on a string column, lists every value it can hold:
30
+ * the agent reads them, and a query comparing the column to anything else is
31
+ * refused before it runs.
32
+ */
33
+ export const datasetColumnSchema = z
34
+ .object({
35
+ name: datasetIdentifierSchema,
36
+ type: z.enum(DATASET_COLUMN_TYPES),
37
+ description: z.string().max(200).default(""),
38
+ values: z.array(z.string().min(1).max(200)).min(1).max(100).optional(),
39
+ })
40
+ .refine((column) => column.values === undefined || column.type === "string", {
41
+ message: "only a string column lists its values",
42
+ path: ["values"],
43
+ });
33
44
  export type DatasetColumn = z.infer<typeof datasetColumnSchema>;
34
45
 
35
46
  /**
@@ -396,10 +407,59 @@ export function resolveDatasetColumn(catalog: DatasetCatalog, from: string, ref:
396
407
  dataset = target;
397
408
  }
398
409
  const column = dataset.columns.find((candidate) => candidate.name === columnName);
399
- if (!column) return missing(`"${dataset.name}" has no column "${columnName}"`);
410
+ if (!column) return missing(unknownColumnError(dataset, columnName));
400
411
  return { ok: true as const, column, path: segments.join(".") };
401
412
  }
402
413
 
414
+ /**
415
+ * Why `dataset` has no column `name`, with the columns it has and up to
416
+ * three closest ones: names containing it or contained in it (ignoring
417
+ * case, and only for names longer than two characters), else names two
418
+ * edits or fewer away. A query that guessed `name` reads `departments has
419
+ * no column "name". Closest: nameAr, nameEn. Columns: id, nameAr, ...`
420
+ */
421
+ export function unknownColumnError(dataset: DatasetDefinition, name: string) {
422
+ const columns = dataset.columns.map((column) => column.name);
423
+ const wanted = name.toLowerCase();
424
+ const containing = columns.filter((column) => {
425
+ const lower = column.toLowerCase();
426
+ return (
427
+ (wanted.length > 2 && lower.includes(wanted)) ||
428
+ (lower.length > 2 && wanted.includes(lower))
429
+ );
430
+ });
431
+ const closest = (
432
+ containing.length > 0
433
+ ? containing
434
+ : // Lengths more than two apart are more than two edits apart, so a
435
+ // name of any length costs only its own columns' comparisons.
436
+ columns.filter(
437
+ (column) =>
438
+ Math.abs(column.length - wanted.length) <= 2 &&
439
+ editDistance(column.toLowerCase(), wanted) <= 2,
440
+ )
441
+ ).slice(0, 3);
442
+ const hint = closest.length > 0 ? ` Closest: ${closest.join(", ")}.` : "";
443
+ return `${dataset.name} has no column "${name}".${hint} Columns: ${columns.join(", ")}.`;
444
+ }
445
+
446
+ /** Levenshtein distance: the fewest single-character edits from `a` to `b`. */
447
+ function editDistance(a: string, b: string) {
448
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
449
+ for (let i = 1; i <= a.length; i++) {
450
+ const current = [i];
451
+ for (let j = 1; j <= b.length; j++) {
452
+ current[j] = Math.min(
453
+ previous[j]! + 1,
454
+ current[j - 1]! + 1,
455
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
456
+ );
457
+ }
458
+ previous = current;
459
+ }
460
+ return previous[b.length]!;
461
+ }
462
+
403
463
  function refError(kind: string, ref: string, reason: string) {
404
464
  return `${kind} "${ref}": ${reason}`;
405
465
  }
@@ -654,6 +714,19 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
654
714
  } satisfies QueryCheck;
655
715
 
656
716
  for (const filter of query.filter) checkFilter(check, filter);
717
+ const row = checkAnsweredRow(check, catalog, query);
718
+ checkFinishedRow(check, query, row);
719
+ checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
720
+ return issues;
721
+ }
722
+
723
+ /**
724
+ * The row a query answers, checked as it is built: on a grouped query its
725
+ * keys and aggregate aliases (never one name for both, and no select), on a
726
+ * plain one its select aliases; each with the names that are a column as it
727
+ * is, which keep what the column says.
728
+ */
729
+ function checkAnsweredRow(check: QueryCheck, catalog: DatasetCatalog, query: DatasetQuery) {
657
730
  const groupKeys = checkGroupBy(check, query.groupBy);
658
731
  const aliases = checkAggregates(check, query.aggregate);
659
732
  for (const alias of aliases.keys()) {
@@ -671,12 +744,17 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
671
744
  );
672
745
  }
673
746
  const selectAliases = checkSelect(check, query.select ?? []);
674
- const row = grouped
675
- ? { names: new Map([...groupKeys, ...aliases]), kind: "grouped" as const }
676
- : { names: selectAliases, kind: "plain" as const };
677
- checkFinishedRow(check, query, row);
678
- checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
679
- return issues;
747
+ return grouped
748
+ ? {
749
+ names: new Map([...groupKeys, ...aliases]),
750
+ kind: "grouped" as const,
751
+ columns: passedColumns(catalog, query, query.groupBy),
752
+ }
753
+ : {
754
+ names: selectAliases,
755
+ kind: "plain" as const,
756
+ columns: passedColumns(catalog, query, query.select ?? []),
757
+ };
680
758
  }
681
759
 
682
760
  /** How large any one expression a host translates may grow once every alias it names is written out in place. */
@@ -856,7 +934,7 @@ function filterCost(filter: DatasetFilter, expanded: ReadonlyMap<string, number>
856
934
  function checkFinishedRow(
857
935
  check: QueryCheck,
858
936
  query: DatasetQuery,
859
- row: { names: Map<string, DatasetColumnType>; kind: "grouped" | "plain" },
937
+ row: RowNames & { names: Map<string, DatasetColumnType> },
860
938
  ) {
861
939
  const rowCheck = rowScope(check, row);
862
940
  // On a plain row a selected column is a name too; a window or calculation may not take it.
@@ -881,7 +959,7 @@ function rowScope(check: QueryCheck, row: RowNames) {
881
959
  issue: (message: string) => check.issue(message),
882
960
  columnOf(kind, ref) {
883
961
  const type = row.names.get(ref);
884
- if (type) return { name: ref, type, description: "" };
962
+ if (type) return row.columns.get(ref) ?? { name: ref, type, description: "" };
885
963
  if (row.kind === "plain") return check.columnOf(kind, ref);
886
964
  check.issue(
887
965
  refError(
@@ -1203,10 +1281,31 @@ function checkFilter(check: QueryCheck, filter: DatasetFilter, kind = "filter")
1203
1281
  ? columnValueIssue(check, kind, type, filter.op, filter.value.col)
1204
1282
  : (filterValueIssue(filter) ??
1205
1283
  filterValueTypeIssue(type, filter) ??
1206
- truncatedDateIssue(filter));
1284
+ truncatedDateIssue(filter) ??
1285
+ listedValueIssue(check, kind, filter));
1207
1286
  if (issue) check.issue(refError(kind, shown, issue));
1208
1287
  }
1209
1288
 
1289
+ const LISTED_VALUE_OPS = new Set<DatasetFilterOp>(["=", "!=", "in", "notIn"]);
1290
+
1291
+ /**
1292
+ * A column that lists its values is only compared with one of them: any
1293
+ * other literal would match no row, or every row, and the host would answer
1294
+ * as if the data said so.
1295
+ */
1296
+ function listedValueIssue(check: QueryCheck, kind: string, filter: DatasetCondition) {
1297
+ if (typeof filter.col !== "string" || !LISTED_VALUE_OPS.has(filter.op)) return undefined;
1298
+ if (filter.value === undefined || isColumnValue(filter.value)) return undefined;
1299
+ const listed = check.columnOf(kind, filter.col)?.values;
1300
+ if (!listed) return undefined;
1301
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
1302
+ // A number or a boolean is compared as the text the host turns it into.
1303
+ const odd = values.find((value) => !listed.includes(String(value)));
1304
+ return odd === undefined
1305
+ ? undefined
1306
+ : `${JSON.stringify(odd)} is not one of its values: ${listed.join(", ")}`;
1307
+ }
1308
+
1210
1309
  /** A truncated date is a whole day, so a literal against it is one too ("2024-03-01"); a time or a bare month would never match. */
1211
1310
  function truncatedDateIssue(filter: DatasetCondition) {
1212
1311
  const truncated =
@@ -1314,7 +1413,31 @@ function aggregateType(
1314
1413
  }
1315
1414
 
1316
1415
  /** What sort and rank may name: on a grouped query a group key or an alias; on a plain one a select alias or any reachable column. */
1317
- type RowNames = { names: ReadonlyMap<string, DatasetColumnType>; kind: "grouped" | "plain" };
1416
+ type RowNames = {
1417
+ names: ReadonlyMap<string, DatasetColumnType>;
1418
+ kind: "grouped" | "plain";
1419
+ /** The names that are a column as it is (group keys, select entries), so what the column says (its values) still holds. */
1420
+ columns: ReadonlyMap<string, DatasetColumn>;
1421
+ };
1422
+
1423
+ /**
1424
+ * The row names that are a column as it is, each with the column: a bare
1425
+ * column (`status`), or one under an alias (`{ expr: "status", as: "state" }`).
1426
+ */
1427
+ function passedColumns(
1428
+ catalog: DatasetCatalog,
1429
+ query: DatasetQuery,
1430
+ entries: ReadonlyArray<string | { expr: DatasetOperand; as: string }>,
1431
+ ) {
1432
+ return new Map(
1433
+ entries.flatMap((entry) => {
1434
+ const [name, ref] = typeof entry === "string" ? [entry, entry] : [entry.as, entry.expr];
1435
+ if (typeof ref !== "string") return [];
1436
+ const resolved = resolveDatasetColumn(catalog, query.from, ref);
1437
+ return resolved.ok ? [[name, resolved.column] as const] : [];
1438
+ }),
1439
+ );
1440
+ }
1318
1441
 
1319
1442
  function checkSort(check: QueryCheck, sort: DatasetQuery["sort"], kind: string, row: RowNames) {
1320
1443
  for (const { col } of sort) checkRowName(check, kind, col, row);
@@ -7,19 +7,22 @@ const REDACTION_MARKER = "[REDACTED]";
7
7
  * itself and, after a leading scheme word (`Bearer xyz`, `Basic abc`), the
8
8
  * bare secret the scheme merely labels; each of those raw as in a header,
9
9
  * percent-encoded as in a path slot (`encodeURIComponent`) or a query value
10
- * (`URLSearchParams`, which spells a space as `+`), and JSON-escaped as inside
11
- * a request body. The scrubber replaces longer forms first.
10
+ * (`URLSearchParams`, which spells a space as `+`), JSON-escaped as inside
11
+ * a request body, and JSON-escaped then percent-encoded as a form field sent
12
+ * as JSON text is. The scrubber replaces longer forms first.
12
13
  */
13
14
  export function credentialForms(value: string) {
14
15
  const bare = /^[A-Za-z][\w.-]* +(\S.*)$/.exec(value)?.[1];
16
+ const percentEncoded = (form: string) => [
17
+ encodeURIComponent(form),
18
+ new URLSearchParams({ v: form }).toString().slice(2),
19
+ ];
15
20
  return [
16
21
  ...new Set(
17
- (bare ? [value, bare] : [value]).flatMap((form) => [
18
- form,
19
- encodeURIComponent(form),
20
- new URLSearchParams({ v: form }).toString().slice(2),
21
- JSON.stringify(form).slice(1, -1),
22
- ]),
22
+ (bare ? [value, bare] : [value]).flatMap((form) => {
23
+ const json = JSON.stringify(form).slice(1, -1);
24
+ return [form, ...percentEncoded(form), json, ...percentEncoded(json)];
25
+ }),
23
26
  ),
24
27
  ];
25
28
  }
@@ -24,3 +24,24 @@ export function escapePointerToken(token: string) {
24
24
  export function unescapePointerToken(token: string) {
25
25
  return token.replaceAll("~1", "/").replaceAll("~0", "~");
26
26
  }
27
+
28
+ /** An RFC 6901 array index: digits with no leading zero. */
29
+ export const ARRAY_INDEX = /^(0|[1-9]\d*)$/;
30
+
31
+ /** RFC 6901 lookup; `undefined` when the pointer names nothing (an inherited key, or an array index not written canonically, included). */
32
+ export function readJsonPointer(value: unknown, pointer: string) {
33
+ if (pointer === "") return value;
34
+ let current: unknown = value;
35
+ for (const raw of pointer.slice(1).split("/")) {
36
+ const key = unescapePointerToken(raw);
37
+ if (Array.isArray(current)) {
38
+ if (!ARRAY_INDEX.test(key)) return undefined;
39
+ current = current[Number(key)];
40
+ } else if (isRecord(current) && Object.hasOwn(current, key)) {
41
+ current = current[key];
42
+ } else {
43
+ return undefined;
44
+ }
45
+ }
46
+ return current;
47
+ }
@@ -46,8 +46,16 @@ export const TOOL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
46
46
  */
47
47
  export const RESPONSE_WRAPS = ["items", "value"] as const;
48
48
 
49
+ /** How OpenAPI serializes a query parameter: `form` (the default), `spaceDelimited`, `pipeDelimited` or `deepObject`. */
50
+ export const QUERY_STYLES = ["form", "spaceDelimited", "pipeDelimited", "deepObject"] as const;
51
+ /** How OpenAPI serializes a path parameter: `simple` (the default), `label` or `matrix`. */
52
+ export const PATH_STYLES = ["simple", "label", "matrix"] as const;
53
+
49
54
  export const SOAP_VERSIONS = ["1.1", "1.2"] as const;
50
55
 
56
+ /** An RFC 6901 pointer into a JSON document, naming a member: `/extra`, `/data/items`. */
57
+ const jsonPointerSchema = z.string().regex(/^\//).max(256);
58
+
51
59
  type Bounds = { min: number; max: number; default: number };
52
60
 
53
61
  /**
@@ -91,6 +99,25 @@ function setting({ min, max, default: fallback }: Bounds) {
91
99
  return z.number().int().min(min).max(max).default(fallback);
92
100
  }
93
101
 
102
+ /**
103
+ * The fields of an entry users may change in Cortex after an apply, each
104
+ * once, from the ones `fields` allows; none when left out. Every other field
105
+ * is the manifest's alone. An apply keeps a live change to a listed field
106
+ * unless the manifest changed that field too (a conflict, see
107
+ * `manifestApplyInputSchema`). Identity, what is fixed at creation, the
108
+ * audience, credentials, and whatever an apply checks against the
109
+ * manifest's own value are never offered.
110
+ */
111
+ function overridable<const TField extends string>(fields: readonly [TField, ...TField[]]) {
112
+ return z
113
+ .array(z.enum(fields))
114
+ .refine(
115
+ uniqueBy((field: TField) => field),
116
+ "a field repeats",
117
+ )
118
+ .default([]);
119
+ }
120
+
94
121
  /** Consumer-facing slugs: the manifest id, service keys, skill names and trigger names share it. */
95
122
  export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
96
123
  const slugSchema = z.string().regex(SLUG_PATTERN).max(64);
@@ -175,6 +202,16 @@ export const soapActionSchema = z.string().regex(/^[^"\\\u0000-\u001f\u007f]*$/)
175
202
  /** An envelope template: XML, so never only whitespace. */
176
203
  const envelopeTemplateSchema = z.string().refine((template) => template.trim().length > 0);
177
204
 
205
+ /**
206
+ * The media types a request body may be sent in besides JSON, the default.
207
+ * Each body field is written as its target says.
208
+ */
209
+ export const FORM_MEDIA_TYPES = [
210
+ "application/x-www-form-urlencoded",
211
+ "multipart/form-data",
212
+ ] as const;
213
+ export type FormMediaType = (typeof FORM_MEDIA_TYPES)[number];
214
+
178
215
  /**
179
216
  * The per-type payload a tool stores and publishes. Soap: the envelope
180
217
  * template is the full request XML with `{{field}}` placeholders bound to
@@ -182,7 +219,12 @@ const envelopeTemplateSchema = z.string().refine((template) => template.trim().l
182
219
  * acquires once per request.
183
220
  */
184
221
  export const toolConfigByType = {
185
- http: z.object({ responseWrap: z.enum(RESPONSE_WRAPS).optional() }),
222
+ http: z.object({
223
+ responseWrap: z.enum(RESPONSE_WRAPS).optional(),
224
+ requestMediaType: z.enum(FORM_MEDIA_TYPES).optional(),
225
+ /** Where a successful response keeps the result, for an API that wraps every answer (`{ success, extra }`); read before `responseWrap`. */
226
+ resultPath: jsonPointerSchema.optional(),
227
+ }),
186
228
  embedded: z.object({ embedOrigin: embedOriginSchema, surface: z.enum(TOOL_SURFACES) }),
187
229
  soap: z.object({
188
230
  soapVersion: z.enum(SOAP_VERSIONS),
@@ -343,17 +385,44 @@ export function sourceFits(
343
385
  }
344
386
 
345
387
  /**
346
- * Where a top-level scalar input field travels in the upstream request;
347
- * absent, path placeholders by name, then the query string on GET and the
348
- * body otherwise. `name` renames a query parameter, header or cookie on the
349
- * wire.
388
+ * Where a top-level input field travels in the upstream request; absent,
389
+ * path placeholders by name, then the query string on GET and the body
390
+ * otherwise. `name` renames a query parameter, header or cookie on the wire,
391
+ * and is the name `matrix` style writes into a path.
392
+ * `style` and `explode` write an array or object value as an OpenAPI
393
+ * parameter declares; left out, OpenAPI's defaults hold (`form` exploded for
394
+ * a query, `simple` unexploded for a path and a header). A cookie joins items
395
+ * with commas. In a form body (the tool's `requestMediaType`), a JSON
396
+ * `contentType` sends the value as JSON text, and `style` and `explode`
397
+ * write it as in a query; a multipart body repeats an array's items as
398
+ * parts, typing an object JSON unless `contentType` says text. A JSON body
399
+ * reads none of them.
350
400
  */
351
401
  export const fieldTargetSchema = z.discriminatedUnion("kind", [
352
- z.object({ kind: z.literal("body") }),
353
- z.object({ kind: z.literal("query"), name: z.string().trim().min(1).max(120).optional() }),
354
- z.object({ kind: z.literal("path") }),
355
- z.object({ kind: z.literal("header"), name: headerNameSchema.optional() }),
356
- z.object({ kind: z.literal("cookie"), name: headerNameSchema.optional() }),
402
+ z.strictObject({
403
+ kind: z.literal("body"),
404
+ contentType: z.enum(["application/json", "text/plain"]).optional(),
405
+ style: z.enum(QUERY_STYLES).optional(),
406
+ explode: z.boolean().optional(),
407
+ }),
408
+ z.strictObject({
409
+ kind: z.literal("query"),
410
+ name: z.string().trim().min(1).max(120).optional(),
411
+ style: z.enum(QUERY_STYLES).optional(),
412
+ explode: z.boolean().optional(),
413
+ }),
414
+ z.strictObject({
415
+ kind: z.literal("path"),
416
+ name: z.string().trim().min(1).max(120).optional(),
417
+ style: z.enum(PATH_STYLES).optional(),
418
+ explode: z.boolean().optional(),
419
+ }),
420
+ z.strictObject({
421
+ kind: z.literal("header"),
422
+ name: headerNameSchema.optional(),
423
+ explode: z.boolean().optional(),
424
+ }),
425
+ z.strictObject({ kind: z.literal("cookie"), name: headerNameSchema.optional() }),
357
426
  ]);
358
427
  export type FieldTarget = z.infer<typeof fieldTargetSchema>;
359
428
 
@@ -381,8 +450,8 @@ const itemsSchema = z.strictObject({
381
450
  * a type (with `"null"` beside it when the value may be null), an `enum` of
382
451
  * strings for a string, an object's `properties` and `required`, an
383
452
  * array's `items`, a `description`, and the extensions `x-sensitive`
384
- * (redacted from logs), `x-source` and `x-target` (top-level scalar fields
385
- * of a tool's input only).
453
+ * (redacted from logs), `x-source` (top-level scalar fields of a tool's
454
+ * input only) and `x-target` (top-level fields of a tool's input only).
386
455
  */
387
456
  const schemaPropertySchema = z.strictObject({
388
457
  type: z.union([schemaTypeSchema, z.tuple([schemaTypeSchema, z.literal("null")])]),
@@ -453,9 +522,9 @@ function enumIssueOf(property: SchemaProperty, type: string | undefined) {
453
522
  /**
454
523
  * What one property breaks of itself: an `enum` off a string, with no value,
455
524
  * or listing `null` when the type does not allow it or the other way round;
456
- * `items` off an array or an array without them; and `x-source` or
457
- * `x-target` off a scalar of a `bindable` node, or a source that does not
458
- * fit the field.
525
+ * `items` off an array or an array without them; `x-source` off a scalar
526
+ * of a `bindable` node, or a source that does not fit the field; and
527
+ * `x-target` off a `bindable` node.
459
528
  */
460
529
  function propertyIssues(property: SchemaProperty, bindable: boolean) {
461
530
  const issues = new Array<RowIssue>();
@@ -469,13 +538,17 @@ function propertyIssues(property: SchemaProperty, bindable: boolean) {
469
538
  });
470
539
  }
471
540
  const binds = bindable && SCALAR_TYPES.some((scalar) => scalar === type);
472
- for (const binding of ["x-source", "x-target"] as const) {
473
- if (property[binding] !== undefined && !binds) {
474
- issues.push({
475
- at: [binding],
476
- message: "only a top-level scalar field of a tool's input has it",
477
- });
478
- }
541
+ if (property["x-source"] !== undefined && !binds) {
542
+ issues.push({
543
+ at: ["x-source"],
544
+ message: "only a top-level scalar field of a tool's input has it",
545
+ });
546
+ }
547
+ if (property["x-target"] !== undefined && !bindable) {
548
+ issues.push({
549
+ at: ["x-target"],
550
+ message: "only a top-level field of a tool's input has it",
551
+ });
479
552
  }
480
553
  const source = property["x-source"];
481
554
  if (binds && source && !sourceFits(source, { type, enum: property.enum })) {
@@ -487,8 +560,8 @@ function propertyIssues(property: SchemaProperty, bindable: boolean) {
487
560
  /**
488
561
  * Refuses, at every depth below `path`, what the console's field rows cannot
489
562
  * hold, since it stores a schema as rows and would drop or change it in
490
- * silence (`nodeIssues`, `propertyIssues`); `x-source` and `x-target` only
491
- * on a scalar directly under a node where `bindable`.
563
+ * silence (`nodeIssues`, `propertyIssues`); `x-source` only on a scalar,
564
+ * and `x-target` only on a field, directly under a node where `bindable`.
492
565
  */
493
566
  function heldByRows(
494
567
  node: {
@@ -516,8 +589,8 @@ function heldByRows(
516
589
 
517
590
  /**
518
591
  * An object's JSON Schema, the shape of a tool's or trigger's input and
519
- * output. `bindable` admits `x-source` and `x-target` on its top-level
520
- * scalar fields, as a tool's input has them.
592
+ * output. `bindable` admits `x-source` on its top-level scalar fields and
593
+ * `x-target` on its top-level fields, as a tool's input has them.
521
594
  */
522
595
  function objectSchemaOf(bindable: boolean) {
523
596
  return z
@@ -572,6 +645,15 @@ const toolBaseSchema = z.strictObject({
572
645
  pinned: z.boolean().default(false),
573
646
  tags: toolTagsSchema.default([]),
574
647
  audience: audienceSchema,
648
+ overridable: overridable([
649
+ "description",
650
+ "timeoutMs",
651
+ "maxResponseBytes",
652
+ "rateLimitRequests",
653
+ "rateLimitWindowSeconds",
654
+ "pinned",
655
+ "tags",
656
+ ]),
575
657
  });
576
658
 
577
659
  /** A tool that calls an endpoint; with `requiresApproval`, every call waits for the end user to approve it. */
@@ -629,6 +711,8 @@ const manifestOpenApiSchema = z.strictObject({
629
711
  /** Where the document is read, with no credentials: OpenAPI 3 or Swagger 2, JSON or YAML. */
630
712
  url: upstreamUrlSchema,
631
713
  credentials: credentialsSchema,
714
+ /** Where every operation's successful response keeps its result, when the API wraps each answer. */
715
+ resultPath: jsonPointerSchema.optional(),
632
716
  /** Tools listed in every turn of every agent they reach, whatever the question. */
633
717
  pinned: toolNamesSchema,
634
718
  approval: z
@@ -650,6 +734,7 @@ const manifestServiceSchema = z.strictObject({
650
734
  /** Rich text: it may mention tools and skills. */
651
735
  description: localizedTextSchema,
652
736
  audience: audienceSchema,
737
+ overridable: overridable(["description"]),
653
738
  });
654
739
  export type ManifestService = z.input<typeof manifestServiceSchema>;
655
740
 
@@ -661,6 +746,7 @@ const manifestSkillSchema = z.strictObject({
661
746
  /** The key of the service the skill renders inside; fixed once created. */
662
747
  service: slugSchema.optional(),
663
748
  audience: audienceSchema,
749
+ overridable: overridable(["description", "body"]),
664
750
  });
665
751
  export type ManifestSkill = z.input<typeof manifestSkillSchema>;
666
752
 
@@ -677,7 +763,7 @@ export const triggerThreadPolicySchema = z.discriminatedUnion("policy", [
677
763
  z.object({ policy: z.literal("single") }),
678
764
  z.object({ policy: z.literal("per-firing") }),
679
765
  z.object({ policy: z.literal("ephemeral") }),
680
- z.object({ policy: z.literal("keyed"), key: z.string().regex(/^\//).max(256) }),
766
+ z.object({ policy: z.literal("keyed"), key: jsonPointerSchema }),
681
767
  ]);
682
768
  export type TriggerThreadPolicy = z.infer<typeof triggerThreadPolicySchema>;
683
769
 
@@ -708,6 +794,14 @@ const manifestTriggerSchema = z.strictObject({
708
794
  /** The model its runs talk to, over each agent's. */
709
795
  model: modelSchema.optional(),
710
796
  audience: audienceSchema,
797
+ overridable: overridable([
798
+ "description",
799
+ "instructions",
800
+ "threadPolicy",
801
+ "budgetPerDay",
802
+ "disableAfterFailures",
803
+ "model",
804
+ ]),
711
805
  });
712
806
  export type ManifestTrigger = z.input<typeof manifestTriggerSchema>;
713
807
 
@@ -731,6 +825,7 @@ const manifestDatasetHostSchema = z
731
825
  /** Among those, the ones listed in every turn, whatever the question. */
732
826
  pinned: z.array(datasetIdentifierSchema).max(50).default([]),
733
827
  audience: audienceSchema,
828
+ overridable: overridable(["timeoutMs"]),
734
829
  })
735
830
  .superRefine((host, context) => {
736
831
  const unexposed = unexposedPins(host);
@@ -771,6 +866,7 @@ const manifestMcpServerSchema = z.strictObject({
771
866
  include: mcpToolNamesSchema.default([]),
772
867
  exclude: mcpToolNamesSchema.default([]),
773
868
  audience: audienceSchema,
869
+ overridable: overridable(["include", "exclude"]),
774
870
  });
775
871
  export type ManifestMcpServer = z.input<typeof manifestMcpServerSchema>;
776
872
 
@@ -793,6 +889,7 @@ const manifestWebhookSchema = z.strictObject({
793
889
  ),
794
890
  timeoutMs: setting(SETTING_BOUNDS.webhook.timeoutMs),
795
891
  audience: audienceSchema,
892
+ overridable: overridable(["description", "timeoutMs"]),
796
893
  });
797
894
  export type ManifestWebhook = z.input<typeof manifestWebhookSchema>;
798
895
 
@@ -802,7 +899,10 @@ const manifestCollectionSchema = z.strictObject({
802
899
  description: z.string().trim().max(2_000).default(""),
803
900
  /** The key of the service every document in the collection belongs to. */
804
901
  service: slugSchema.optional(),
902
+ /** Whether answers may name its documents; false keeps their names, sections and pages from the model. */
903
+ citable: z.boolean().default(true),
805
904
  audience: audienceSchema,
905
+ overridable: overridable(["description"]),
806
906
  });
807
907
  export type ManifestCollection = z.input<typeof manifestCollectionSchema>;
808
908
 
@@ -840,10 +940,11 @@ const { agent: agentBounds, agentContext, agentLimits } = SETTING_BOUNDS;
840
940
 
841
941
  /** How the agent runs; every setting left out keeps its default. */
842
942
  const agentSettingsSchema = z.strictObject({
843
- /** Offers `searchKnowledge` over the collections the agent sees. */
943
+ /** Lets `searchRegistries` search the knowledge in the collections the agent sees. */
844
944
  ragEnabled: z.boolean().default(false),
845
- /** Offers `searchTools` over the tools the agent sees, beyond the pinned and mentioned ones. */
945
+ /** Lets `searchRegistries` search the tools the agent sees, beyond the pinned and mentioned ones. */
846
946
  toolRegistryEnabled: z.boolean().default(false),
947
+ /** Lets `searchRegistries` search the services the agent sees. */
847
948
  serviceRegistryEnabled: z.boolean().default(false),
848
949
  attachmentsEnabled: z.boolean().default(true),
849
950
  /** Hosts the built-in `fetchImage` tool may pull images from; none, no such tool. */
@@ -903,6 +1004,7 @@ const manifestAgentSchema = z.strictObject({
903
1004
  settings: agentSettingsSchema.prefault({}),
904
1005
  suggestions: suggestionsSchema.default({ en: [], ar: [] }),
905
1006
  greeting: greetingSchema.default({ en: "", ar: "" }),
1007
+ overridable: overridable(["name", "prompt", "models", "settings", "suggestions", "greeting"]),
906
1008
  });
907
1009
  export type ManifestAgent = z.input<typeof manifestAgentSchema>;
908
1010
 
@@ -923,7 +1025,9 @@ export const CORTEX_MANIFEST_ID = "cortex";
923
1025
  /**
924
1026
  * A deployment's agents and components, owned by the manifest `id`: an apply
925
1027
  * of it creates what is new, updates what changed, and deletes what it no
926
- * longer lists, touching nothing it did not create.
1028
+ * longer lists, touching nothing it did not create. What an entry lists as
1029
+ * `overridable` may then change in Cortex too, and an apply keeps that
1030
+ * change unless the manifest changed the same field.
927
1031
  */
928
1032
  export const manifestSchema = z.strictObject({
929
1033
  /** Fixed for the deployment it describes: the rows an apply owns carry it. */
@@ -942,14 +1046,11 @@ export const manifestSchema = z.strictObject({
942
1046
  /** A manifest as written: every field with a default may be left out. */
943
1047
  export type Manifest = z.input<typeof manifestSchema>;
944
1048
 
945
- /**
946
- * Entry names per kind: agents by slug, services by key, everything else by
947
- * name. An OpenAPI source's operations are counted among `tools`.
948
- */
949
- const namesByKindSchema = z.record(
950
- manifestSchema.keyof().exclude(["id", "openapi"]),
951
- z.array(z.string()),
952
- );
1049
+ /** The kinds of row a manifest owns; an OpenAPI source's operations are `tools`. */
1050
+ const manifestKindSchema = manifestSchema.keyof().exclude(["id", "openapi"]);
1051
+
1052
+ /** Entry names per kind: agents by slug, services by key, everything else by name. */
1053
+ const namesByKindSchema = z.record(manifestKindSchema, z.array(z.string()));
953
1054
 
954
1055
  /** What an apply creates, updates and deletes, kind by kind; a preview answers the same without doing it. */
955
1056
  export const manifestChangesSchema = z.object({
@@ -958,6 +1059,32 @@ export const manifestChangesSchema = z.object({
958
1059
  delete: namesByKindSchema,
959
1060
  });
960
1061
 
1062
+ /** A field of one entry that both the manifest and a live write changed since the last apply. */
1063
+ const manifestConflictSchema = z.strictObject({
1064
+ kind: manifestKindSchema,
1065
+ name: z.string(),
1066
+ field: z.string(),
1067
+ });
1068
+
1069
+ /** Which value a conflict keeps: the one changed live in Cortex, or the manifest's. */
1070
+ const conflictSideSchema = z.enum(["live", "manifest"]);
1071
+
1072
+ /**
1073
+ * What preview and sync take: the manifest, and how its conflicts resolve.
1074
+ * A conflict takes its own entry in `resolutions`, else `prefer`; a sync
1075
+ * left with one neither answers is refused as `unresolved_conflicts`,
1076
+ * naming them. A resolution for a field that no longer conflicts is ignored.
1077
+ */
1078
+ export const manifestApplyInputSchema = manifestSchema.extend({
1079
+ prefer: conflictSideSchema.optional(),
1080
+ resolutions: z.array(manifestConflictSchema.extend({ keep: conflictSideSchema })).default([]),
1081
+ });
1082
+
1083
+ /** What a preview answers: the changes, and the conflicts nothing resolved. */
1084
+ export const manifestPreviewSchema = manifestChangesSchema.extend({
1085
+ conflicts: z.array(manifestConflictSchema),
1086
+ });
1087
+
961
1088
  /**
962
1089
  * An apply's changes, and a fire secret, shown this once, for each trigger
963
1090
  * it left with none anyone holds: one it created, or one whose
@@ -97,10 +97,8 @@ export const BUILT_IN_TOOL_NAMES = [
97
97
  "queryDataset",
98
98
  "readAttachment",
99
99
  "readReport",
100
- "searchKnowledge",
101
- "searchServices",
100
+ "searchRegistries",
102
101
  "searchThreadHistory",
103
- "searchTools",
104
102
  REQUEST_LOCATION_TOOL,
105
103
  TRIGGER_ANSWER_TOOL_NAME,
106
104
  ] as const;
@@ -7,10 +7,8 @@
7
7
  "queryDataset",
8
8
  "readAttachment",
9
9
  "readReport",
10
- "searchKnowledge",
11
- "searchServices",
10
+ "searchRegistries",
12
11
  "searchThreadHistory",
13
- "searchTools",
14
12
  "requestLocation",
15
13
  "submitAnswer"
16
14
  ],
@@ -131,12 +131,23 @@ export type ClientToolBinding = {
131
131
  */
132
132
  export type AgentCapabilities = {
133
133
  attachments: boolean;
134
- /** False when the agent's `message` trigger is off: the widget hides its composer. */
134
+ /**
135
+ * False when this caller may not send to the chat: the agent's `message`
136
+ * trigger is off, or the caller only reads (`readOnly`). The widget hides
137
+ * its composer and answers no tool call.
138
+ */
135
139
  messages: boolean;
140
+ /** True when the caller only reads, as an operator inspecting a user's thread does. */
141
+ readOnly: boolean;
136
142
  /** Prompt chips on the blank-chat landing per locale; clicking one sends its `prompt` as the first message. */
137
143
  suggestions: Suggestions;
138
144
  /** Headline of the blank-chat landing per locale; "" falls back to the widget's built-in copy. */
139
145
  greeting: Greeting;
146
+ /**
147
+ * Whether this caller gets the debug view: tool inputs and outputs, reasoning,
148
+ * model calls. Cortex sends anyone else only what the widget's normal UI draws.
149
+ */
150
+ debug: boolean;
140
151
  };
141
152
 
142
153
  export type MessageMetadata = {
@@ -178,6 +189,13 @@ export const STATEMENT_TOOLS = ["executeCode", "queryDataset", "describeDataset"
178
189
  */
179
190
  export const APPROVAL_TITLE_FIELD = "approvalTitle";
180
191
 
192
+ /** The title the model wrote into an approval call's input, or undefined while it has none. */
193
+ export function approvalTitleOf(input: unknown) {
194
+ if (!input || typeof input !== "object" || !(APPROVAL_TITLE_FIELD in input)) return undefined;
195
+ const title: unknown = input[APPROVAL_TITLE_FIELD];
196
+ return typeof title === "string" && title.trim() ? title.trim() : undefined;
197
+ }
198
+
181
199
  /**
182
200
  * What Cortex stamps on a tool call's `metadata.cortex` from its first chunk,
183
201
  * so the live part and the stored one agree.
@@ -331,3 +349,19 @@ export type WsEvent =
331
349
 
332
350
  /** The header the widget sends the browser's IANA time zone in; the turn reasons in that clock. */
333
351
  export const TIMEZONE_HEADER = "X-Cortex-Timezone";
352
+
353
+ /**
354
+ * The console operator's short-lived token, sent by the playground and the
355
+ * thread inspector on every request to an agent and by the fire and answer
356
+ * calls Cortex makes to a trigger's route. A verified one makes the operator the caller unless the
357
+ * request also carries an end-user token to test as, and marks the threads it
358
+ * opens as test threads. A WebSocket upgrade carries it as `?operator=`.
359
+ */
360
+ export const OPERATOR_HEADER = "X-Cortex-Operator";
361
+
362
+ /**
363
+ * Beside the operator token, the id of a thread the operator inspects: the
364
+ * request then reads as that thread's owner, read-only, with the debug view.
365
+ * A WebSocket upgrade carries it as `?inspect=`.
366
+ */
367
+ export const INSPECT_HEADER = "X-Cortex-Inspect";
@@ -27,7 +27,7 @@ import { applyPorts, type Ports } from "@/scaffold/ports";
27
27
  * `@m6d/cortex` range below are stamped by scripts/sync-package-versions.ts
28
28
  * before the CLI publishes, so a scaffold pins what was current then.
29
29
  */
30
- const CORTEX_VERSION = "0.14.1";
30
+ const CORTEX_VERSION = "0.15.0";
31
31
 
32
32
  /** This machine as Cortex's containers name it: the one private host the stack's egress policy admits. */
33
33
  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.2.0",
153
+ "@m6d/cortex": "~0.3.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",
package/src/ui/prompts.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  log,
15
15
  outro,
16
16
  password,
17
+ select,
17
18
  spinner,
18
19
  text,
19
20
  } from "@clack/prompts";
@@ -50,6 +51,14 @@ export function askName(check: (name: string) => string | undefined) {
50
51
  );
51
52
  }
52
53
 
54
+ /** One of `options`, by its value; the first is the default. */
55
+ export function askChoice<const TValue extends string>(
56
+ message: string,
57
+ options: Parameters<typeof select<TValue>>[0]["options"],
58
+ ) {
59
+ return orExit(select({ message, options }));
60
+ }
61
+
53
62
  /** A yes-or-no question a flag left open, answered no unless the caller says otherwise. */
54
63
  export function askConfirm(message: string, initialValue = false) {
55
64
  return orExit(confirm({ message, initialValue }));