@m6d/cortex-cli 0.12.1 → 0.13.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.
Files changed (74) hide show
  1. package/README.md +135 -130
  2. package/package.json +16 -5
  3. package/src/cli.ts +17 -12
  4. package/src/commands/apply.ts +169 -0
  5. package/src/commands/{cc.ts → management.ts} +150 -100
  6. package/src/commands/new.ts +50 -74
  7. package/src/contracts/charts/chart-spec.ts +1 -1
  8. package/src/contracts/client-tools/index.ts +3 -3
  9. package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
  10. package/src/contracts/datasets/index.ts +3 -0
  11. package/src/contracts/egress-policy/index.ts +16 -0
  12. package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
  13. package/src/contracts/{egress → egress-policy}/url.ts +22 -9
  14. package/src/contracts/errors/index.ts +38 -0
  15. package/src/contracts/json/index.ts +5 -0
  16. package/src/contracts/manifest/index.ts +932 -0
  17. package/src/contracts/manifest-file/index.ts +16 -0
  18. package/src/contracts/markdown/index.ts +1 -1
  19. package/src/contracts/pattern/index.ts +276 -0
  20. package/src/contracts/server/index.ts +247 -0
  21. package/src/contracts/server/reserved-names.fixtures.json +18 -0
  22. package/src/contracts/triggers/index.ts +112 -0
  23. package/src/contracts/wire/index.ts +61 -32
  24. package/src/{cc → management}/client.ts +19 -7
  25. package/src/{cc → management}/flags.ts +2 -1
  26. package/src/{cc → management}/profiles.ts +5 -5
  27. package/src/scaffold/files.ts +362 -237
  28. package/src/scaffold/ports.ts +13 -24
  29. package/src/scaffold/templates/datasets.ts.txt +52 -0
  30. package/src/scaffold/templates/dockerignore.txt +7 -0
  31. package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
  32. package/src/scaffold/templates/gitignore.txt +2 -1
  33. package/src/scaffold/templates/server.ts.txt +40 -0
  34. package/src/scaffold/templates/setup.ts.txt +52 -0
  35. package/src/ui/prompts.ts +4 -31
  36. package/src/ui/report.ts +5 -15
  37. package/tsconfig.json +2 -3
  38. package/src/commands/graph.ts +0 -228
  39. package/src/commands/swagger.ts +0 -247
  40. package/src/config/load.ts +0 -122
  41. package/src/config/validate.ts +0 -46
  42. package/src/contracts/credentials/index.ts +0 -183
  43. package/src/contracts/egress/index.ts +0 -120
  44. package/src/contracts/graph/clients/embed.ts +0 -62
  45. package/src/contracts/graph/clients/neo4j.ts +0 -85
  46. package/src/contracts/graph/helpers.ts +0 -55
  47. package/src/contracts/graph/index.ts +0 -30
  48. package/src/contracts/graph/schema.ts +0 -65
  49. package/src/contracts/graph/types.ts +0 -125
  50. package/src/contracts/rich-text/index.ts +0 -220
  51. package/src/contracts/runtime/api.ts +0 -345
  52. package/src/contracts/runtime/auth.ts +0 -41
  53. package/src/contracts/runtime/grammar.ts +0 -169
  54. package/src/contracts/runtime/index.ts +0 -15
  55. package/src/contracts/runtime/models.ts +0 -50
  56. package/src/contracts/runtime/signature.ts +0 -255
  57. package/src/contracts/runtime/sync.ts +0 -196
  58. package/src/contracts/runtime/triggers.ts +0 -267
  59. package/src/graph/expand-domains.ts +0 -286
  60. package/src/graph/generate-cypher.ts +0 -201
  61. package/src/graph/seed.ts +0 -199
  62. package/src/graph/validate.ts +0 -78
  63. package/src/scaffold/features.ts +0 -190
  64. package/src/scaffold/templates/domains-README.md.txt +0 -16
  65. package/src/scaffold/templates/index.ts.txt +0 -18
  66. package/src/swagger/extract-endpoints.ts +0 -269
  67. package/src/swagger/generated-block.ts +0 -74
  68. package/src/swagger/openapi-schema.ts +0 -168
  69. package/src/swagger/parse-response.ts +0 -88
  70. package/src/swagger/types.ts +0 -25
  71. /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
  72. /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
  73. /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
  74. /package/src/{cc → management}/output.ts +0 -0
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Host ports for the services a generated project runs, chosen once at scaffold
3
- * time.
2
+ * Host ports for the Cortex stack a generated project runs, chosen once at
3
+ * scaffold time.
4
4
  *
