@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transforms declarative DomainDef into idempotent MERGE-based Cypher.
|
|
3
|
+
*
|
|
4
|
+
* Returns two phases:
|
|
5
|
+
* - `nodes` -- MERGE statements that create/update Domain, Concept, Endpoint,
|
|
6
|
+
* Service, and Rule nodes.
|
|
7
|
+
* - `edges` -- MATCH+MERGE statements that wire up relationships between nodes.
|
|
8
|
+
*
|
|
9
|
+
* The caller MUST run all `nodes` from every module before running any `edges`,
|
|
10
|
+
* because edges may reference nodes defined in a different module (e.g. a Rule
|
|
11
|
+
* in the leaves module that GOVERNS a Concept created by the servicing module).
|
|
12
|
+
*
|
|
13
|
+
* Every label, relationship type and property name comes from the `GraphSchema`
|
|
14
|
+
* the caller passes in — the project's own copy, so what this writes is by
|
|
15
|
+
* construction what that project's resolver reads back.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ConceptDef,
|
|
20
|
+
DomainDef,
|
|
21
|
+
EndpointDef,
|
|
22
|
+
GraphSchema,
|
|
23
|
+
RuleDef,
|
|
24
|
+
ServiceDef,
|
|
25
|
+
} from "@cortex/contracts/graph";
|
|
26
|
+
|
|
27
|
+
export type GeneratedCypher = {
|
|
28
|
+
nodes: string[];
|
|
29
|
+
edges: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function generateCypher(data: DomainDef, s: GraphSchema) {
|
|
33
|
+
const nodes: string[] = [domainNode(data, s)];
|
|
34
|
+
const edges: string[] = [];
|
|
35
|
+
|
|
36
|
+
for (const concept of data.concepts ?? []) {
|
|
37
|
+
nodes.push(conceptNode(concept, s));
|
|
38
|
+
edges.push(...conceptEdges(concept, data.name, s));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const endpoint of data.endpoints ?? []) {
|
|
42
|
+
nodes.push(endpointNode(endpoint, s));
|
|
43
|
+
edges.push(...endpointEdges(endpoint, s));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const service of data.services ?? []) {
|
|
47
|
+
nodes.push(serviceNode(service, s));
|
|
48
|
+
edges.push(...serviceEdges(service, s));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const rule of data.rules ?? []) {
|
|
52
|
+
nodes.push(ruleNode(rule, s));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { nodes, edges } satisfies GeneratedCypher;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function domainNode(data: DomainDef, s: GraphSchema) {
|
|
59
|
+
return `MERGE (d:${s.label.domain} {${s.prop.name}: '${esc(data.name)}'})
|
|
60
|
+
SET d.${s.prop.description} = '${esc(data.description)}'${metadataClause("d", data.metadata, s)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function conceptNode(concept: ConceptDef, s: GraphSchema) {
|
|
64
|
+
const aliases =
|
|
65
|
+
concept.aliases && concept.aliases.length > 0
|
|
66
|
+
? `, c.${s.prop.aliases} = [${concept.aliases.map((alias) => `'${esc(alias)}'`).join(", ")}]`
|
|
67
|
+
: "";
|
|
68
|
+
|
|
69
|
+
return `MERGE (c:${s.label.concept} {${s.prop.name}: '${esc(concept.name)}'})
|
|
70
|
+
SET c.${s.prop.description} = '${esc(concept.description)}'${aliases}${metadataClause("c", concept.metadata, s)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function conceptEdges(concept: ConceptDef, domainName: string, s: GraphSchema) {
|
|
74
|
+
const edges = [
|
|
75
|
+
`MATCH (c:${conceptRef(concept, s)})
|
|
76
|
+
MATCH (d:${s.label.domain} {${s.prop.name}: '${esc(domainName)}'})
|
|
77
|
+
MERGE (c)-[:${s.rel.belongsTo}]->(d)`,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
if (concept.parentConcept) {
|
|
81
|
+
edges.push(
|
|
82
|
+
`MATCH (child:${conceptRef(concept, s)})
|
|
83
|
+
MATCH (parent:${conceptRef(concept.parentConcept, s)})
|
|
84
|
+
MERGE (child)-[:${s.rel.specializes}]->(parent)`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
edges.push(...governedByEdges(concept.governedBy, conceptRef(concept, s), s));
|
|
89
|
+
|
|
90
|
+
return edges;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function endpointNode(endpoint: EndpointDef, s: GraphSchema) {
|
|
94
|
+
return `MERGE (e:${endpointRef(endpoint, s)})
|
|
95
|
+
SET e.${s.prop.name} = '${esc(endpoint.name)}',
|
|
96
|
+
e.${s.prop.description} = '${esc(endpoint.description)}',
|
|
97
|
+
e.${s.prop.params} = '${jsonStr(endpoint.params)}',
|
|
98
|
+
e.${s.prop.body} = '${jsonStr(endpoint.body)}',
|
|
99
|
+
e.${s.prop.response} = '${jsonStr(endpoint.response)}',
|
|
100
|
+
e.${s.prop.propertiesDescriptions} = '${jsonStr(endpoint.propertiesDescriptions)}',
|
|
101
|
+
e.${s.prop.successStatus} = ${endpoint.successStatus},
|
|
102
|
+
e.${s.prop.errorStatuses} = [${endpoint.errorStatuses.join(", ")}]${metadataClause("e", endpoint.metadata, s)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function endpointEdges(endpoint: EndpointDef, s: GraphSchema) {
|
|
106
|
+
const edges: string[] = [];
|
|
107
|
+
const self = endpointRef(endpoint, s);
|
|
108
|
+
|
|
109
|
+
for (const [concepts, relationship] of [
|
|
110
|
+
[endpoint.queries, s.rel.queriedVia],
|
|
111
|
+
[endpoint.mutates, s.rel.mutatedVia],
|
|
112
|
+
] as const) {
|
|
113
|
+
for (const concept of concepts ?? []) {
|
|
114
|
+
edges.push(
|
|
115
|
+
`MATCH (c:${conceptRef(concept, s)})
|
|
116
|
+
MATCH (e:${self})
|
|
117
|
+
MERGE (c)-[:${relationship}]->(e)`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const returned of endpoint.returns ?? []) {
|
|
123
|
+
// The field says which part of the response carries the concept; without
|
|
124
|
+
// one the whole body is the concept.
|
|
125
|
+
const field = returned.field ? ` SET r.${s.prop.field} = '${esc(returned.field)}'` : "";
|
|
126
|
+
edges.push(
|
|
127
|
+
`MATCH (e:${self})
|
|
128
|
+
MATCH (c:${conceptRef(returned.concept, s)})
|
|
129
|
+
MERGE (e)-[r:${s.rel.returns}]->(c)${field}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
edges.push(...governedByEdges(endpoint.governedBy, self, s));
|
|
134
|
+
|
|
135
|
+
for (const dependency of endpoint.dependsOn ?? []) {
|
|
136
|
+
edges.push(
|
|
137
|
+
`MATCH (e:${self})
|
|
138
|
+
MATCH (dep:${endpointRef(dependency.endpoint, s)})
|
|
139
|
+
MERGE (e)-[d:${s.rel.dependsOn}]->(dep)
|
|
140
|
+
SET d.${s.prop.paramName} = '${esc(dependency.paramName)}', d.${s.prop.fromField} = '${esc(dependency.fromField)}'`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return edges;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function serviceNode(service: ServiceDef, s: GraphSchema) {
|
|
148
|
+
return `MERGE (s:${serviceRef(service, s)})
|
|
149
|
+
SET s.${s.prop.name} = '${esc(service.name)}',
|
|
150
|
+
s.${s.prop.description} = '${esc(service.description)}'${metadataClause("s", service.metadata, s)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function serviceEdges(service: ServiceDef, s: GraphSchema) {
|
|
154
|
+
return [
|
|
155
|
+
`MATCH (c:${conceptRef(service.belongsTo, s)})
|
|
156
|
+
MATCH (s:${serviceRef(service, s)})
|
|
157
|
+
MERGE (c)-[:${s.rel.requestedVia}]->(s)`,
|
|
158
|
+
...governedByEdges(service.governedBy, serviceRef(service, s), s),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ruleNode(rule: RuleDef, s: GraphSchema) {
|
|
163
|
+
return `MERGE (r:${s.label.rule} {${s.prop.name}: '${esc(rule.name)}'})
|
|
164
|
+
SET r.${s.prop.description} = '${esc(rule.description)}'${metadataClause("r", rule.metadata, s)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Rules govern concepts, endpoints and services identically; only the target differs. */
|
|
168
|
+
function governedByEdges(rules: RuleDef[] | undefined, targetRef: string, s: GraphSchema) {
|
|
169
|
+
return (rules ?? []).map(
|
|
170
|
+
(rule) => `MATCH (r:${s.label.rule} {${s.prop.name}: '${esc(rule.name)}'})
|
|
171
|
+
MATCH (t:${targetRef})
|
|
172
|
+
MERGE (r)-[:${s.rel.governs}]->(t)`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Endpoints are keyed on path plus method, so both must appear in every match. */
|
|
177
|
+
function endpointRef(endpoint: Pick<EndpointDef, "path" | "method">, s: GraphSchema) {
|
|
178
|
+
return `${s.label.endpoint} {${s.prop.path}: '${esc(endpoint.path)}', ${s.prop.method}: '${endpoint.method}'}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function conceptRef(concept: Pick<ConceptDef, "name">, s: GraphSchema) {
|
|
182
|
+
return `${s.label.concept} {${s.prop.name}: '${esc(concept.name)}'}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function serviceRef(service: Pick<ServiceDef, "builtInId">, s: GraphSchema) {
|
|
186
|
+
return `${s.label.service} {${s.prop.builtInId}: '${esc(service.builtInId)}'}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Omitted entirely when absent — an empty assignment is not valid Cypher. */
|
|
190
|
+
function metadataClause(alias: string, metadata: unknown, s: GraphSchema) {
|
|
191
|
+
return metadata ? `, ${alias}.${s.prop.metadata} = '${jsonStr(metadata)}'` : "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Backslashes first, so escaping a quote cannot itself be escaped away. */
|
|
195
|
+
function esc(value: string) {
|
|
196
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function jsonStr(value: unknown) {
|
|
200
|
+
return esc(JSON.stringify(value));
|
|
201
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed orchestrator for the knowledge graph.
|
|
3
|
+
*
|
|
4
|
+
* Validates domains, generates Cypher, executes against Neo4j,
|
|
5
|
+
* and generates concept embeddings. All configuration is passed
|
|
6
|
+
* explicitly — no environment variables or Convex dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DomainDef, EmbedFn, GraphSchema, Neo4jClient } from "@cortex/contracts/graph";
|
|
10
|
+
|
|
11
|
+
import { expandDomains } from "./expand-domains";
|
|
12
|
+
import { generateCypher, type GeneratedCypher } from "./generate-cypher";
|
|
13
|
+
import { validateDomain } from "./validate";
|
|
14
|
+
|
|
15
|
+
export type EmbeddingConfig = {
|
|
16
|
+
embed: EmbedFn;
|
|
17
|
+
dimension: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SeedGraphConfig = {
|
|
21
|
+
neo4j: Neo4jClient;
|
|
22
|
+
embedding: EmbeddingConfig;
|
|
23
|
+
/** `GRAPH_SCHEMA`, passed rather than imported so tests can drive a fake one. */
|
|
24
|
+
schema: GraphSchema;
|
|
25
|
+
/** Stamped onto the graph, so the server can refuse a shape it cannot read. */
|
|
26
|
+
schemaVersion: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type StatementFailure = {
|
|
30
|
+
/** The Neo4j error code where there was one, its message otherwise. */
|
|
31
|
+
error: string;
|
|
32
|
+
/** The statement as generated, untruncated — the caller decides how much to show. */
|
|
33
|
+
statement: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type DomainSeedResult = {
|
|
37
|
+
nodes: number;
|
|
38
|
+
edges: number;
|
|
39
|
+
failed: number;
|
|
40
|
+
failures: StatementFailure[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SeedGraphResult = {
|
|
44
|
+
nodes: number;
|
|
45
|
+
edges: number;
|
|
46
|
+
failed: number;
|
|
47
|
+
/** Keyed by the same names the config declares under `knowledge.domains`. */
|
|
48
|
+
domains: Record<string, DomainSeedResult>;
|
|
49
|
+
embeddings: { concepts: number; dimension: number };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Nodes for every domain run before any edge does, so a statement carries the
|
|
53
|
+
// domain it came from rather than being attributed by the loop that runs it.
|
|
54
|
+
type TaggedStatement = { domain: string; statement: string };
|
|
55
|
+
|
|
56
|
+
// The client resolves query failures as {"error":true} JSON rather than
|
|
57
|
+
// rejecting; unchecked, a failed statement would count as a success.
|
|
58
|
+
async function runStatement(client: Neo4jClient, statement: string) {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(await client.query(statement)) as {
|
|
61
|
+
error?: boolean;
|
|
62
|
+
code?: string;
|
|
63
|
+
message?: string;
|
|
64
|
+
};
|
|
65
|
+
if (!parsed.error) return undefined;
|
|
66
|
+
return parsed.code ?? parsed.message ?? "Neo4j query failed";
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return error instanceof Error ? error.message : String(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Maps, not objects: a domain is named by the user, and `constructor` is a legal
|
|
73
|
+
// domain name that on a plain record reads back `Object.prototype.constructor` —
|
|
74
|
+
// a function, so `?? 0` never fires and `.push` throws.
|
|
75
|
+
async function runPhase(client: Neo4jClient, statements: TaggedStatement[]) {
|
|
76
|
+
const succeeded = new Map<string, number>();
|
|
77
|
+
const failures = new Map<string, StatementFailure[]>();
|
|
78
|
+
|
|
79
|
+
for (const { domain, statement } of statements) {
|
|
80
|
+
const error = await runStatement(client, statement);
|
|
81
|
+
if (error === undefined) {
|
|
82
|
+
succeeded.set(domain, (succeeded.get(domain) ?? 0) + 1);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const collected = failures.get(domain) ?? [];
|
|
87
|
+
collected.push({ error, statement });
|
|
88
|
+
failures.set(domain, collected);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { succeeded, failures };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Same reason as `runStatement`, but the embedding pass has no partial result to
|
|
95
|
+
// report: a failure there is a thrown error, not a count.
|
|
96
|
+
function assertOk(result: string) {
|
|
97
|
+
const parsed = JSON.parse(result) as { error?: boolean; message?: string };
|
|
98
|
+
if (parsed.error) throw new Error(parsed.message ?? "Neo4j query failed");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function generateEmbeddings(
|
|
102
|
+
client: Neo4jClient,
|
|
103
|
+
embeddingConfig: EmbeddingConfig,
|
|
104
|
+
s: GraphSchema,
|
|
105
|
+
) {
|
|
106
|
+
// 1. Create the vector index (idempotent)
|
|
107
|
+
assertOk(
|
|
108
|
+
await client.query(
|
|
109
|
+
`CREATE VECTOR INDEX ${s.conceptEmbeddingsIndex} IF NOT EXISTS
|
|
110
|
+
FOR (c:${s.label.concept}) ON (c.${s.prop.embedding})
|
|
111
|
+
OPTIONS {indexConfig: {
|
|
112
|
+
\`vector.dimensions\`: ${embeddingConfig.dimension},
|
|
113
|
+
\`vector.similarity_function\`: 'cosine'
|
|
114
|
+
}}`,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// 2. Fetch all concepts
|
|
119
|
+
const conceptsRaw = await client.query(
|
|
120
|
+
`MATCH (c:${s.label.concept}) RETURN c.${s.prop.name} AS name, c.${s.prop.description} AS description`,
|
|
121
|
+
);
|
|
122
|
+
assertOk(conceptsRaw);
|
|
123
|
+
const concepts = JSON.parse(conceptsRaw) as { name: string; description: string }[];
|
|
124
|
+
|
|
125
|
+
if (!Array.isArray(concepts) || concepts.length === 0) {
|
|
126
|
+
return { concepts: 0, dimension: embeddingConfig.dimension };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Generate embeddings for every concept in one request
|
|
130
|
+
const embeddings = await embeddingConfig.embed(concepts.map((c) => c.description));
|
|
131
|
+
|
|
132
|
+
// 4. Store the embeddings in the graph
|
|
133
|
+
await Promise.all(
|
|
134
|
+
concepts.map(async (concept, idx) =>
|
|
135
|
+
assertOk(
|
|
136
|
+
await client.query(
|
|
137
|
+
`MATCH (c:${s.label.concept} {${s.prop.name}: $name})
|
|
138
|
+
SET c.${s.prop.embedding} = $embedding`,
|
|
139
|
+
{
|
|
140
|
+
name: concept.name,
|
|
141
|
+
embedding: embeddings[idx]!,
|
|
142
|
+
},
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return { concepts: concepts.length, dimension: embeddingConfig.dimension };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function seedGraph(config: SeedGraphConfig, domains: Record<string, DomainDef>) {
|
|
152
|
+
const expandedDomains = expandDomains(domains);
|
|
153
|
+
|
|
154
|
+
// Validate all domains
|
|
155
|
+
const validationErrors = Object.entries(expandedDomains).flatMap(([name, domain]) =>
|
|
156
|
+
validateDomain(domain).map((err) => `[${name}] ${err}`),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (validationErrors.length) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Validation failed with ${validationErrors.length} error(s):\n${validationErrors.join("\n")}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const client = config.neo4j;
|
|
166
|
+
const targets = Object.entries(expandedDomains);
|
|
167
|
+
|
|
168
|
+
// Generate Cypher for all target domains
|
|
169
|
+
const allCypher: [string, GeneratedCypher][] = targets.map(([name, data]) => [
|
|
170
|
+
name,
|
|
171
|
+
generateCypher(data, config.schema),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
// Stamped before anything else is written, so an interrupted seed still
|
|
175
|
+
// leaves the graph labelled with the schema it was written against.
|
|
176
|
+
assertOk(
|
|
177
|
+
await client.query(
|
|
178
|
+
`MERGE (g:${config.schema.label.marker})
|
|
179
|
+
SET g.${config.schema.prop.version} = ${config.schemaVersion}`,
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Phase 1: Create all nodes across every domain first.
|
|
184
|
+
// This ensures cross-domain references resolve correctly.
|
|
185
|
+
const allNodes = allCypher.flatMap(([domain, c]) => tag(domain, c.nodes));
|
|
186
|
+
const nodes = await runPhase(client, allNodes);
|
|
187
|
+
|
|
188
|
+
// Phase 2: Create all edges now that every node exists.
|
|
189
|
+
const allEdges = allCypher.flatMap(([domain, c]) => tag(domain, c.edges));
|
|
190
|
+
const edges = await runPhase(client, allEdges);
|
|
191
|
+
|
|
192
|
+
// Phase 3: Generate embeddings for concepts
|
|
193
|
+
const embeddings = await generateEmbeddings(client, config.embedding, config.schema);
|
|
194
|
+
|
|
195
|
+
const domainResults: Record<string, DomainSeedResult> = Object.fromEntries(
|
|
196
|
+
targets.map(([name]) => {
|
|
197
|
+
const failures = [
|
|
198
|
+
...(nodes.failures.get(name) ?? []),
|
|
199
|
+
...(edges.failures.get(name) ?? []),
|
|
200
|
+
];
|
|
201
|
+
return [
|
|
202
|
+
name,
|
|
203
|
+
{
|
|
204
|
+
nodes: nodes.succeeded.get(name) ?? 0,
|
|
205
|
+
edges: edges.succeeded.get(name) ?? 0,
|
|
206
|
+
failed: failures.length,
|
|
207
|
+
failures,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// The aggregates stay the headline number; per-domain is the breakdown of it.
|
|
214
|
+
return Object.values(domainResults).reduce<SeedGraphResult>(
|
|
215
|
+
(total, domain) => ({
|
|
216
|
+
...total,
|
|
217
|
+
nodes: total.nodes + domain.nodes,
|
|
218
|
+
edges: total.edges + domain.edges,
|
|
219
|
+
failed: total.failed + domain.failed,
|
|
220
|
+
}),
|
|
221
|
+
{ nodes: 0, edges: 0, failed: 0, domains: domainResults, embeddings },
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function tag(domain: string, statements: string[]) {
|
|
226
|
+
return statements.map((statement) => ({ domain, statement }) satisfies TaggedStatement);
|
|
227
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ConceptDef, DomainDef, RuleDef } from "@cortex/contracts/graph";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks a domain for the two mistakes that survive the type system: a name used
|
|
5
|
+
* twice, and an entity nothing points at.
|
|
6
|
+
*
|
|
7
|
+
* Cross-domain references are allowed — a concept, endpoint or service may be
|
|
8
|
+
* defined in another module — so local membership is deliberately not enforced.
|
|
9
|
+
*/
|
|
10
|
+
export function validateDomain(data: DomainDef) {
|
|
11
|
+
const referenced = collectReferences(data);
|
|
12
|
+
|
|
13
|
+
return [
|
|
14
|
+
...duplicateNames(data.concepts, "concept"),
|
|
15
|
+
...duplicateNames(data.endpoints, "endpoint"),
|
|
16
|
+
...duplicateNames(data.services, "service"),
|
|
17
|
+
...duplicateNames(data.rules, "rule"),
|
|
18
|
+
...unreferenced(
|
|
19
|
+
data.concepts,
|
|
20
|
+
referenced.concepts,
|
|
21
|
+
(name) => `Concept "${name}" is never referenced by any endpoint or service`,
|
|
22
|
+
),
|
|
23
|
+
...unreferenced(
|
|
24
|
+
data.rules,
|
|
25
|
+
referenced.rules,
|
|
26
|
+
(name) => `Rule "${name}" is never referenced by any endpoint, service, or concept`,
|
|
27
|
+
),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function duplicateNames(items: { name: string }[] | undefined, kind: string) {
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
|
|
34
|
+
return (items ?? []).flatMap((item) => {
|
|
35
|
+
const duplicate = seen.has(item.name);
|
|
36
|
+
seen.add(item.name);
|
|
37
|
+
|
|
38
|
+
return duplicate ? [`Duplicate ${kind} name: "${item.name}"`] : [];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unreferenced(
|
|
43
|
+
items: { name: string }[] | undefined,
|
|
44
|
+
referenced: Set<string>,
|
|
45
|
+
message: (name: string) => string,
|
|
46
|
+
) {
|
|
47
|
+
return (items ?? [])
|
|
48
|
+
.filter((item) => !referenced.has(item.name))
|
|
49
|
+
.map((item) => message(item.name));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Gathers every name pointed at from anywhere in the domain. */
|
|
53
|
+
function collectReferences(data: DomainDef) {
|
|
54
|
+
const concepts = new Set<string>();
|
|
55
|
+
const rules = new Set<string>();
|
|
56
|
+
const addRules = (governedBy: RuleDef[] | undefined) =>
|
|
57
|
+
governedBy?.forEach((rule) => rules.add(rule.name));
|
|
58
|
+
const addConcept = (concept: ConceptDef | undefined) => concept && concepts.add(concept.name);
|
|
59
|
+
|
|
60
|
+
for (const endpoint of data.endpoints ?? []) {
|
|
61
|
+
endpoint.queries?.forEach(addConcept);
|
|
62
|
+
endpoint.mutates?.forEach(addConcept);
|
|
63
|
+
endpoint.returns?.forEach((returned) => addConcept(returned.concept));
|
|
64
|
+
addRules(endpoint.governedBy);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const service of data.services ?? []) {
|
|
68
|
+
addConcept(service.belongsTo);
|
|
69
|
+
addRules(service.governedBy);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const concept of data.concepts ?? []) {
|
|
73
|
+
addConcept(concept.parentConcept);
|
|
74
|
+
addRules(concept.governedBy);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { concepts, rules };
|
|
78
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five features `cortex new` toggles (design spec §7.1). A feature is not a
|
|
3
|
+
* config key: it is a bundle of a `cortex.config.ts` fragment, a block of env
|
|
4
|
+
* keys, and sometimes a compose service. Fragments concatenate — there is no
|
|
5
|
+
* merge engine — so every field here is plain text that gets joined.
|
|
6
|
+
*
|
|
7
|
+
* The grouping is what the server code already enforces. `vision` is inert
|
|
8
|
+
* without `storage`, so attachments writes both; `seedGraph` throws unless it
|
|
9
|
+
* has all of `neo4j`, `embedding` and `knowledge.domains`, so knowledge writes
|
|
10
|
+
* all three plus the `src/domains/` tree the domainsDir convention assumes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type Feature = "attachments" | "redis" | "auth" | "knowledge" | "controlCenter";
|
|
14
|
+
|
|
15
|
+
type Definition = {
|
|
16
|
+
/** One line, shown in `--help` and next to the option in the multi-select. */
|
|
17
|
+
hint: string;
|
|
18
|
+
/** Sits above the fragment in the generated config, commented either way. */
|
|
19
|
+
note: string;
|
|
20
|
+
/**
|
|
21
|
+
* The `cortex.config.ts` fragment, written at zero indentation: the assembler
|
|
22
|
+
* indents it into the object literal, or comments it out for a feature that
|
|
23
|
+
* was not selected. Conditional spread throughout, so an unfilled env var
|
|
24
|
+
* leaves the section *absent* rather than present-and-undefined.
|
|
25
|
+
*/
|
|
26
|
+
config: string;
|
|
27
|
+
/** The `.env` block, identical in `.env` and `.env.example`. */
|
|
28
|
+
env: string;
|
|
29
|
+
/** The `docker-compose.yml` service, indented under `services:`. */
|
|
30
|
+
compose?: string;
|
|
31
|
+
/** The named volume that service mounts. */
|
|
32
|
+
volume?: string;
|
|
33
|
+
/** What `cd`-then-edit has to fill in before the project boots. */
|
|
34
|
+
secrets?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const FEATURES: Record<Feature, Definition> = {
|
|
38
|
+
attachments: {
|
|
39
|
+
hint: "MinIO storage for uploads, described by a vision model",
|
|
40
|
+
note: "Attachments: an S3-compatible bucket for uploads, plus an image-capable\nmodel that describes them. Vision does nothing without storage, which is\nwhy one toggle writes both.",
|
|
41
|
+
config: `...(process.env["CORTEX_MINIO_ENDPOINT"]
|
|
42
|
+
? {
|
|
43
|
+
storage: {
|
|
44
|
+
endPoint: process.env["CORTEX_MINIO_ENDPOINT"],
|
|
45
|
+
port: Number(process.env["CORTEX_MINIO_PORT"] ?? {{port.minio}}),
|
|
46
|
+
useSSL: process.env["CORTEX_MINIO_USE_SSL"] === "true",
|
|
47
|
+
accessKey: process.env["CORTEX_MINIO_ACCESS_KEY"]!,
|
|
48
|
+
secretKey: process.env["CORTEX_MINIO_SECRET_KEY"]!,
|
|
49
|
+
},
|
|
50
|
+
vision: {
|
|
51
|
+
baseURL: process.env["CORTEX_VISION_URL"]!,
|
|
52
|
+
apiKey: process.env["CORTEX_VISION_KEY"]!,
|
|
53
|
+
modelName: process.env["CORTEX_VISION_MODEL"]!,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
: {}),`,
|
|
57
|
+
env: `# --- Attachments -------------------------------------------------------------
|
|
58
|
+
# S3-compatible object storage. docker-compose.yml runs MinIO on these values.
|
|
59
|
+
CORTEX_MINIO_ENDPOINT=localhost
|
|
60
|
+
CORTEX_MINIO_PORT={{port.minio}}
|
|
61
|
+
CORTEX_MINIO_USE_SSL=false
|
|
62
|
+
CORTEX_MINIO_ACCESS_KEY=minioadmin
|
|
63
|
+
CORTEX_MINIO_SECRET_KEY=minioadmin
|
|
64
|
+
|
|
65
|
+
# An image-capable, OpenAI-compatible model. It describes uploaded images so the
|
|
66
|
+
# agent can read them.
|
|
67
|
+
CORTEX_VISION_URL=https://api.openai.com/v1
|
|
68
|
+
CORTEX_VISION_KEY=your-vision-api-key
|
|
69
|
+
CORTEX_VISION_MODEL=your-vision-model`,
|
|
70
|
+
compose: ` minio:
|
|
71
|
+
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
|
72
|
+
command: server /data --console-address ":9001"
|
|
73
|
+
environment:
|
|
74
|
+
MINIO_ROOT_USER: minioadmin
|
|
75
|
+
MINIO_ROOT_PASSWORD: minioadmin
|
|
76
|
+
ports:
|
|
77
|
+
- "{{port.minio}}:9000"
|
|
78
|
+
- "{{port.minioConsole}}:9001"
|
|
79
|
+
volumes:
|
|
80
|
+
- minio-data:/data`,
|
|
81
|
+
volume: "minio-data",
|
|
82
|
+
secrets: "CORTEX_VISION_*",
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
redis: {
|
|
86
|
+
hint: "resumable streams across restarts",
|
|
87
|
+
note: "Redis makes live runs survive a restart and reach every instance. Without\nit, an in-memory fallback covers this single process.",
|
|
88
|
+
config: `...(process.env["CORTEX_REDIS_URL"] ? { redis: { url: process.env["CORTEX_REDIS_URL"] } } : {}),`,
|
|
89
|
+
env: `# --- Redis -------------------------------------------------------------------
|
|
90
|
+
# Resumable streams. docker-compose.yml runs one on this URL.
|
|
91
|
+
CORTEX_REDIS_URL=redis://localhost:{{port.redis}}`,
|
|
92
|
+
compose: ` redis:
|
|
93
|
+
image: redis:7-alpine
|
|
94
|
+
ports:
|
|
95
|
+
- "{{port.redis}}:6379"`,
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
auth: {
|
|
99
|
+
hint: "JWT verification via JWKS",
|
|
100
|
+
note: "Bearer tokens verified against an identity provider's JWKS endpoint. The\nprovider is external, so there is no service for it.",
|
|
101
|
+
config: `...(process.env["CORTEX_AUTH_JWKS_URI"]
|
|
102
|
+
? {
|
|
103
|
+
auth: {
|
|
104
|
+
jwksUri: process.env["CORTEX_AUTH_JWKS_URI"],
|
|
105
|
+
issuer: process.env["CORTEX_AUTH_ISSUER"]!,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
: {}),`,
|
|
109
|
+
env: `# --- Auth --------------------------------------------------------------------
|
|
110
|
+
# Your identity provider's JWKS endpoint, and the issuer it stamps on tokens.
|
|
111
|
+
CORTEX_AUTH_JWKS_URI=https://your-idp.example.com/.well-known/jwks.json
|
|
112
|
+
CORTEX_AUTH_ISSUER=https://your-idp.example.com/`,
|
|
113
|
+
secrets: "CORTEX_AUTH_*",
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
knowledge: {
|
|
117
|
+
hint: "Swagger + domains + Neo4j",
|
|
118
|
+
note: "The knowledge graph, which is three keys rather than one: Neo4j stores it,\nthe embedding endpoint makes it searchable, and the domain definitions under\nsrc/domains/ are what `cortex graph seed` loads into it.",
|
|
119
|
+
config: `...(process.env["CORTEX_NEO4J_URL"]
|
|
120
|
+
? {
|
|
121
|
+
neo4j: {
|
|
122
|
+
url: process.env["CORTEX_NEO4J_URL"],
|
|
123
|
+
user: process.env["CORTEX_NEO4J_USER"]!,
|
|
124
|
+
password: process.env["CORTEX_NEO4J_PASSWORD"]!,
|
|
125
|
+
},
|
|
126
|
+
embedding: {
|
|
127
|
+
baseURL: process.env["CORTEX_EMBEDDING_URL"]!,
|
|
128
|
+
apiKey: process.env["CORTEX_EMBEDDING_KEY"]!,
|
|
129
|
+
modelName: process.env["CORTEX_EMBEDDING_MODEL"]!,
|
|
130
|
+
dimension: Number(process.env["CORTEX_EMBEDDING_DIMENSION"] ?? 1024),
|
|
131
|
+
},
|
|
132
|
+
knowledge: {
|
|
133
|
+
// swagger: { url: "https://api.example.com/swagger/v1/swagger.json" },
|
|
134
|
+
// Import the domains you write under src/domains/ and list them here.
|
|
135
|
+
domains: {},
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
: {}),`,
|
|
139
|
+
env: `# --- Knowledge graph ---------------------------------------------------------
|
|
140
|
+
# Neo4j. docker-compose.yml runs one with exactly these credentials. The URL is
|
|
141
|
+
# the HTTP endpoint, not the bolt one: the server talks to Neo4j over its HTTP
|
|
142
|
+
# transaction API.
|
|
143
|
+
CORTEX_NEO4J_URL=http://localhost:{{port.neo4j}}
|
|
144
|
+
CORTEX_NEO4J_USER=neo4j
|
|
145
|
+
CORTEX_NEO4J_PASSWORD=cortex-dev-password
|
|
146
|
+
|
|
147
|
+
# An OpenAI-compatible embeddings endpoint — external, like the main model.
|
|
148
|
+
# CORTEX_EMBEDDING_DIMENSION has to match what the model returns.
|
|
149
|
+
CORTEX_EMBEDDING_URL=https://api.openai.com/v1
|
|
150
|
+
CORTEX_EMBEDDING_KEY=your-embedding-api-key
|
|
151
|
+
CORTEX_EMBEDDING_MODEL=your-embedding-model
|
|
152
|
+
CORTEX_EMBEDDING_DIMENSION=1024`,
|
|
153
|
+
compose: ` neo4j:
|
|
154
|
+
image: neo4j:5-community
|
|
155
|
+
environment:
|
|
156
|
+
NEO4J_AUTH: neo4j/cortex-dev-password
|
|
157
|
+
# Neo4j Browser, served on the HTTP port, opens bolt at whatever this
|
|
158
|
+
# advertises. Without it the browser would offer 7687, which is the
|
|
159
|
+
# container's port and not the one published below.
|
|
160
|
+
NEO4J_server_bolt_advertised__address: ":{{port.neo4jBolt}}"
|
|
161
|
+
ports:
|
|
162
|
+
- "{{port.neo4j}}:7474"
|
|
163
|
+
- "{{port.neo4jBolt}}:7687"
|
|
164
|
+
volumes:
|
|
165
|
+
- neo4j-data:/data`,
|
|
166
|
+
volume: "neo4j-data",
|
|
167
|
+
secrets: "CORTEX_EMBEDDING_*",
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
controlCenter: {
|
|
171
|
+
hint: "serve agents published on a Control Center instance",
|
|
172
|
+
note: "The Control Center you point this server at. External, so no service here.",
|
|
173
|
+
config: `...(process.env["CORTEX_CONTROL_CENTER_URL"]
|
|
174
|
+
? {
|
|
175
|
+
controlCenter: {
|
|
176
|
+
url: process.env["CORTEX_CONTROL_CENTER_URL"],
|
|
177
|
+
apiKey: process.env["CORTEX_CONTROL_CENTER_KEY"]!,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
: {}),`,
|
|
181
|
+
env: `# --- Control Center ----------------------------------------------------------
|
|
182
|
+
CORTEX_CONTROL_CENTER_URL=http://localhost:4000
|
|
183
|
+
CORTEX_CONTROL_CENTER_KEY=your-control-center-key`,
|
|
184
|
+
secrets: "CORTEX_CONTROL_CENTER_KEY",
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Catalogue order, which is the order every generated file lists them in. */
|
|
189
|
+
export const FEATURE_NAMES = Object.keys(FEATURES) as Feature[];
|