@m6d/cortex-cli 1.0.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 +135 -0
- package/bin/cortex.js +52 -0
- package/package.json +37 -0
- package/src/cli.ts +63 -0
- package/src/commands/graph.ts +253 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +285 -0
- package/src/config/load.ts +76 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/README.md +23 -0
- package/src/contracts/graph/embed.ts +50 -0
- package/src/contracts/graph/helpers.ts +63 -0
- package/src/contracts/graph/neo4j.ts +97 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +131 -0
- package/src/contracts/graph.ts +36 -0
- package/src/contracts/runtime.ts +208 -0
- package/src/contracts/wire.ts +143 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +227 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +189 -0
- package/src/scaffold/files.ts +568 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/swagger/extract-endpoints.ts +258 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +149 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +102 -0
- package/src/ui/report.ts +102 -0
- package/tsconfig.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @m6d/cortex-cli
|
|
2
|
+
|
|
3
|
+
The `cortex` command line: scaffold Cortex servers, and run the generators that otherwise need a
|
|
4
|
+
hand-written script.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
bunx @m6d/cortex-cli new my-app # from nowhere — scaffolds a project
|
|
8
|
+
bunx cortex swagger sync # inside a project — reads its cortex.config.ts
|
|
9
|
+
bunx cortex graph seed
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Commands are noun-first — `cortex <resource> <verb>` — except `new`, which acts on no existing
|
|
13
|
+
resource. Run `cortex --help`, then `cortex <resource> --help`.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add -d @m6d/cortex-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires [Bun](https://bun.sh). The binary re-execs itself under Bun when Node started it, and tells
|
|
20
|
+
you how to install Bun when it is genuinely absent.
|
|
21
|
+
|
|
22
|
+
## `cortex new`
|
|
23
|
+
|
|
24
|
+
Scaffolds `./<name>`: a standalone project with `cortex.config.ts`, `index.ts`, `.env` and
|
|
25
|
+
`.env.example`, inlined eslint/prettier/cspell/fallow config, and a `docker-compose.yml` when a
|
|
26
|
+
selection contributes a service. It then runs `bun install`, and `git init` unless the target is
|
|
27
|
+
already inside a work tree. An existing non-empty directory is an error.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bunx @m6d/cortex-cli new widget-backend --knowledge --redis
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
On a TTY it asks only what the flags left open — the name, the database, and one multi-select over
|
|
34
|
+
the undecided features. With no TTY it never prompts and behaves as `--yes`, so a missing name is a
|
|
35
|
+
hard error rather than a hang.
|
|
36
|
+
|
|
37
|
+
| Flag | Default | Writes |
|
|
38
|
+
| ------------------------------------------ | ---------- | ------------------------------------------------------------------------- |
|
|
39
|
+
| `--database <postgres\|mssql>` | `postgres` | The `database` block. `postgres` also gets a compose service; `mssql` not |
|
|
40
|
+
| `--attachments` / `--no-attachments` | off | `storage` + `vision` — MinIO uploads, described by a vision model |
|
|
41
|
+
| `--redis` / `--no-redis` | off | `redis` — resumable streams across restarts |
|
|
42
|
+
| `--auth` / `--no-auth` | off | `auth` — JWT verification via JWKS |
|
|
43
|
+
| `--knowledge` / `--no-knowledge` | off | `neo4j` + `embedding` + `knowledge`, plus a `src/domains/` tree |
|
|
44
|
+
| `--control-center` / `--no-control-center` | off | `controlCenter` |
|
|
45
|
+
| `--yes` | | Accept the defaults instead of asking |
|
|
46
|
+
|
|
47
|
+
Unselected features are still written to `cortex.config.ts`, commented out, alongside `fastModel`,
|
|
48
|
+
`reranker`, and `context` — the generated config documents what else exists. Every credential and
|
|
49
|
+
URL is a placeholder in `.env`; none of them is prompted for.
|
|
50
|
+
|
|
51
|
+
## `cortex swagger sync` / `check`
|
|
52
|
+
|
|
53
|
+
Fetches every Swagger URL the config names — server-level and per-agent, deduped — and reconciles
|
|
54
|
+
the `.endpoint.ts` files under the domains directory against it.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
bunx cortex swagger sync
|
|
58
|
+
bunx cortex swagger check
|
|
59
|
+
bunx cortex swagger sync --domains-dir ./server/src/domains
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`sync` rewrites each endpoint's params, body, and response schemas in place and prints only the
|
|
63
|
+
files it changed; unchanged ones live in the footer count. `check` writes nothing, reports the drift
|
|
64
|
+
it found, and exits 2 — which is what makes it a CI step.
|
|
65
|
+
|
|
66
|
+
An endpoint file pointing at a route the spec no longer serves is `MISSING`. `sync` prints those
|
|
67
|
+
last, so they are the thing you read, and still exits 0: writing is its job. `check` counts them as
|
|
68
|
+
a finding, same as drift.
|
|
69
|
+
|
|
70
|
+
`-d, --domains-dir <path>` is declared on the `swagger` group, so it works on either side of the
|
|
71
|
+
verb. It defaults to `src/domains`, resolved relative to the config file.
|
|
72
|
+
|
|
73
|
+
## `cortex graph seed`
|
|
74
|
+
|
|
75
|
+
Loads every domain the config declares — server-level `knowledge.domains` and per-agent ones,
|
|
76
|
+
deduped — and seeds Neo4j with the concepts, endpoints, services, and rules they declare, then
|
|
77
|
+
embeds them. Reports per domain, because a domain is the unit you edit. No
|
|
78
|
+
flags.
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
bunx cortex graph seed
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Seeding is `MERGE`-based, so re-running is safe. If any statement failed, the graph is partially
|
|
85
|
+
seeded and the command exits 1 — a half-seeded graph should not pass as success.
|
|
86
|
+
|
|
87
|
+
## Global options
|
|
88
|
+
|
|
89
|
+
| Option | Description |
|
|
90
|
+
| --------------------- | ----------------------------------------------------- |
|
|
91
|
+
| `-c, --config <path>` | Path to the config file. Default `./cortex.config.ts` |
|
|
92
|
+
| `-v, --verbose` | Print full errors and the commands being run |
|
|
93
|
+
| `-V, --version` | Output the version number |
|
|
94
|
+
| `-h, --help` | Display help for command |
|
|
95
|
+
|
|
96
|
+
Config discovery is that exact filename in the directory you run in, with no upward search. There is
|
|
97
|
+
no `--cwd`: Bun auto-loads `.env` relative to the process cwd before any of our code runs, so
|
|
98
|
+
reading another directory's config from here would pair it with the wrong environment. `cd` first,
|
|
99
|
+
or point `--config` at the file.
|
|
100
|
+
|
|
101
|
+
The config is inert data — a plain object literal the CLI imports. Any key that is present but
|
|
102
|
+
evaluates to `undefined`, usually a missing environment variable, is listed by path and stops the
|
|
103
|
+
run.
|
|
104
|
+
|
|
105
|
+
`graph seed` runs standalone: it never constructs or serves your `@m6d/cortex-server` — it only
|
|
106
|
+
imports your `cortex.config.ts` (whose own imports may pull the server's modules in, inertly).
|
|
107
|
+
Its Neo4j client, its embedder and its `GRAPH_SCHEMA` come from the graph contract the server
|
|
108
|
+
reads the graph back with (each package vendors its own copy at pack time; the server re-exports
|
|
109
|
+
it) — one contract, shared from both ends. Seeding stamps the schema
|
|
110
|
+
version it wrote onto the graph; if that graph already carries a different one, the CLI says so and
|
|
111
|
+
seeds anyway, noting that nodes from the old schema are not removed. Skew is caught on the reading
|
|
112
|
+
side, where it matters: the server checks the stamp on its first resolve and refuses a graph it
|
|
113
|
+
cannot read.
|
|
114
|
+
|
|
115
|
+
`swagger` writes `.endpoint.ts` source whose generated block has to satisfy the server's own
|
|
116
|
+
`EndpointDef`, so a codegen that outran the installed version is a loud type error in your
|
|
117
|
+
`bun run check`.
|
|
118
|
+
|
|
119
|
+
Your `cortex.config.ts` imports `@m6d/cortex-server` for `defineAgent`, so a project without it
|
|
120
|
+
installed fails on the import — reported as an unloadable config, with the missing package named.
|
|
121
|
+
|
|
122
|
+
## Exit codes
|
|
123
|
+
|
|
124
|
+
| Command | 0 | 1 | 2 |
|
|
125
|
+
| --------------- | --------------------------------------------- | ----------------------------------- | ---------------------- |
|
|
126
|
+
| `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled | — |
|
|
127
|
+
| `swagger sync` | Wrote what it could, `MISSING` files included | Couldn't run | — |
|
|
128
|
+
| `swagger check` | Every endpoint file matched and in sync | Couldn't run | Drift and/or `MISSING` |
|
|
129
|
+
| `graph seed` | Everything seeded | A statement failed, or couldn't run | — |
|
|
130
|
+
|
|
131
|
+
_Couldn't run_ means no config file, an unloadable config (a missing `@m6d/cortex-server` in the
|
|
132
|
+
project reads as one), undefined values in it, an absent domains directory, or an unreachable
|
|
133
|
+
Swagger host or Neo4j.
|
|
134
|
+
|
|
135
|
+
Design: [`docs/design/cortex-cli.md`](../../docs/design/cortex-cli.md).
|
package/bin/cortex.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Plain JavaScript, and a Node shebang, on purpose: `bunx` honours the shebang
|
|
4
|
+
// unless you pass `--bun`, so Node parses this file on the normal path. A
|
|
5
|
+
// `#!/usr/bin/env bun` shebang would give a user without Bun
|
|
6
|
+
// `env: bun: No such file or directory` — unrecoverable, because none of our
|
|
7
|
+
// code ever runs. See docs/design/cortex-cli.md §4.
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
if (process.versions.bun) {
|
|
13
|
+
await import("../src/cli");
|
|
14
|
+
} else {
|
|
15
|
+
const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
|
16
|
+
const args = [cli, ...process.argv.slice(2)];
|
|
17
|
+
let result = spawnSync("bun", args, { stdio: "inherit" });
|
|
18
|
+
|
|
19
|
+
// Node's CVE-2024-27980 fix refuses to spawn a `.cmd` file directly, which is
|
|
20
|
+
// exactly what an npm-installed Bun is on Windows: the user has Bun and still
|
|
21
|
+
// gets EINVAL. `shell: true` is the documented way through; every argument is
|
|
22
|
+
// quoted because cmd.exe splits on the spaces in a Windows path otherwise.
|
|
23
|
+
if (result.error?.code === "EINVAL" && process.platform === "win32") {
|
|
24
|
+
result = spawnSync("bun", args.map(quote), { stdio: "inherit", shell: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Any spawn error at all means Bun did not run, and the reason matters less
|
|
28
|
+
// than the fix — a raw `Error: spawnSync bun EACCES` helps nobody.
|
|
29
|
+
if (result.error) {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
"\n■ cortex needs Bun.\n" +
|
|
32
|
+
(result.error.code === "ENOENT" ? "" : `\n ${result.error.message}\n`) +
|
|
33
|
+
"\n curl -fsSL https://bun.sh/install | bash\n" +
|
|
34
|
+
"\n Then: bunx @m6d/cortex-cli new\n\n",
|
|
35
|
+
);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A signal-killed child reports `status: null`. Exiting 1 would turn the
|
|
40
|
+
// user's Ctrl-C into a generic failure, so the signal is re-raised on
|
|
41
|
+
// ourselves and the shell sees the 130/143 it expects. If the signal is
|
|
42
|
+
// ignored or blocked and we survive it, the exit below still runs.
|
|
43
|
+
if (result.signal) {
|
|
44
|
+
process.kill(process.pid, result.signal);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.exit(result.status ?? 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function quote(argument) {
|
|
51
|
+
return `"${argument}"`;
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@m6d/cortex-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Scaffold and operate Cortex servers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cortex": "./bin/cortex.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"tsconfig.json",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"check": "tsc --noEmit",
|
|
19
|
+
"prepack": "cp -R ../../internal/contracts src/contracts && rm src/contracts/package.json src/contracts/tsconfig.json",
|
|
20
|
+
"postpack": "rm -rf src/contracts",
|
|
21
|
+
"test": "bun test"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@clack/prompts": "^1.7.0",
|
|
25
|
+
"@commander-js/extra-typings": "^15.0.0",
|
|
26
|
+
"commander": "^15.0.0",
|
|
27
|
+
"picocolors": "^1.1.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@cortex/contracts": "workspace:*",
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"typescript": "~5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Command, Help, Option } from "@commander-js/extra-typings";
|
|
2
|
+
|
|
3
|
+
import pkg from "../package.json";
|
|
4
|
+
import { registerGraph } from "@/commands/graph";
|
|
5
|
+
import { registerNew } from "@/commands/new";
|
|
6
|
+
import { registerSwagger } from "@/commands/swagger";
|
|
7
|
+
import * as report from "@/ui/report";
|
|
8
|
+
|
|
9
|
+
// Discovery is this exact name in the cwd, with no upward walk (design spec §6.2),
|
|
10
|
+
// so the flag's default *is* the discovery rule. The second argument is what help
|
|
11
|
+
// prints — without it commander would JSON.stringify the path and show quotes.
|
|
12
|
+
const DEFAULT_CONFIG = "./cortex.config.ts";
|
|
13
|
+
|
|
14
|
+
const program = new Command()
|
|
15
|
+
.name("cortex")
|
|
16
|
+
.description("Scaffold and operate Cortex servers.")
|
|
17
|
+
.addOption(
|
|
18
|
+
new Option("-c, --config <path>", "Path to cortex.config.ts").default(
|
|
19
|
+
DEFAULT_CONFIG,
|
|
20
|
+
DEFAULT_CONFIG,
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
.option("-v, --verbose", "Print full errors and the commands being run")
|
|
24
|
+
.version(pkg.version, "-V, --version", "Output the version number")
|
|
25
|
+
.helpOption("-h, --help", "Display help for command")
|
|
26
|
+
// The implicit `help [command]` would print as a third, ungrouped section and
|
|
27
|
+
// break the two-group screen the prototype fixes. `cortex <cmd> --help` covers it.
|
|
28
|
+
.helpCommand(false)
|
|
29
|
+
// Commander appends ` [options]` to any command that has some. Left in, the
|
|
30
|
+
// first command to grow a flag widens the whole list and the two-group screen
|
|
31
|
+
// in cli-output.md §1 stops matching. `cortex <command> --help` lists them.
|
|
32
|
+
.configureHelp({
|
|
33
|
+
subcommandTerm: function (cmd) {
|
|
34
|
+
return new Help().subcommandTerm(cmd).replace(" [options]", "");
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
.addHelpText("after", "\nRun `cortex <resource> --help` to see what you can do with one.");
|
|
38
|
+
|
|
39
|
+
// Each command owns its help group, so the two headings survive these files being
|
|
40
|
+
// rewritten. Registration order fixes the order the groups print in.
|
|
41
|
+
registerNew(program);
|
|
42
|
+
registerSwagger(program);
|
|
43
|
+
registerGraph(program);
|
|
44
|
+
|
|
45
|
+
// Commander's own errors print and exit on their own, and every failure the
|
|
46
|
+
// commands predict goes through `report.fail`. Anything still reaching here is a
|
|
47
|
+
// bug — in the CLI, or in the project's server — and it gets the same grammar
|
|
48
|
+
// rather than a raw Bun stack dump.
|
|
49
|
+
await program.parseAsync().catch(function (error: unknown) {
|
|
50
|
+
const verbose = program.opts().verbose === true;
|
|
51
|
+
const rerun = process.argv.slice(2);
|
|
52
|
+
if (!verbose) rerun.push("--verbose");
|
|
53
|
+
|
|
54
|
+
report.fail({
|
|
55
|
+
what: "cortex hit an unexpected error",
|
|
56
|
+
error,
|
|
57
|
+
blocks: [
|
|
58
|
+
"That is a bug — the CLI should have explained this. Report it at\nhttps://github.com/boring91/cortex/issues with the output of:",
|
|
59
|
+
` cortex ${rerun.join(" ")}`,
|
|
60
|
+
],
|
|
61
|
+
verbose,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
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, findConfig, loadConfig } from "@/config/load";
|
|
15
|
+
import { validateConfig } from "@/config/validate";
|
|
16
|
+
import { seedGraph } from "@/graph/seed";
|
|
17
|
+
import * as report from "@/ui/report";
|
|
18
|
+
|
|
19
|
+
const TITLE = "cortex graph seed";
|
|
20
|
+
|
|
21
|
+
/** Domain names are longer than swagger's finding labels, so the body is wider. */
|
|
22
|
+
const DOMAIN_COLUMN = 14;
|
|
23
|
+
|
|
24
|
+
/** A failing statement is worth one line: enough to identify, not enough to drown. */
|
|
25
|
+
const DETAIL_WIDTH = 60;
|
|
26
|
+
|
|
27
|
+
/** Only the parts of a loaded config `graph seed` reads. */
|
|
28
|
+
type ProjectConfig = {
|
|
29
|
+
neo4j?: Neo4jConfig;
|
|
30
|
+
embedding?: EmbeddingProviderConfig & { dimension: number };
|
|
31
|
+
knowledge?: { domains?: Record<string, DomainDef> };
|
|
32
|
+
agents?: Record<string, { knowledge?: { domains?: Record<string, DomainDef> } | null }>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Connection failures reach us as whatever `fetch` threw, so match on shape
|
|
36
|
+
// rather than on a driver's error class.
|
|
37
|
+
const UNREACHABLE =
|
|
38
|
+
/unable to connect|connection refused|econnrefused|failed to fetch|fetch failed|enotfound|getaddrinfo|timed out/i;
|
|
39
|
+
|
|
40
|
+
export function registerGraph(program: Command) {
|
|
41
|
+
const graph = program
|
|
42
|
+
.command("graph")
|
|
43
|
+
.helpGroup("Resources:")
|
|
44
|
+
.summary("The Neo4j knowledge graph")
|
|
45
|
+
.description(
|
|
46
|
+
"The Neo4j knowledge graph.\n\nReads `knowledge.domains` and `neo4j` from your cortex.config.ts.",
|
|
47
|
+
)
|
|
48
|
+
.helpCommand(false);
|
|
49
|
+
|
|
50
|
+
// No flags. `--domain <name>` is flag-shaped design work, not a flag:
|
|
51
|
+
// `expandDomains` resolves cross-domain references and phase 1 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
|
+
// The globals live on the root command, which reaches this file
|
|
58
|
+
// untyped — `Command` here is the parameter type, not cli.ts's
|
|
59
|
+
// inferred one.
|
|
60
|
+
const { config: path, verbose = false } = program.opts() as {
|
|
61
|
+
config: string;
|
|
62
|
+
verbose?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await seed({
|
|
66
|
+
path,
|
|
67
|
+
explicit: program.getOptionValueSource("config") !== "default",
|
|
68
|
+
verbose,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function seed(options: { path: string; explicit: boolean; verbose: boolean }) {
|
|
74
|
+
const { verbose } = options;
|
|
75
|
+
|
|
76
|
+
// Discovery runs before the box opens: with no config there is no run to
|
|
77
|
+
// report on, only the reason there isn't one.
|
|
78
|
+
const configPath = findConfig({ ...options, command: TITLE });
|
|
79
|
+
|
|
80
|
+
report.header(TITLE);
|
|
81
|
+
const loaded = await loadConfig(configPath, verbose);
|
|
82
|
+
validateConfig(loaded, configPath);
|
|
83
|
+
report.input("Config", configLabel(configPath));
|
|
84
|
+
|
|
85
|
+
const config = loaded as ProjectConfig;
|
|
86
|
+
|
|
87
|
+
// Inputs are reported as they resolve, and only when the config has them:
|
|
88
|
+
// an absent section is `runSeed`'s error to throw, not ours to predict.
|
|
89
|
+
if (config.neo4j?.url) report.input("Neo4j", config.neo4j.url);
|
|
90
|
+
const domains = collectDomains(config);
|
|
91
|
+
const names = Object.keys(domains);
|
|
92
|
+
if (names.length) report.input("Domains", `${names.length} — ${names.join(", ")}`);
|
|
93
|
+
|
|
94
|
+
const startedAt = Date.now();
|
|
95
|
+
const result = await runSeed(config, domains).catch(function (error: unknown): never {
|
|
96
|
+
return failSeed({ error, config, configPath, verbose });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// A domain is the unit the user edits, so it is the unit the report counts
|
|
100
|
+
// against: a wrong number next to `attendance` points at src/domains/attendance/.
|
|
101
|
+
report.blank();
|
|
102
|
+
for (const [name, domain] of Object.entries(result.domains)) {
|
|
103
|
+
const counts = [report.plural(domain.nodes, "node"), report.plural(domain.edges, "edge")];
|
|
104
|
+
if (domain.failed) counts.push(`${domain.failed} failed`);
|
|
105
|
+
report.row(name, counts.join(" · "), DOMAIN_COLUMN);
|
|
106
|
+
|
|
107
|
+
// The first failure only: enough to identify what broke, not enough to
|
|
108
|
+
// drown the run — the count above says how many followed it.
|
|
109
|
+
const failure = domain.failures[0];
|
|
110
|
+
if (failure) {
|
|
111
|
+
report.row("", `${pc.red("✗")} ${truncate(failure.error)}`, DOMAIN_COLUMN);
|
|
112
|
+
report.row("", ` ${truncate(oneLine(failure.statement))}`, DOMAIN_COLUMN);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Embeddings are a second pass over the whole graph, so they get their own
|
|
117
|
+
// step rather than a row among the counts.
|
|
118
|
+
report.blank();
|
|
119
|
+
report.input(
|
|
120
|
+
"Embeddings",
|
|
121
|
+
`${report.plural(result.embeddings.concepts, "concept")}, dimension ${result.embeddings.dimension}`,
|
|
122
|
+
);
|
|
123
|
+
report.blank();
|
|
124
|
+
|
|
125
|
+
const counts = [report.plural(result.nodes, "node"), report.plural(result.edges, "edge")];
|
|
126
|
+
if (result.failed) counts.push(`${result.failed} failed`);
|
|
127
|
+
report.footer(`${counts.join(" · ")} (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
|
|
128
|
+
|
|
129
|
+
if (result.failed) {
|
|
130
|
+
report.note("The graph is partially seeded. Re-running is safe — seeding is MERGE-based.");
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Cypher is generated multi-line and indented; a report row is neither. */
|
|
136
|
+
function oneLine(statement: string) {
|
|
137
|
+
return statement.replace(/\s+/g, " ").trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function truncate(text: string) {
|
|
141
|
+
return text.length > DETAIL_WIDTH ? `${text.slice(0, DETAIL_WIDTH - 2)} …` : text;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Server level first, then per agent — an agent's domain of the same name wins. */
|
|
145
|
+
function collectDomains(config: ProjectConfig) {
|
|
146
|
+
const all: Record<string, DomainDef> = { ...config.knowledge?.domains };
|
|
147
|
+
|
|
148
|
+
for (const agent of Object.values(config.agents ?? {})) {
|
|
149
|
+
Object.assign(all, agent.knowledge?.domains);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return all;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The whole pipeline is the CLI's, built on the graph contract it depends on:
|
|
157
|
+
* `@cortex/contracts/graph` is where the vocabulary, the Neo4j client and the embedder
|
|
158
|
+
* come from, and the version it seeds at is the version of that dependency.
|
|
159
|
+
*/
|
|
160
|
+
async function runSeed(config: ProjectConfig, domains: Record<string, DomainDef>) {
|
|
161
|
+
if (!Object.keys(domains).length) throw new Error("No domains found in knowledge config");
|
|
162
|
+
if (!config.neo4j) throw new Error("neo4j config is required for graph seed");
|
|
163
|
+
if (!config.embedding) throw new Error("embedding config is required for graph seed");
|
|
164
|
+
|
|
165
|
+
const embed = createEmbedder(config.embedding);
|
|
166
|
+
const neo4j = createNeo4jClient(config.neo4j, embed);
|
|
167
|
+
|
|
168
|
+
await warnOnRestamp(neo4j);
|
|
169
|
+
|
|
170
|
+
return await seedGraph(
|
|
171
|
+
{
|
|
172
|
+
neo4j,
|
|
173
|
+
embedding: { embed, dimension: config.embedding.dimension },
|
|
174
|
+
schema: GRAPH_SCHEMA,
|
|
175
|
+
schemaVersion: GRAPH_SCHEMA_VERSION,
|
|
176
|
+
},
|
|
177
|
+
domains,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Seeding is MERGE-based, so a graph written under an older schema is not cleaned
|
|
183
|
+
* up — its nodes stay, under labels nothing reads. Worth saying out loud; not
|
|
184
|
+
* worth blocking on, since re-seeding is exactly how that graph gets fixed.
|
|
185
|
+
*/
|
|
186
|
+
async function warnOnRestamp(neo4j: Neo4jClient) {
|
|
187
|
+
const rows: unknown = JSON.parse(
|
|
188
|
+
await neo4j.query(
|
|
189
|
+
`MATCH (g:${GRAPH_SCHEMA.label.marker}) RETURN g.${GRAPH_SCHEMA.prop.version} AS version`,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Anything but a row array means an unreachable or unreadable graph, which
|
|
194
|
+
// the seed itself is about to report far better than a warning could.
|
|
195
|
+
if (!Array.isArray(rows)) return;
|
|
196
|
+
|
|
197
|
+
const stamped = (rows as { version?: number }[])[0]?.version;
|
|
198
|
+
if (stamped === undefined || stamped === GRAPH_SCHEMA_VERSION) return;
|
|
199
|
+
|
|
200
|
+
report.blank();
|
|
201
|
+
report.row(
|
|
202
|
+
"",
|
|
203
|
+
`${pc.yellow("!")} Graph was written with schema v${stamped}; restamping as v${GRAPH_SCHEMA_VERSION}.`,
|
|
204
|
+
DOMAIN_COLUMN,
|
|
205
|
+
);
|
|
206
|
+
report.row("", " Nodes from the old schema are not removed.", DOMAIN_COLUMN);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Section requirements are `runSeed`'s to enforce — it already throws
|
|
211
|
+
* `"neo4j config is required for graph seed"`. This renders what it threw.
|
|
212
|
+
*/
|
|
213
|
+
function failSeed(options: {
|
|
214
|
+
error: unknown;
|
|
215
|
+
config: ProjectConfig;
|
|
216
|
+
configPath: string;
|
|
217
|
+
verbose: boolean;
|
|
218
|
+
}): never {
|
|
219
|
+
const { error, config, configPath, verbose } = options;
|
|
220
|
+
const label = configLabel(configPath);
|
|
221
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
222
|
+
|
|
223
|
+
if (message === "No domains found in knowledge config") {
|
|
224
|
+
report.fail({
|
|
225
|
+
what: `${label} has no knowledge.domains`,
|
|
226
|
+
blocks: [
|
|
227
|
+
"`graph seed` loads domain definitions into Neo4j. Add them:",
|
|
228
|
+
" knowledge: { domains: { leaves: leavesDomain } }",
|
|
229
|
+
"See https://github.com/boring91/cortex#file-structure-convention",
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const url = config.neo4j?.url;
|
|
235
|
+
if (url !== undefined && UNREACHABLE.test(message)) {
|
|
236
|
+
report.fail({
|
|
237
|
+
what: `Cannot reach Neo4j at ${url}`,
|
|
238
|
+
error,
|
|
239
|
+
blocks: [
|
|
240
|
+
"Is it running? docker compose up -d neo4j",
|
|
241
|
+
`Otherwise check neo4j.url / neo4j.user / neo4j.password in\n${label}.`,
|
|
242
|
+
],
|
|
243
|
+
verbose,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
report.fail({
|
|
248
|
+
what: "Could not seed the graph",
|
|
249
|
+
error,
|
|
250
|
+
blocks: [`Check neo4j, embedding, and knowledge.domains in ${label}.`],
|
|
251
|
+
verbose,
|
|
252
|
+
});
|
|
253
|
+
}
|