@m6d/cortex-cli 0.1.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 +175 -0
- package/bin/cortex.js +52 -0
- package/package.json +41 -0
- package/src/cc/client.ts +295 -0
- package/src/cc/flags.ts +135 -0
- package/src/cc/output.ts +77 -0
- package/src/cc/profiles.ts +105 -0
- package/src/cli.ts +56 -0
- package/src/commands/cc.ts +504 -0
- package/src/commands/graph.ts +229 -0
- package/src/commands/new.ts +333 -0
- package/src/commands/swagger.ts +261 -0
- package/src/config/load.ts +122 -0
- package/src/config/validate.ts +46 -0
- package/src/contracts/charts/chart-spec.ts +201 -0
- package/src/contracts/charts/index.ts +7 -0
- package/src/contracts/client-tools/index.ts +82 -0
- package/src/contracts/egress/index.ts +161 -0
- package/src/contracts/egress/scrub.ts +59 -0
- package/src/contracts/graph/clients/embed.ts +45 -0
- package/src/contracts/graph/clients/neo4j.ts +85 -0
- package/src/contracts/graph/helpers.ts +57 -0
- package/src/contracts/graph/index.ts +30 -0
- package/src/contracts/graph/schema.ts +65 -0
- package/src/contracts/graph/types.ts +125 -0
- package/src/contracts/markdown/index.ts +39 -0
- package/src/contracts/rich-text/index.ts +220 -0
- package/src/contracts/runtime/api.ts +328 -0
- package/src/contracts/runtime/auth.ts +41 -0
- package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
- package/src/contracts/runtime/dataset-sql.ts +2522 -0
- package/src/contracts/runtime/datasets.fixtures.json +2849 -0
- package/src/contracts/runtime/datasets.ts +1391 -0
- package/src/contracts/runtime/grammar.ts +161 -0
- package/src/contracts/runtime/index.ts +14 -0
- package/src/contracts/runtime/signature.ts +237 -0
- package/src/contracts/runtime/sync.ts +172 -0
- package/src/contracts/runtime/triggers.ts +224 -0
- package/src/contracts/wire/index.ts +264 -0
- package/src/graph/expand-domains.ts +288 -0
- package/src/graph/generate-cypher.ts +201 -0
- package/src/graph/seed.ts +207 -0
- package/src/graph/validate.ts +78 -0
- package/src/scaffold/features.ts +190 -0
- package/src/scaffold/files.ts +411 -0
- package/src/scaffold/ports.ts +86 -0
- package/src/scaffold/templates/domains-README.md.txt +16 -0
- package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
- package/src/scaffold/templates/gitignore.txt +19 -0
- package/src/scaffold/templates/index.ts.txt +18 -0
- package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
- package/src/scaffold/templates/tsconfig.json.txt +25 -0
- package/src/swagger/extract-endpoints.ts +266 -0
- package/src/swagger/generated-block.ts +74 -0
- package/src/swagger/openapi-schema.ts +161 -0
- package/src/swagger/parse-response.ts +88 -0
- package/src/swagger/types.ts +25 -0
- package/src/ui/prompts.ts +125 -0
- package/src/ui/report.ts +107 -0
- package/tsconfig.json +46 -0
|
@@ -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,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five features `cortex new` toggles. 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=minio
|
|
63
|
+
CORTEX_MINIO_SECRET_KEY=minio-secret-key
|
|
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: minio
|
|
75
|
+
MINIO_ROOT_PASSWORD: minio-secret-key
|
|
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
|
+
kind: "jwks" as const,
|
|
105
|
+
jwksUri: process.env["CORTEX_AUTH_JWKS_URI"],
|
|
106
|
+
issuer: process.env["CORTEX_AUTH_ISSUER"]!,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
: {}),`,
|
|
110
|
+
env: `# --- Auth --------------------------------------------------------------------
|
|
111
|
+
# Your identity provider's JWKS endpoint, and the issuer it stamps on tokens.
|
|
112
|
+
CORTEX_AUTH_JWKS_URI=https://your-idp.example.com/.well-known/jwks.json
|
|
113
|
+
CORTEX_AUTH_ISSUER=https://your-idp.example.com/`,
|
|
114
|
+
secrets: "CORTEX_AUTH_*",
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
knowledge: {
|
|
118
|
+
hint: "Swagger + domains + Neo4j",
|
|
119
|
+
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.",
|
|
120
|
+
config: `...(process.env["CORTEX_NEO4J_URL"]
|
|
121
|
+
? {
|
|
122
|
+
neo4j: {
|
|
123
|
+
url: process.env["CORTEX_NEO4J_URL"],
|
|
124
|
+
user: process.env["CORTEX_NEO4J_USER"]!,
|
|
125
|
+
password: process.env["CORTEX_NEO4J_PASSWORD"]!,
|
|
126
|
+
},
|
|
127
|
+
embedding: {
|
|
128
|
+
baseURL: process.env["CORTEX_EMBEDDING_URL"]!,
|
|
129
|
+
apiKey: process.env["CORTEX_EMBEDDING_KEY"]!,
|
|
130
|
+
modelName: process.env["CORTEX_EMBEDDING_MODEL"]!,
|
|
131
|
+
dimension: Number(process.env["CORTEX_EMBEDDING_DIMENSION"] ?? 1024),
|
|
132
|
+
},
|
|
133
|
+
knowledge: {
|
|
134
|
+
// swagger: { url: "https://api.example.com/swagger/v1/swagger.json" },
|
|
135
|
+
// Import the domains you write under src/domains/ and list them here.
|
|
136
|
+
domains: {},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {}),`,
|
|
140
|
+
env: `# --- Knowledge graph ---------------------------------------------------------
|
|
141
|
+
# Neo4j. docker-compose.yml runs one with exactly these credentials. The URL is
|
|
142
|
+
# the HTTP endpoint, not the bolt one: the server talks to Neo4j over its HTTP
|
|
143
|
+
# transaction API.
|
|
144
|
+
CORTEX_NEO4J_URL=http://localhost:{{port.neo4j}}
|
|
145
|
+
CORTEX_NEO4J_USER=neo4j
|
|
146
|
+
CORTEX_NEO4J_PASSWORD=cortex-dev-password
|
|
147
|
+
|
|
148
|
+
# An OpenAI-compatible embeddings endpoint — external, like the main model.
|
|
149
|
+
# CORTEX_EMBEDDING_DIMENSION has to match what the model returns.
|
|
150
|
+
CORTEX_EMBEDDING_URL=https://api.openai.com/v1
|
|
151
|
+
CORTEX_EMBEDDING_KEY=your-embedding-api-key
|
|
152
|
+
CORTEX_EMBEDDING_MODEL=your-embedding-model
|
|
153
|
+
CORTEX_EMBEDDING_DIMENSION=1024`,
|
|
154
|
+
compose: ` neo4j:
|
|
155
|
+
image: neo4j:5-community
|
|
156
|
+
environment:
|
|
157
|
+
NEO4J_AUTH: neo4j/cortex-dev-password
|
|
158
|
+
# Neo4j Browser, served on the HTTP port, opens bolt at whatever this
|
|
159
|
+
# advertises. Without it the browser would offer 7687, which is the
|
|
160
|
+
# container's port and not the one published below.
|
|
161
|
+
NEO4J_server_bolt_advertised__address: ":{{port.neo4jBolt}}"
|
|
162
|
+
ports:
|
|
163
|
+
- "{{port.neo4j}}:7474"
|
|
164
|
+
- "{{port.neo4jBolt}}:7687"
|
|
165
|
+
volumes:
|
|
166
|
+
- neo4j-data:/data`,
|
|
167
|
+
volume: "neo4j-data",
|
|
168
|
+
secrets: "CORTEX_EMBEDDING_*",
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
controlCenter: {
|
|
172
|
+
hint: "serve agents published on a Control Center instance",
|
|
173
|
+
note: "The Control Center you point this server at. External, so no service here.",
|
|
174
|
+
config: `...(process.env["CORTEX_CONTROL_CENTER_URL"]
|
|
175
|
+
? {
|
|
176
|
+
controlCenter: {
|
|
177
|
+
url: process.env["CORTEX_CONTROL_CENTER_URL"],
|
|
178
|
+
apiKey: process.env["CORTEX_CONTROL_CENTER_API_KEY"]!,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
: {}),`,
|
|
182
|
+
env: `# --- Control Center ----------------------------------------------------------
|
|
183
|
+
CORTEX_CONTROL_CENTER_URL=http://localhost:20000
|
|
184
|
+
CORTEX_CONTROL_CENTER_API_KEY=cc_live_...`,
|
|
185
|
+
secrets: "CORTEX_CONTROL_CENTER_API_KEY",
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Catalogue order, which is the order every generated file lists them in. */
|
|
190
|
+
export const FEATURE_NAMES = Object.keys(FEATURES) as Feature[];
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every file `cortex new` writes, as one pure function. Files with nothing to
|
|
3
|
+
* fill in are verbatim text under templates/; the ones that vary with the
|
|
4
|
+
* selection are assembled here. The tooling configs are inlined rather than
|
|
5
|
+
* pulled from `@m6d/eslint-config`, `@m6d/prettier-config` and `@m6d/tsconfig`:
|
|
6
|
+
* those are `private: true` at `0.0.0` and resolvable only through this
|
|
7
|
+
* workspace, and a standalone scaffold has no workspace.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { FEATURE_NAMES, FEATURES, type Feature } from "@/scaffold/features";
|
|
11
|
+
import indexTs from "@/scaffold/templates/index.ts.txt";
|
|
12
|
+
import domainsReadme from "@/scaffold/templates/domains-README.md.txt";
|
|
13
|
+
import tsconfigJson from "@/scaffold/templates/tsconfig.json.txt";
|
|
14
|
+
import gitignoreText from "@/scaffold/templates/gitignore.txt";
|
|
15
|
+
import eslintConfig from "@/scaffold/templates/eslint.config.mjs.txt";
|
|
16
|
+
import prettierConfig from "@/scaffold/templates/prettier.config.mjs.txt";
|
|
17
|
+
import fallowrc from "@/scaffold/templates/fallowrc.jsonc.txt";
|
|
18
|
+
import { applyPorts, type Ports } from "@/scaffold/ports";
|
|
19
|
+
|
|
20
|
+
export type Database = "postgres" | "mssql";
|
|
21
|
+
|
|
22
|
+
// The generated project depends on these directly, not transitively: its own code
|
|
23
|
+
// imports `zod` for tool schemas and `hono` for extra routes on `server.app`.
|
|
24
|
+
// The ranges are packages/cortex-server's own peers, which is the compatibility
|
|
25
|
+
// statement the server itself publishes, and a server range pinned to its
|
|
26
|
+
// current minor, since a minor bump is where the server breaks; the release
|
|
27
|
+
// sync (scripts/sync-package-versions.ts) restamps it and files.test.ts holds
|
|
28
|
+
// them to that.
|
|
29
|
+
export const DEPENDENCIES = {
|
|
30
|
+
"@m6d/cortex-server": "~0.1.0",
|
|
31
|
+
"@tanstack/ai": "^0.55.0",
|
|
32
|
+
"@tanstack/ai-openai": "^0.22.8",
|
|
33
|
+
hono: "^4.12.3",
|
|
34
|
+
jose: "^6.1.3",
|
|
35
|
+
openai: "^6",
|
|
36
|
+
zod: "^4.1.8",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const DEV_DEPENDENCIES = {
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
cspell: "^9.6.4",
|
|
42
|
+
eslint: "^9",
|
|
43
|
+
"eslint-config-prettier": "^10.1.8",
|
|
44
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
45
|
+
fallow: "^3.9.1",
|
|
46
|
+
prettier: "^3.6.2",
|
|
47
|
+
typescript: "^5",
|
|
48
|
+
"typescript-eslint": "^8.65.0",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Postgres is the one we run, so it gets a scaffolded port. MSSQL is normally an
|
|
52
|
+
// existing instance nobody moved, which is why it keeps 1433 and has no service.
|
|
53
|
+
const CONNECTION_STRING: Record<Database, string> = {
|
|
54
|
+
postgres: "postgres://postgres:postgres@localhost:{{port.postgres}}/cortex",
|
|
55
|
+
mssql: "Server=localhost,1433;Database=cortex;User Id=sa;Password=your-password;Encrypt=false",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function packageJson(name: string) {
|
|
59
|
+
return `${JSON.stringify(
|
|
60
|
+
{
|
|
61
|
+
name,
|
|
62
|
+
version: "0.1.0",
|
|
63
|
+
private: true,
|
|
64
|
+
type: "module",
|
|
65
|
+
scripts: {
|
|
66
|
+
// Not `fallow audit`: it scopes itself to files changed against a
|
|
67
|
+
// base branch, and a project on line one has no commits and no
|
|
68
|
+
// remote to derive one from. Bare `fallow` runs the same three
|
|
69
|
+
// analyses — dead code, duplication, complexity — over everything.
|
|
70
|
+
audit: "fallow",
|
|
71
|
+
check: "tsc --noEmit",
|
|
72
|
+
dev: "bun run --hot index.ts",
|
|
73
|
+
format: "prettier --write .",
|
|
74
|
+
"format:check": "prettier --check .",
|
|
75
|
+
lint: "eslint",
|
|
76
|
+
spellcheck: 'cspell "**/*.{ts,mjs,json,md}"',
|
|
77
|
+
verify: "bun run format:check && bun run lint && bun run spellcheck && bun run check && bun run audit",
|
|
78
|
+
},
|
|
79
|
+
dependencies: DEPENDENCIES,
|
|
80
|
+
devDependencies: DEV_DEPENDENCIES,
|
|
81
|
+
},
|
|
82
|
+
null,
|
|
83
|
+
4,
|
|
84
|
+
)}\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Never toggled, so they only ever appear commented out: `context` is fully
|
|
89
|
+
* defaulted, and the other two are what you reach for once a real problem shows
|
|
90
|
+
* up — noisy graph retrieval, a cheap-model path — not on day one.
|
|
91
|
+
*/
|
|
92
|
+
const NEVER_SCAFFOLDED: [note: string, config: string][] = [
|
|
93
|
+
[
|
|
94
|
+
"A cheaper, faster model for auxiliary passes. Same shape as `model`.",
|
|
95
|
+
`fastModel: {
|
|
96
|
+
baseURL: process.env["CORTEX_FAST_MODEL_URL"]!,
|
|
97
|
+
apiKey: process.env["CORTEX_FAST_MODEL_KEY"]!,
|
|
98
|
+
modelName: process.env["CORTEX_FAST_MODEL_NAME"]!,
|
|
99
|
+
},`,
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
"Re-ranks knowledge-graph retrieval when it comes back noisy.",
|
|
103
|
+
`reranker: {
|
|
104
|
+
url: process.env["CORTEX_RERANKER_URL"]!,
|
|
105
|
+
apiKey: process.env["CORTEX_RERANKER_KEY"]!,
|
|
106
|
+
},`,
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
"Context-window tuning. Every field is defaulted, so override only what you\nhave measured a reason to change.",
|
|
110
|
+
`context: {
|
|
111
|
+
maxContextTokens: 120_000,
|
|
112
|
+
recentMessagesToKeep: 6,
|
|
113
|
+
},`,
|
|
114
|
+
],
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/** Fragments are authored at zero indentation and placed by these two. */
|
|
118
|
+
function indented(block: string) {
|
|
119
|
+
return block.replace(/^(?!$)/gm, " ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function commented(block: string) {
|
|
123
|
+
return block.replace(/^(?!$)/gm, " // ");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A note, then its fragment — written live, or written disabled. */
|
|
127
|
+
function block(note: string, config: string, enabled: boolean) {
|
|
128
|
+
return `\n${commented(note)}\n${enabled ? indented(config) : commented(config)}\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function cortexConfig(database: Database, features: Feature[]) {
|
|
132
|
+
const fragment = (feature: Feature) =>
|
|
133
|
+
block(FEATURES[feature].note, FEATURES[feature].config, features.includes(feature));
|
|
134
|
+
|
|
135
|
+
const on = FEATURE_NAMES.filter((feature) => features.includes(feature))
|
|
136
|
+
.map(fragment)
|
|
137
|
+
.join("");
|
|
138
|
+
const off = FEATURE_NAMES.filter((feature) => !features.includes(feature))
|
|
139
|
+
.map(fragment)
|
|
140
|
+
.join("");
|
|
141
|
+
const never = NEVER_SCAFFOLDED.map(([note, config]) => block(note, config, false)).join("");
|
|
142
|
+
|
|
143
|
+
// cspell.json ships an empty word list on purpose, so the Cortex jargon the
|
|
144
|
+
// config names teaches the checker from the file that uses it.
|
|
145
|
+
return `import { defineAgent } from "@m6d/cortex-server";
|
|
146
|
+
import type { CortexConfig } from "@m6d/cortex-server";
|
|
147
|
+
|
|
148
|
+
// cspell:words reranker
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Inert on purpose: this file default-exports data and nothing else, so importing
|
|
152
|
+
* it never opens a connection or binds a port. Construction lives in index.ts,
|
|
153
|
+
* which is what lets tooling read this config without booting the server.
|
|
154
|
+
*/
|
|
155
|
+
export default {
|
|
156
|
+
port: 3331,
|
|
157
|
+
database: {
|
|
158
|
+
type: "${database}",
|
|
159
|
+
connectionString: process.env["CORTEX_DATABASE_URL"]!,
|
|
160
|
+
},
|
|
161
|
+
model: {
|
|
162
|
+
baseURL: process.env["CORTEX_MODEL_URL"]!,
|
|
163
|
+
apiKey: process.env["CORTEX_MODEL_KEY"]!,
|
|
164
|
+
modelName: process.env["CORTEX_MODEL_NAME"]!,
|
|
165
|
+
},
|
|
166
|
+
${on === "" ? "" : `${on}\n`} agents: {
|
|
167
|
+
assistant: defineAgent({
|
|
168
|
+
systemPrompt: "You are a helpful assistant.",
|
|
169
|
+
// Applications that serve datasets for grouped questions (see the
|
|
170
|
+
// README's "Dataset hosts"): the host applies its own access rules,
|
|
171
|
+
// the agent only picks which datasets to expose and pin.
|
|
172
|
+
// datasetHosts: [{ name: "store", url: "https://store.example/api/datasets", pinned: ["orders"] }],
|
|
173
|
+
}),
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Everything below is off, written out so the file documents what else the
|
|
177
|
+
// server does. Uncomment a block and fill in its keys in .env to enable it.
|
|
178
|
+
${off}${never}} satisfies CortexConfig;
|
|
179
|
+
`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether this selection runs anything of its own — which is the same question as
|
|
184
|
+
* "did any port get chosen", since a chosen port is always some service's.
|
|
185
|
+
*/
|
|
186
|
+
function hasServices(database: Database, features: Feature[]) {
|
|
187
|
+
return (
|
|
188
|
+
database === "postgres" ||
|
|
189
|
+
features.some((feature) => FEATURES[feature].compose !== undefined)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function env(database: Database, features: Feature[]) {
|
|
194
|
+
const blocks = FEATURE_NAMES.filter((feature) => features.includes(feature)).map(
|
|
195
|
+
(feature) => `\n${FEATURES[feature].env}\n`,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Only the URLs pointing at scaffolded services carry a chosen port, and those
|
|
199
|
+
// are exactly the ones docker-compose.yml publishes.
|
|
200
|
+
const note = hasServices(database, features)
|
|
201
|
+
? `#
|
|
202
|
+
# The service ports below are not the canonical ones. They were picked when this
|
|
203
|
+
# project was scaffolded, from what was free on the machine, so it does not
|
|
204
|
+
# collide with anything else you run. Change them freely — in step with
|
|
205
|
+
# docker-compose.yml.
|
|
206
|
+
`
|
|
207
|
+
: "";
|
|
208
|
+
|
|
209
|
+
return `# Environment for this Cortex server. Bun loads this file from the directory you
|
|
210
|
+
# run in, so it has to sit next to cortex.config.ts.
|
|
211
|
+
#
|
|
212
|
+
# .env.example is the committed copy. Keep the two in step; keep secrets in .env.
|
|
213
|
+
${note}
|
|
214
|
+
# --- Model -------------------------------------------------------------------
|
|
215
|
+
# Any OpenAI-compatible endpoint: base URL, key, and model name.
|
|
216
|
+
CORTEX_MODEL_URL=https://api.openai.com/v1
|
|
217
|
+
CORTEX_MODEL_KEY=your-model-api-key
|
|
218
|
+
CORTEX_MODEL_NAME=your-model-name
|
|
219
|
+
|
|
220
|
+
# --- Database ----------------------------------------------------------------
|
|
221
|
+
# The dialect is ${database}, set in cortex.config.ts. Change it there, not here.
|
|
222
|
+
CORTEX_DATABASE_URL=${CONNECTION_STRING[database]}
|
|
223
|
+
${blocks.join("")}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Services the selections actually need, and nothing else. `auth` and the
|
|
228
|
+
* Control Center are external, the embedding endpoint is a URL like the main
|
|
229
|
+
* model, and MSSQL is normally an existing instance — so with `--yes --database
|
|
230
|
+
* mssql` there is no service at all, and no file is written.
|
|
231
|
+
*/
|
|
232
|
+
function dockerCompose(name: string, database: Database, features: Feature[]) {
|
|
233
|
+
const services: string[] = [];
|
|
234
|
+
const volumes: string[] = [];
|
|
235
|
+
|
|
236
|
+
if (database === "postgres") {
|
|
237
|
+
services.push(` postgres:
|
|
238
|
+
image: postgres:17-alpine
|
|
239
|
+
environment:
|
|
240
|
+
POSTGRES_USER: postgres
|
|
241
|
+
POSTGRES_PASSWORD: postgres
|
|
242
|
+
POSTGRES_DB: cortex
|
|
243
|
+
ports:
|
|
244
|
+
- "{{port.postgres}}:5432"
|
|
245
|
+
volumes:
|
|
246
|
+
- postgres-data:/var/lib/postgresql/data`);
|
|
247
|
+
volumes.push("postgres-data");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const feature of FEATURE_NAMES.filter((feature) => features.includes(feature))) {
|
|
251
|
+
const { compose, volume } = FEATURES[feature];
|
|
252
|
+
if (compose === undefined) continue;
|
|
253
|
+
services.push(compose);
|
|
254
|
+
if (volume !== undefined) volumes.push(volume);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (services.length === 0) return undefined;
|
|
258
|
+
|
|
259
|
+
const volumeBlock =
|
|
260
|
+
volumes.length === 0
|
|
261
|
+
? ""
|
|
262
|
+
: `\nvolumes:\n${volumes.map((name) => ` ${name}:\n`).join("")}`;
|
|
263
|
+
|
|
264
|
+
return `# Local infrastructure for this project. The .env this was scaffolded with
|
|
265
|
+
# already points at these, so \`docker compose up -d\` is enough.
|
|
266
|
+
#
|
|
267
|
+
# The host ports were picked at scaffold time from what was free on the machine —
|
|
268
|
+
# the container ports next to them are the canonical ones and should stay put.
|
|
269
|
+
# Move a host port and .env has to follow.
|
|
270
|
+
|
|
271
|
+
# Explicit so the project never collides with another checkout whose directory
|
|
272
|
+
# happens to share this one's name (Compose defaults to the directory name, and
|
|
273
|
+
# a name clash silently replaces the other project's containers and volumes).
|
|
274
|
+
name: ${name}-cortex-server
|
|
275
|
+
|
|
276
|
+
services:
|
|
277
|
+
${services.join("\n\n")}
|
|
278
|
+
${volumeBlock}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `--knowledge` scaffolds the directory `domainsDir` defaults to, which is what
|
|
283
|
+
* makes the convention true by construction: `cortex swagger sync` finds it with
|
|
284
|
+
* no config key.
|
|
285
|
+
*/
|
|
286
|
+
|
|
287
|
+
function cspellConfig() {
|
|
288
|
+
return `${JSON.stringify(
|
|
289
|
+
{
|
|
290
|
+
version: "0.2",
|
|
291
|
+
language: "en",
|
|
292
|
+
// Yours to fill in. Project jargon belongs here; nothing is seeded,
|
|
293
|
+
// because a stranger's vocabulary is noise in your repo.
|
|
294
|
+
words: [],
|
|
295
|
+
ignorePaths: [
|
|
296
|
+
"node_modules/**",
|
|
297
|
+
"out/**",
|
|
298
|
+
"build/**",
|
|
299
|
+
"**/dist/**",
|
|
300
|
+
"coverage/**",
|
|
301
|
+
"*.tsbuildinfo",
|
|
302
|
+
"bun.lock",
|
|
303
|
+
"package-lock.json",
|
|
304
|
+
"pnpm-lock.yaml",
|
|
305
|
+
"yarn.lock",
|
|
306
|
+
"*.db",
|
|
307
|
+
"*.sqlite",
|
|
308
|
+
"scratch.*",
|
|
309
|
+
".env*",
|
|
310
|
+
"**/*.min.js",
|
|
311
|
+
"**/*.min.css",
|
|
312
|
+
"**/db/migrations/**",
|
|
313
|
+
"**/i18n/**",
|
|
314
|
+
"docs",
|
|
315
|
+
".claude",
|
|
316
|
+
".angular",
|
|
317
|
+
".vercel/**",
|
|
318
|
+
"vendor/**",
|
|
319
|
+
".gitignore",
|
|
320
|
+
"**/domains/**/*.concept.ts",
|
|
321
|
+
],
|
|
322
|
+
dictionaries: [
|
|
323
|
+
"typescript",
|
|
324
|
+
"node",
|
|
325
|
+
"npm",
|
|
326
|
+
"html",
|
|
327
|
+
"css",
|
|
328
|
+
"bash",
|
|
329
|
+
"companies",
|
|
330
|
+
"softwareTerms",
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
null,
|
|
334
|
+
4,
|
|
335
|
+
)}\n`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function readme(name: string, database: Database, features: Feature[], compose: boolean) {
|
|
339
|
+
const enabled = FEATURE_NAMES.filter((feature) => features.includes(feature));
|
|
340
|
+
|
|
341
|
+
return `# ${name}
|
|
342
|
+
|
|
343
|
+
A Cortex server: one agent, a ${database} database, and an OpenAI-compatible model.${
|
|
344
|
+
enabled.length === 0 ? "" : ` Also on: ${enabled.join(", ")}.`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
## Getting started
|
|
348
|
+
|
|
349
|
+
\`\`\`sh
|
|
350
|
+
bun install
|
|
351
|
+
${compose ? "docker compose up -d\n" : ""}# fill in CORTEX_MODEL_* in .env
|
|
352
|
+
bun run dev
|
|
353
|
+
\`\`\`
|
|
354
|
+
|
|
355
|
+
The server listens on http://localhost:3331. Change \`port\` in \`cortex.config.ts\` to move it.${
|
|
356
|
+
compose
|
|
357
|
+
? `
|
|
358
|
+
|
|
359
|
+
The service ports in \`docker-compose.yml\` are not the canonical ones: they were picked at scaffold
|
|
360
|
+
time from what was free on this machine, so this project does not fight whatever else you run.
|
|
361
|
+
\`.env\` already points at them.`
|
|
362
|
+
: ""
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
## Layout
|
|
366
|
+
|
|
367
|
+
- \`cortex.config.ts\` — inert data. It default-exports a \`CortexConfig\` and never constructs
|
|
368
|
+
anything, so tooling can import it without booting a server.
|
|
369
|
+
- \`index.ts\` — construction. It reads the config, calls \`createCortex\`, serves, and is where your
|
|
370
|
+
own routes go.
|
|
371
|
+
- \`.env\` — local secrets, loaded by Bun from the directory you run in. \`.env.example\` is the
|
|
372
|
+
committed copy.
|
|
373
|
+
|
|
374
|
+
Commented-out sections in \`cortex.config.ts\` document what else the server can do.
|
|
375
|
+
|
|
376
|
+
## Checks
|
|
377
|
+
|
|
378
|
+
\`bun run verify\` runs formatting, lint, spelling, types, and the fallow audit — the same set a
|
|
379
|
+
pre-commit hook or CI job should run. Each is also available on its own: \`format:check\`, \`lint\`,
|
|
380
|
+
\`spellcheck\`, \`check\`, \`audit\`.
|
|
381
|
+
`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Relative path → contents. Nothing here touches the filesystem. */
|
|
385
|
+
export function scaffoldFiles(name: string, database: Database, features: Feature[], ports: Ports) {
|
|
386
|
+
const envFile = env(database, features);
|
|
387
|
+
const compose = dockerCompose(name, database, features);
|
|
388
|
+
|
|
389
|
+
const files: Record<string, string> = {
|
|
390
|
+
"cortex.config.ts": cortexConfig(database, features),
|
|
391
|
+
"index.ts": indexTs,
|
|
392
|
+
"package.json": packageJson(name),
|
|
393
|
+
"tsconfig.json": tsconfigJson,
|
|
394
|
+
".env": envFile,
|
|
395
|
+
".env.example": envFile,
|
|
396
|
+
".gitignore": gitignoreText,
|
|
397
|
+
"README.md": readme(name, database, features, compose !== undefined),
|
|
398
|
+
"eslint.config.mjs": eslintConfig,
|
|
399
|
+
"prettier.config.mjs": prettierConfig,
|
|
400
|
+
"cspell.json": cspellConfig(),
|
|
401
|
+
".fallowrc.jsonc": fallowrc,
|
|
402
|
+
...(compose === undefined ? {} : { "docker-compose.yml": compose }),
|
|
403
|
+
...(features.includes("knowledge") ? { "src/domains/README.md": domainsReadme } : {}),
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// One pass at the end, over everything: no generator holds a port number of
|
|
407
|
+
// its own, so compose, .env and the README cannot disagree about one.
|
|
408
|
+
return Object.fromEntries(
|
|
409
|
+
Object.entries(files).map(([path, contents]) => [path, applyPorts(contents, ports)]),
|
|
410
|
+
);
|
|
411
|
+
}
|