5
- * The canonical ports — 5432, 6379, 9000/9001, 7474/7687 — are precisely the ones
6
- * a developer already running Postgres or Redis has bound, so a scaffold that
7
- * hardcoded them collides on its first `docker compose up -d`. Only the host side
8
- * moves: container ports stay canonical, so images, health checks and anything
9
- * inside the compose network are unaffected. The server's own port is not one of
10
- * these — it is the project's to choose, and stays the documented default.
5
+ * Only the app and the engine are published: the browser and this
6
+ * project's callers reach them, and nothing outside the stack reaches its
7
+ * databases. A fixed pair would collide with the next project scaffolded on
8
+ * the same machine, so each project draws its own. Container ports stay
9
+ * canonical. The backend's own port is not one of these: it is the project's
10
+ * to choose, and stays the documented default.
11
11
  *
12
12
  * Every generated file spells its ports `{{port.<name>}}` and gets them filled in
13
- * by `applyPorts`. That is the point of the token: compose and `.env` cannot
14
- * drift apart, because neither holds a number of its own.
13
+ * by `applyPorts`. That is the point of the token: compose and the README
14
+ * cannot drift apart, because neither holds a number of its own.
15
15
  */
16
16
 
17
17
  /** High, unprivileged, and clear of the ephemeral range macOS and Linux hand out. */
@@ -51,11 +51,7 @@ function allocate(taken: Set<number>) {
51
51
  return first ?? FIRST;
52
52
  }
53
53
 
54
- /**
55
- * Every port a generated project can need, allocated together so they are
56
- * distinct within the project. Features that were not selected simply never
57
- * mention theirs.
58
- */
54
+ /** Every port a generated project publishes, allocated together so they are distinct. */
59
55
  export function allocatePorts() {
60
56
  const taken = new Set<number>();
61
57
 
@@ -65,19 +61,12 @@ export function allocatePorts() {
65
61
  return port;
66
62
  }
67
63
 
68
- return {
69
- postgres: next(),
70
- redis: next(),
71
- minio: next(),
72
- minioConsole: next(),
73
- neo4j: next(),
74
- neo4jBolt: next(),
75
- };
64
+ return { cortex: next(), engine: next() };
76
65
  }
77
66
 
78
67
  export type Ports = ReturnType<typeof allocatePorts>;
79
68
 
80
- /** Fills in `{{port.postgres}}` and friends. Applied to every file, once, at the end. */
69
+ /** Fills in `{{port.cortex}}` and `{{port.engine}}`. Applied to every file, once, at the end. */
81
70
  export function applyPorts(contents: string, ports: Ports) {
82
71
  return Object.entries(ports).reduce(
83
72
  (text, [name, port]) => text.replaceAll(`{{port.${name}}}`, String(port)),
@@ -0,0 +1,52 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { defineDatasetHost } from "@m6d/cortex";
3
+ import { drizzleDataset } from "@m6d/cortex/drizzle";
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+ import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
6
+
7
+ /*
8
+ * This backend as a dataset host: an `orders` table agents ask grouped
9
+ * questions of, such as revenue per product by month. It lives in memory and
10
+ * is seeded on every boot; point `db` and the table at your own database.
11
+ * The host answers whoever reaches its URL, so `resolveUser` is where yours
12
+ * verifies the token Cortex forwards, and `scoped` narrows rows to that user.
13
+ */
14
+
15
+ const orders = sqliteTable("orders", {
16
+ id: integer().primaryKey(),
17
+ product: text().notNull(),
18
+ quantity: integer().notNull(),
19
+ total: real().notNull(),
20
+ /** ISO text, which the dataset reads as a date. */
21
+ placedAt: text().notNull(),
22
+ });
23
+
24
+ const sqlite = new Database(":memory:");
25
+ sqlite.run(
26
+ "create table orders (id integer primary key, product text not null, quantity integer not null, total real not null, placed_at text not null)",
27
+ );
28
+ const db = drizzle({ client: sqlite, casing: "snake_case" });
29
+ db.insert(orders)
30
+ .values([
31
+ { product: "Notebook", quantity: 3, total: 12, placedAt: "2026-01-05" },
32
+ { product: "Pen", quantity: 10, total: 15, placedAt: "2026-01-19" },
33
+ { product: "Notebook", quantity: 1, total: 4, placedAt: "2026-02-02" },
34
+ { product: "Backpack", quantity: 1, total: 49, placedAt: "2026-02-14" },
35
+ { product: "Pen", quantity: 4, total: 6, placedAt: "2026-03-08" },
36
+ ])
37
+ .run();
38
+
39
+ export const datasets = defineDatasetHost({
40
+ expose: [
41
+ drizzleDataset({
42
+ name: "orders",
43
+ description: "One row per order: what was bought, how many, for how much, and when.",
44
+ table: orders,
45
+ dialect: "sqlite",
46
+ db,
47
+ types: { placedAt: "date" },
48
+ scoped: () => undefined,
49
+ }),
50
+ ],
51
+ resolveUser: () => ({}),
52
+ });
@@ -0,0 +1,7 @@
1
+ # The env files never go into an image: they hold this checkout's secrets.
2
+ # With their example copies left out too, setup.ts writes none during the
3
+ # image's own `bun install`.
4
+ .env*
5
+ cortex.env*
6
+ node_modules/
7
+ .git/
@@ -1,19 +1,15 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
3
3
 
4
- // The single root everything else hangs off. Left to auto-detection, framework
5
- // heuristics mark far too much as reachable and unused exports stop being
6
- // reportable at all.
7
- "entry": ["index.ts"],
4
+ // The roots everything else hangs off: the backend, the manifest
5
+ // `cortex apply` loads, and the env setup `bun install` runs. Left to
6
+ // auto-detection, framework heuristics mark far too much as reachable and
7
+ // unused exports stop being reportable.
8
+ "entry": ["index.ts", "cortex.manifest.ts", "setup.ts"],
8
9
 
9
- // These are @m6d/cortex-server's peer dependencies. The server needs them
10
- // resolvable and it is this package.json's job to install them, but nothing
11
- // here imports them until you write the code that does — a tool schema in
12
- // zod, an extra route on `server.app`. A requirement that lives in another
13
- // package's `peerDependencies` is not something reachability analysis can
14
- // see, so it is stated here instead. Once your own code imports one, its
15
- // entry is doing nothing and can go.
16
- "ignoreDependencies": ["@tanstack/ai", "@tanstack/ai-openai", "hono", "jose", "openai", "zod"],
10
+ // The CLI is run as `cortex`, a binary nothing here imports, so
11
+ // reachability analysis cannot see that the project uses it.
12
+ "ignoreDependencies": ["@m6d/cortex-cli"],
17
13
 
18
14
  "duplicates": {
19
15
  // fallow's default is 0, which means "no limit": clone groups get printed
@@ -1,8 +1,9 @@
1
1
  # Dependencies
2
2
  node_modules/
3
3
 
4
- # Environment variables — .env.example is the committed one
4
+ # Environment variables: the .example files are the committed ones
5
5
  .env
6
+ cortex.env
6
7
  .env.local
7
8
  .env.*.local
8
9
 
@@ -0,0 +1,40 @@
1
+ import { defineServer } from "@m6d/cortex";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * This backend as a Cortex server: the variable its agents' prompts read,
6
+ * and the native tool it runs for them. Cortex presents CORTEX_SERVER_KEY as
7
+ * the bearer of every call, and nothing below runs for any other.
8
+ */
9
+ export const server = defineServer({
10
+ key: process.env["CORTEX_SERVER_KEY"]!,
11
+ variables: {
12
+ // Resolved once per thread; prompts read it as {{userName}}.
13
+ userName: {
14
+ description: "Display name of the signed-in user",
15
+ example: "Sara",
16
+ type: "string",
17
+ resolve: ({ user }) => {
18
+ const name = user?.claims["name"];
19
+ return typeof name === "string" ? name : "guest";
20
+ },
21
+ },
22
+ },
23
+ tools: {
24
+ rollDice: {
25
+ description: "Roll dice and report each face and the total.",
26
+ input: z.object({
27
+ count: z.number().int().min(1).max(10).describe("How many dice to roll"),
28
+ sides: z.number().int().min(2).max(100).default(6).describe("Faces per die"),
29
+ }),
30
+ readOnly: true,
31
+ run: ({ count, sides }) => {
32
+ const faces = Array.from(
33
+ { length: count },
34
+ () => 1 + Math.floor(Math.random() * sides),
35
+ );
36
+ return { faces, total: faces.reduce((sum, face) => sum + face, 0) };
37
+ },
38
+ },
39
+ },
40
+ });
@@ -0,0 +1,52 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
3
+
4
+ /*
5
+ * Writes .env and cortex.env on every `bun install`: each starts from its
6
+ * committed .example copy, and each value the copy leaves blank is
7
+ * generated here, the Compose project this checkout's stack runs as among
8
+ * them. A value already set is never changed, so a fresh clone gets a stack
9
+ * and secrets of its own, and a rerun changes nothing. The one exception: a
10
+ * cortex.env written now holds new keys, which cannot read the volumes of the
11
+ * stack .env names, so the stack gets a new name too. Without the example
12
+ * copies, as in an image build, it writes nothing.
13
+ */
14
+
15
+ const token = () => randomBytes(32).toString("base64url");
16
+ const key = () => randomBytes(32).toString("base64");
17
+
18
+ /** The package name as Compose takes a project name: lowercase words and hyphens. */
19
+ const { name } = JSON.parse(readFileSync("package.json", "utf8")) as { name: string };
20
+ const project =
21
+ name
22
+ .replace(/[^a-z0-9]+/g, "-")
23
+ .slice(0, 40)
24
+ .replace(/^-+|-+$/g, "") || "cortex";
25
+
26
+ const GENERATED = [
27
+ [".env", "COMPOSE_PROJECT_NAME", () => `${project}-${randomBytes(4).toString("hex")}`],
28
+ [".env", "CORTEX_SERVER_KEY", token],
29
+ ["cortex.env", "DEFAULT_ADMIN_PASSWORD", token],
30
+ // Cortex takes management keys only under this prefix. cspell:ignore mgmt
31
+ ["cortex.env", "BOOTSTRAP_MANAGEMENT_KEY", () => `cortex_mgmt_${token()}`],
32
+ ["cortex.env", "BETTER_AUTH_SECRET", key],
33
+ ["cortex.env", "SECRETS_MASTER_KEY", key],
34
+ ] as const;
35
+
36
+ const freshKeys = !existsSync("cortex.env") && existsSync("cortex.env.example");
37
+
38
+ for (const file of [".env", "cortex.env"]) {
39
+ const source = existsSync(file) ? file : `${file}.example`;
40
+ if (!existsSync(source)) continue;
41
+ // Only its owner reads a file of secrets, however it was first created.
42
+ if (source === file) chmodSync(file, 0o600);
43
+ let text = readFileSync(source, "utf8");
44
+ if (file === ".env" && freshKeys) {
45
+ text = text.replace(/^COMPOSE_PROJECT_NAME=.*$/m, "COMPOSE_PROJECT_NAME=");
46
+ }
47
+ for (const [target, variable, generate] of GENERATED) {
48
+ if (target !== file) continue;
49
+ text = text.replace(new RegExp(`^${variable}=$`, "m"), `${variable}=${generate()}`);
50
+ }
51
+ writeFileSync(file, text, { mode: 0o600 });
52
+ }
package/src/ui/prompts.ts CHANGED
@@ -8,20 +8,16 @@
8
8
 
9
9
  import {
10
10
  cancel,
11
+ confirm,
11
12
  intro,
12
13
  isCancel,
13
14
  log,
14
- multiselect,
15
15
  outro,
16
16
  password,
17
- select,
18
17
  spinner,
19
18
  text,
20
19
  } from "@clack/prompts";
21
20
 
22
- import { FEATURES, type Feature } from "@/scaffold/features";
23
- import type { Database } from "@/scaffold/files";
24
-
25
21
  export { intro, log, outro };
26
22
 
27
23
  async function orExit<T>(answer: Promise<T | symbol>) {
@@ -54,32 +50,9 @@ export function askName(check: (name: string) => string | undefined) {
54
50
  );
55
51
  }
56
52
 
57
- /**
58
- * The one structural pick: it changes a `cortex.config.ts` line and decides
59
- * whether the generated compose file has a database service at all.
60
- */
61
- export function askDatabase() {
62
- return orExit(
63
- select<Database>({
64
- message: "Database",
65
- initialValue: "postgres",
66
- options: [
67
- { value: "postgres", hint: "docker-compose.yml runs one for you" },
68
- { value: "mssql", hint: "normally an existing external instance" },
69
- ],
70
- }),
71
- );
72
- }
73
-
74
- /** One multi-select for the whole catalogue — minus anything a flag settled. */
75
- export function askFeatures(undecided: Feature[]) {
76
- return orExit(
77
- multiselect<Feature>({
78
- message: "Which optional features?",
79
- required: false,
80
- options: undecided.map((feature) => ({ value: feature, hint: FEATURES[feature].hint })),
81
- }),
82
- );
53
+ /** A yes-or-no question a flag left open, answered no unless the user says otherwise. */
54
+ export function askConfirm(message: string) {
55
+ return orExit(confirm({ message, initialValue: false }));
83
56
  }
84
57
 
85
58
  /** A required value a command was not given: asked by name, re-asked while empty. */
package/src/ui/report.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The output grammar, shared by every command that runs against a project.
2
+ * The output grammar, shared by every command.
3
3
  * The report layout is deliberately frozen; this file is the only
4
4
  * place that knows the box characters.
5
5
  */
@@ -31,12 +31,7 @@ function describe(error: unknown) {
31
31
  // anything opened the box above it.
32
32
  let open = false;
33
33
 
34
- /** `1 node` / `3 nodes` — every count the box prints goes through this. */
35
- export function plural(count: number, noun: string) {
36
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
37
- }
38
-
39
- /** `┌ cortex graph seed` — opens a run. */
34
+ /** `┌ cortex login` — opens a run. */
40
35
  export function header(title: string) {
41
36
  open = true;
42
37
  write(process.stdout, ["", `${pc.gray("┌")} ${title}`, BAR]);
@@ -50,20 +45,15 @@ export function input(label: string, value: string) {
50
45
  write(process.stdout, [`${pc.green("◇")} ${label.padEnd(9)} ${value}`]);
51
46
  }
52
47
 
53
- export function blank() {
54
- write(process.stdout, [BAR]);
55
- }
56
-
57
48
  /**
58
- * `│ updated leaves/listLeaves.endpoint.ts` — a row in the body. An empty
59
- * label indents to the same gutter, for the second line a finding gets. `width`
60
- * is the label column: findings fit in 10, domain names need 14.
49
+ * `│ name orders` — a row in the body. An empty label indents to the same
50
+ * gutter, for a continuation line. `width` is the label column.
61
51
  */
62
52
  export function row(label: string, text: string, width = 10) {
63
53
  write(process.stdout, [`${BAR} ${label.padEnd(width - 2)} ${text}`]);
64
54
  }
65
55
 
66
- /** `└ 87 nodes · 124 edges (6.2s)` — closes a run. */
56
+ /** `└ authenticated with ada@example.com` — closes a run. */
67
57
  export function footer(text: string) {
68
58
  write(process.stdout, [`${pc.gray("└")} ${text}`, ""]);
69
59
  }
package/tsconfig.json CHANGED
@@ -5,8 +5,7 @@
5
5
  // Environment setup & latest features
6
6
  "lib": ["ESNext"],
7
7
  // Named explicitly: auto-inclusion of @types/* is an editor-dependent
8
- // heuristic, and `Bun`/`Response` come from here. See @cortex/contracts'
9
- // tsconfig for the same fix.
8
+ // heuristic, and `Bun`/`Response` come from here.
10
9
  "types": ["bun"],
11
10
  "target": "ESNext",
12
11
  "module": "Preserve",
@@ -18,7 +17,7 @@
18
17
  "resolveJsonModule": true,
19
18
  "noEmit": true,
20
19
  "paths": {
21
- // The graph contract is @cortex/contracts, a private workspace package
20
+ // @cortex/contracts is a private workspace package
22
21
  // that never publishes, so the CLI carries its own copy: `prepack`
23
22
  // vendors it into src/contracts, where the first candidate finds it in
24
23
  // the published tarball. Inside the repo that copy doesn't exist and
@@ -1,228 +0,0 @@
1
- import type { Command } from "@commander-js/extra-typings";
2
- import {
3
- GRAPH_SCHEMA,
4
- GRAPH_SCHEMA_VERSION,
5
- createEmbedder,
6
- createNeo4jClient,
7
- type DomainDef,
8
- type EmbeddingProviderConfig,
9
- type Neo4jClient,
10
- type Neo4jConfig,
11
- } from "@cortex/contracts/graph";
12
- import pc from "picocolors";
13
-
14
- import { configLabel, configOption, openProject } from "@/config/load";
15
- import { seedGraph } from "@/graph/seed";
16
- import * as report from "@/ui/report";
17
-
18
- const TITLE = "cortex graph seed";
19
-
20
- /** Domain names are longer than swagger's finding labels, so the body is wider. */
21
- const DOMAIN_COLUMN = 14;
22
-
23
- /** A failing statement is worth one line: enough to identify, not enough to drown. */
24
- const DETAIL_WIDTH = 60;
25
-
26
- /** Only the parts of a loaded config `graph seed` reads. */
27
- type ProjectConfig = {
28
- neo4j?: Neo4jConfig;
29
- embedding?: EmbeddingProviderConfig & { dimension: number };
30
- knowledge?: { domains?: Record<string, DomainDef> };
31
- agents?: Record<string, { knowledge?: { domains?: Record<string, DomainDef> } | null }>;
32
- };
33
-
34
- // Connection failures reach us as whatever `fetch` threw, so match on shape
35
- // rather than on a driver's error class.
36
- const UNREACHABLE =
37
- /unable to connect|connection refused|econnrefused|failed to fetch|fetch failed|enotfound|getaddrinfo|timed out/i;
38
-
39
- export function registerGraph(program: Command) {
40
- const graph = program
41
- .command("graph")
42
- .helpGroup("Resources:")
43
- .summary("The Neo4j knowledge graph")
44
- .description(
45
- "The Neo4j knowledge graph.\n\nReads `knowledge.domains` and `neo4j` from your cortex.config.ts.",
46
- )
47
- .addOption(configOption())
48
- .helpCommand(false);
49
-
50
- // No flags. `--domain <name>` is flag-shaped design work, not a flag:
51
- // `expandDomains` resolves cross-domain references and the node pass creates every
52
- // node before any edge, so a subset seed risks dangling edges.
53
- graph
54
- .command("seed")
55
- .description("Load concepts, endpoints, services, and rules into Neo4j")
56
- .action(async function () {
57
- await seed(graph);
58
- });
59
- }
60
-
61
- async function seed(group: Command) {
62
- const { config: loaded, configPath, verbose } = await openProject(group, TITLE);
63
- const config = loaded as ProjectConfig;
64
-
65
- // Inputs are reported as they resolve. With no domains there is nothing to
66
- // seed; an absent neo4j or embedding section is `runSeed`'s error to throw.
67
- if (config.neo4j?.url) report.input("Neo4j", config.neo4j.url);
68
- const domains = collectDomains(config);
69
- const names = Object.keys(domains);
70
- if (!names.length) failMissingDomains(configPath);
71
- report.input("Domains", `${names.length} — ${names.join(", ")}`);
72
-
73
- const startedAt = Date.now();
74
- const result = await runSeed(config, domains).catch(function (error: unknown): never {
75
- return failSeed({ error, config, configPath, verbose });
76
- });
77
-
78
- // A domain is the unit the user edits, so it is the unit the report counts
79
- // against: a wrong number next to `attendance` points at src/domains/attendance/.
80
- report.blank();
81
- for (const [name, domain] of Object.entries(result.domains)) {
82
- const counts = [report.plural(domain.nodes, "node"), report.plural(domain.edges, "edge")];
83
- if (domain.failed) counts.push(`${domain.failed} failed`);
84
- report.row(name, counts.join(" · "), DOMAIN_COLUMN);
85
-
86
- // The first failure only: enough to identify what broke, not enough to
87
- // drown the run — the count above says how many followed it.
88
- const failure = domain.failures[0];
89
- if (failure) {
90
- report.row("", `${pc.red("✗")} ${truncate(failure.error)}`, DOMAIN_COLUMN);
91
- report.row("", ` ${truncate(oneLine(failure.statement))}`, DOMAIN_COLUMN);
92
- }
93
- }
94
-
95
- // Embeddings are a second pass over the whole graph, so they get their own
96
- // step rather than a row among the counts.
97
- report.blank();
98
- report.input(
99
- "Embeddings",
100
- `${report.plural(result.embeddings.concepts, "concept")}, dimension ${result.embeddings.dimension}`,
101
- );
102
- report.blank();
103
-
104
- const counts = [report.plural(result.nodes, "node"), report.plural(result.edges, "edge")];
105
- if (result.failed) counts.push(`${result.failed} failed`);
106
- report.footer(`${counts.join(" · ")} (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
107
-
108
- if (result.failed) {
109
- report.note("The graph is partially seeded. Re-running is safe — seeding is MERGE-based.");
110
- process.exit(1);
111
- }
112
- }
113
-
114
- /** Cypher is generated multi-line and indented; a report row is neither. */
115
- function oneLine(statement: string) {
116
- return statement.replace(/\s+/g, " ").trim();
117
- }
118
-
119
- function truncate(text: string) {
120
- return text.length > DETAIL_WIDTH ? `${text.slice(0, DETAIL_WIDTH - 2)} …` : text;
121
- }
122
-
123
- /** Server level first, then per agent — an agent's domain of the same name wins. */
124
- function collectDomains(config: ProjectConfig) {
125
- const all = { ...config.knowledge?.domains };
126
-
127
- for (const agent of Object.values(config.agents ?? {})) {
128
- Object.assign(all, agent.knowledge?.domains);
129
- }
130
-
131
- return all;
132
- }
133
-
134
- /**
135
- * The whole pipeline is the CLI's, built on the graph contract it depends on:
136
- * `@cortex/contracts/graph` is where the vocabulary, the Neo4j client and the embedder
137
- * come from, and the version it seeds at is the version of that dependency.
138
- */
139
- async function runSeed(config: ProjectConfig, domains: Record<string, DomainDef>) {
140
- if (!config.neo4j) throw new Error("neo4j config is required for graph seed");
141
- if (!config.embedding) throw new Error("embedding config is required for graph seed");
142
-
143
- const embed = createEmbedder(config.embedding);
144
- const neo4j = createNeo4jClient(config.neo4j, embed);
145
-
146
- await warnOnRestamp(neo4j);
147
-
148
- return await seedGraph(
149
- {
150
- neo4j,
151
- embedding: { embed, dimension: config.embedding.dimension },
152
- schema: GRAPH_SCHEMA,
153
- schemaVersion: GRAPH_SCHEMA_VERSION,
154
- },
155
- domains,
156
- );
157
- }
158
-
159
- /**
160
- * Seeding is MERGE-based, so a graph written under an older schema is not cleaned
161
- * up — its nodes stay, under labels nothing reads. Worth saying out loud; not
162
- * worth blocking on, since re-seeding is exactly how that graph gets fixed.
163
- */
164
- async function warnOnRestamp(neo4j: Neo4jClient) {
165
- // An unreachable graph is the seed's to report, far better than a warning could.
166
- const rows = await neo4j
167
- .query(
168
- `MATCH (g:${GRAPH_SCHEMA.label.marker}) RETURN g.${GRAPH_SCHEMA.prop.version} AS version`,
169
- )
170
- .catch(() => undefined);
171
- if (!rows) return;
172
-
173
- const stamped = (rows as { version?: number }[])[0]?.version;
174
- if (stamped === undefined || stamped === GRAPH_SCHEMA_VERSION) return;
175
-
176
- report.blank();
177
- report.row(
178
- "",
179
- `${pc.yellow("!")} Graph was written with schema v${stamped}; restamping as v${GRAPH_SCHEMA_VERSION}.`,
180
- DOMAIN_COLUMN,
181
- );
182
- report.row("", " Nodes from the old schema are not removed.", DOMAIN_COLUMN);
183
- }
184
-
185
- function failMissingDomains(configPath: string): never {
186
- report.fail({
187
- what: `${configLabel(configPath)} has no knowledge.domains`,
188
- blocks: [
189
- "`graph seed` loads domain definitions into Neo4j. Add them:",
190
- " knowledge: { domains: { leaves: leavesDomain } }",
191
- "See https://github.com/boring91/cortex/blob/main/packages/cortex-server/README.md#file-structure-convention",
192
- ],
193
- });
194
- }
195
-
196
- /**
197
- * The neo4j and embedding sections are `runSeed`'s to require — it throws
198
- * `"neo4j config is required for graph seed"`. This renders what it threw.
199
- */
200
- function failSeed(options: {
201
- error: unknown;
202
- config: ProjectConfig;
203
- configPath: string;
204
- verbose: boolean;
205
- }): never {
206
- const { error, config, configPath, verbose } = options;
207
- const label = configLabel(configPath);
208
- const message = error instanceof Error ? error.message : String(error);
209
- const url = config.neo4j?.url;
210
- if (url !== undefined && UNREACHABLE.test(message)) {
211
- report.fail({
212
- what: `Cannot reach Neo4j at ${url}`,
213
- error,
214
- blocks: [
215
- "Is it running? docker compose up -d neo4j",
216
- `Otherwise check neo4j.url / neo4j.user / neo4j.password in\n${label}.`,
217
- ],
218
- verbose,
219
- });
220
- }
221
-
222
- report.fail({
223
- what: "Could not seed the graph",
224
- error,
225
- blocks: [`Check neo4j, embedding, and knowledge.domains in ${label}.`],
226
- verbose,
227
- });
228
- }