@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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The embed ↔ widget handshake for client tools.
|
|
3
|
+
*
|
|
4
|
+
* A page embedded by the chat widget (a hosted checkout, signing page, …)
|
|
5
|
+
* reports its outcome with:
|
|
6
|
+
*
|
|
7
|
+
* window.parent.postMessage(
|
|
8
|
+
* { type: "cortex:client-tool", status: "completed", reference: "session_123" },
|
|
9
|
+
* "*",
|
|
10
|
+
* );
|
|
11
|
+
*
|
|
12
|
+
* The widget accepts the message only when the event's origin matches the
|
|
13
|
+
* tool's configured `embedOrigin`. The payload is the page's claim, relayed to
|
|
14
|
+
* the agent as-is: cortex does not verify it, and anything that must actually
|
|
15
|
+
* be true lives in the integrating backend's own records.
|
|
16
|
+
*
|
|
17
|
+
* Like the wire seam, this file is compiled into the client SDKs and must stay
|
|
18
|
+
* free of runtime dependencies. It is also the spec any future SDK (Flutter)
|
|
19
|
+
* reimplements against.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const CLIENT_TOOL_MESSAGE_TYPE = "cortex:client-tool";
|
|
23
|
+
|
|
24
|
+
export const CLIENT_TOOL_STATUSES = ["completed", "cancelled", "failed"] as const;
|
|
25
|
+
|
|
26
|
+
export type ClientToolStatus = (typeof CLIENT_TOOL_STATUSES)[number];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the browser answers a client tool call with, whoever produced it: an
|
|
30
|
+
* embedded page's handshake or one of the widget's own input components.
|
|
31
|
+
* The model reads this envelope as the tool result.
|
|
32
|
+
*/
|
|
33
|
+
export type ClientToolOutcome<TResult = unknown> = {
|
|
34
|
+
status: ClientToolStatus;
|
|
35
|
+
/** Opaque id for the integrating backend's own bookkeeping (session id, envelope id, …). */
|
|
36
|
+
reference?: string;
|
|
37
|
+
/** Optional payload passed through to the agent as part of the tool result. */
|
|
38
|
+
result?: TResult;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ClientToolHandshake = ClientToolOutcome & {
|
|
42
|
+
type: typeof CLIENT_TOOL_MESSAGE_TYPE;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* Built-in input tools: static client tools the server package exports and
|
|
47
|
+
* the widget answers with a component of its own, keyed by tool name. A
|
|
48
|
+
* consumer's `toolComponents` entry under the same name replaces the built-in.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export const REQUEST_LOCATION_TOOL = "requestLocation";
|
|
52
|
+
|
|
53
|
+
export type LatLng = { lat: number; lng: number };
|
|
54
|
+
|
|
55
|
+
/** The model's arguments: where the map opens; a world view when omitted. */
|
|
56
|
+
export type RequestLocationInput = { center?: LatLng };
|
|
57
|
+
|
|
58
|
+
/** The picked point, with the browser's accuracy radius in metres when it came from geolocation. */
|
|
59
|
+
export type LocationResult = LatLng & { accuracy?: number };
|
|
60
|
+
|
|
61
|
+
export type RequestLocationOutcome = ClientToolOutcome<LocationResult>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a `message` event into a handshake, or null when the origin doesn't
|
|
65
|
+
* match the tool's `embedOrigin` or the payload isn't a well-formed handshake.
|
|
66
|
+
* Call from the widget's `message` listener with `event.data` / `event.origin`.
|
|
67
|
+
*/
|
|
68
|
+
export function parseClientToolHandshake(data: unknown, origin: string, embedOrigin: string) {
|
|
69
|
+
if (origin !== embedOrigin) return null;
|
|
70
|
+
if (typeof data !== "object" || data === null) return null;
|
|
71
|
+
const message = data as Record<string, unknown>;
|
|
72
|
+
if (message["type"] !== CLIENT_TOOL_MESSAGE_TYPE) return null;
|
|
73
|
+
const status = CLIENT_TOOL_STATUSES.find((known) => known === message["status"]);
|
|
74
|
+
if (!status) return null;
|
|
75
|
+
const reference = message["reference"];
|
|
76
|
+
return {
|
|
77
|
+
type: CLIENT_TOOL_MESSAGE_TYPE,
|
|
78
|
+
status,
|
|
79
|
+
...(typeof reference === "string" ? { reference } : {}),
|
|
80
|
+
...(message["result"] !== undefined ? { result: message["result"] } : {}),
|
|
81
|
+
} satisfies ClientToolHandshake;
|
|
82
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import type { LookupFunction } from "node:net";
|
|
4
|
+
import { Agent } from "undici";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The outbound network policy for URLs a tenant controls (tool endpoints,
|
|
8
|
+
* per-agent JWKS URIs): private, loopback, link-local, and metadata ranges are
|
|
9
|
+
* refused, and DNS is pinned so a rebind cannot slip a private address past
|
|
10
|
+
* the check, and credentials an answer echoes back are redacted from it.
|
|
11
|
+
* Shared by cc and the server, vendored like the rest of this package.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { credentialForms, scrubCredentialStrings } from "./scrub";
|
|
15
|
+
|
|
16
|
+
type Range = { first: number; last: number };
|
|
17
|
+
|
|
18
|
+
/** IPv4 /8 and /16 blocks that never denote a public host, as `[a, b]` octet ranges. */
|
|
19
|
+
const BLOCKED_V4 = [
|
|
20
|
+
{ a: 0 },
|
|
21
|
+
{ a: 10 },
|
|
22
|
+
{ a: 127 },
|
|
23
|
+
{ a: 100, b: { first: 64, last: 127 } },
|
|
24
|
+
{ a: 169, b: { first: 254, last: 254 } },
|
|
25
|
+
{ a: 172, b: { first: 16, last: 31 } },
|
|
26
|
+
{ a: 192, b: { first: 168, last: 168 } },
|
|
27
|
+
{ a: 198, b: { first: 18, last: 19 } },
|
|
28
|
+
] satisfies Array<{ a: number; b?: Range }>;
|
|
29
|
+
|
|
30
|
+
/** Multicast and reserved: everything from 224.0.0.0 up. */
|
|
31
|
+
const BLOCKED_V4_FROM = 224;
|
|
32
|
+
|
|
33
|
+
function isBlockedV4(address: string) {
|
|
34
|
+
const [a = 0, b = 0] = address.split(".").map(Number);
|
|
35
|
+
if (a >= BLOCKED_V4_FROM) return true;
|
|
36
|
+
return BLOCKED_V4.some(
|
|
37
|
+
(block) => block.a === a && (!block.b || (b >= block.b.first && b <= block.b.last)),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isBlockedV6(address: string) {
|
|
42
|
+
const normalized = address.toLowerCase();
|
|
43
|
+
if (normalized.startsWith("::ffff:")) {
|
|
44
|
+
const mapped = normalized.slice("::ffff:".length);
|
|
45
|
+
return isIP(mapped) === 4 ? isBlockedV4(mapped) : true;
|
|
46
|
+
}
|
|
47
|
+
return (
|
|
48
|
+
normalized === "::" ||
|
|
49
|
+
normalized === "::1" ||
|
|
50
|
+
normalized.startsWith("fc") ||
|
|
51
|
+
normalized.startsWith("fd") ||
|
|
52
|
+
/^fe[89ab]/.test(normalized)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether an IP address may be dialed. `EGRESS_ALLOW_PRIVATE_NETWORK=true` is
|
|
58
|
+
* the development opt-out for localhost and private demo backends; it disables
|
|
59
|
+
* every range check and must never be set in production.
|
|
60
|
+
*/
|
|
61
|
+
export function isBlockedAddress(address: string) {
|
|
62
|
+
if (process.env.EGRESS_ALLOW_PRIVATE_NETWORK === "true") return false;
|
|
63
|
+
return isIP(address) === 6 ? isBlockedV6(address) : isBlockedV4(address);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a credential may travel to this URL: over TLS anywhere, or over
|
|
68
|
+
* plain http to loopback alone, for a service on the developer's machine.
|
|
69
|
+
*/
|
|
70
|
+
export function isTlsOrLoopback(url: string) {
|
|
71
|
+
try {
|
|
72
|
+
const { protocol, hostname } = new URL(url);
|
|
73
|
+
if (protocol === "https:") return true;
|
|
74
|
+
return (
|
|
75
|
+
protocol === "http:" &&
|
|
76
|
+
(hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function egressError() {
|
|
84
|
+
const error: NodeJS.ErrnoException = new Error("egress blocked");
|
|
85
|
+
error.code = "EGRESS_BLOCKED";
|
|
86
|
+
return error;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when `error` or any of its causes is the egress refusal. */
|
|
90
|
+
export function isEgressError(error: unknown) {
|
|
91
|
+
for (let current = error; current instanceof Error; current = current.cause) {
|
|
92
|
+
if ((current as NodeJS.ErrnoException).code === "EGRESS_BLOCKED") return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Re-resolves the hostname and re-validates every candidate on every connection
|
|
99
|
+
* attempt, handing the socket only vetted public addresses. The address dialed
|
|
100
|
+
* is the exact one just checked, so a nameserver cannot return a public IP to
|
|
101
|
+
* the check and a private one to the connect. Server name / Host header stay
|
|
102
|
+
* the original hostname.
|
|
103
|
+
*/
|
|
104
|
+
const pinnedLookup: LookupFunction = (hostname, options, callback) => {
|
|
105
|
+
lookup(hostname, { all: true, verbatim: true }).then(
|
|
106
|
+
(addresses) => {
|
|
107
|
+
const first = addresses[0];
|
|
108
|
+
if (!first || addresses.some((address) => isBlockedAddress(address.address))) {
|
|
109
|
+
callback(egressError(), "", 0);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (options.all) {
|
|
113
|
+
callback(
|
|
114
|
+
null,
|
|
115
|
+
addresses.map((address) => ({
|
|
116
|
+
address: address.address,
|
|
117
|
+
family: address.family,
|
|
118
|
+
})),
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
callback(null, first.address, first.family);
|
|
123
|
+
},
|
|
124
|
+
(error: unknown) =>
|
|
125
|
+
callback(error instanceof Error ? error : new Error("lookup failed"), "", 0),
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks a URL before any socket opens: http(s) only, no embedded credentials,
|
|
131
|
+
* an optional exact-host allowlist, and every resolved address public. Returns
|
|
132
|
+
* the bare hostname on success. `pinnedLookup` re-checks at connect time; this
|
|
133
|
+
* is the cheap, clean refusal for a statically bad target.
|
|
134
|
+
*/
|
|
135
|
+
export async function checkEgressTarget(target: URL, allowedHostnames?: string[]) {
|
|
136
|
+
const hostname = target.hostname.replace(/^\[(.*)]$/, "$1");
|
|
137
|
+
const normalize = (host: string) => host.toLowerCase().replace(/\.$/, "");
|
|
138
|
+
|
|
139
|
+
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (allowedHostnames && !allowedHostnames.map(normalize).includes(normalize(hostname))) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const preflight = await lookup(hostname, { all: true, verbatim: true });
|
|
147
|
+
return preflight.length > 0 && !preflight.some((address) => isBlockedAddress(address.address));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* An undici dispatcher whose every connection resolves through `pinnedLookup`.
|
|
152
|
+
* Call the returned `release` once the response has been read; Bun's undici
|
|
153
|
+
* Agent may lack `close`, so release tolerates its absence.
|
|
154
|
+
*/
|
|
155
|
+
export function guardedDispatcher() {
|
|
156
|
+
const dispatcher = new Agent({ connect: { lookup: pinnedLookup } });
|
|
157
|
+
const release = () => {
|
|
158
|
+
if (typeof dispatcher.close === "function") void dispatcher.close().catch(() => undefined);
|
|
159
|
+
};
|
|
160
|
+
return { dispatcher, release };
|
|
161
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const REDACTION_MARKER = "[REDACTED]";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every spelling of one credential an upstream may echo back. The value
|
|
5
|
+
* itself and, after a leading scheme word (`Bearer xyz`, `Basic abc`), the
|
|
6
|
+
* bare secret the scheme merely labels; each of those raw as in a header,
|
|
7
|
+
* percent-encoded as in a path slot (`encodeURIComponent`) or a query value
|
|
8
|
+
* (`URLSearchParams`, which spells a space as `+`), and JSON-escaped as inside
|
|
9
|
+
* a request body. The scrubber replaces longer forms first.
|
|
10
|
+
*/
|
|
11
|
+
export function credentialForms(value: string) {
|
|
12
|
+
const bare = /^[A-Za-z][\w.-]* +(\S.*)$/.exec(value)?.[1];
|
|
13
|
+
return [
|
|
14
|
+
...new Set(
|
|
15
|
+
(bare ? [value, bare] : [value]).flatMap((form) => [
|
|
16
|
+
form,
|
|
17
|
+
encodeURIComponent(form),
|
|
18
|
+
new URLSearchParams({ v: form }).toString().slice(2),
|
|
19
|
+
JSON.stringify(form).slice(1, -1),
|
|
20
|
+
]),
|
|
21
|
+
),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The one boundary every upstream body crosses before it is stored or sent
|
|
27
|
+
* on, in cc and the server alike: U+0000 is dropped from keys and values
|
|
28
|
+
* because Postgres refuses it in text and jsonb on both cc's execution log
|
|
29
|
+
* and the server's transcript, then the credentials the request presented
|
|
30
|
+
* are redacted. Dropping first, on the patterns as well, means a credential
|
|
31
|
+
* an upstream spelled with U+0000 inside it still matches; redacting first
|
|
32
|
+
* would let the strip reassemble it.
|
|
33
|
+
*/
|
|
34
|
+
export function scrubCredentialStrings(value: unknown, credentials: string[]): unknown {
|
|
35
|
+
const dropU0000 = (text: string) => text.replaceAll("\u0000", "");
|
|
36
|
+
// Longest first: a credential's encoded spelling often contains its raw
|
|
37
|
+
// form, and replacing the raw form first would leave a mangled, still
|
|
38
|
+
// recognizable remainder of the longer one.
|
|
39
|
+
const ordered = [...new Set(credentials.map(dropU0000).filter(Boolean))].sort(
|
|
40
|
+
(a, b) => b.length - a.length,
|
|
41
|
+
);
|
|
42
|
+
const scrub = (text: string) =>
|
|
43
|
+
ordered.reduce(
|
|
44
|
+
(safe, credential) => safe.replaceAll(credential, REDACTION_MARKER),
|
|
45
|
+
dropU0000(text),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (typeof value === "string") return scrub(value);
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
return value.map((item) => scrubCredentialStrings(item, credentials));
|
|
51
|
+
}
|
|
52
|
+
if (typeof value !== "object" || value === null) return value;
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
Object.entries(value).map(([name, child]) => [
|
|
55
|
+
scrub(name),
|
|
56
|
+
scrubCredentialStrings(child, credentials),
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embeddings, which both halves of the graph need: `@m6d/cortex-cli` embeds every
|
|
3
|
+
* concept description as it seeds, `@m6d/cortex-server` embeds every prompt as it
|
|
4
|
+
* resolves. Two embedders would mean two vector spaces, so there is one. A bare
|
|
5
|
+
* `fetch` to the OpenAI-compatible `/embeddings` call keeps this package
|
|
6
|
+
* dependency-free. Everything under `graph/` compiles into an Angular library
|
|
7
|
+
* too, so it is written to the oldest target any consumer builds against.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Everything cortex needs to reach one OpenAI-compatible embeddings endpoint. */
|
|
11
|
+
export type EmbeddingProviderConfig = {
|
|
12
|
+
baseURL: string;
|
|
13
|
+
apiKey: string;
|
|
14
|
+
modelName: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Turns a batch of strings into their vectors, in the order given. */
|
|
18
|
+
export type EmbedFn = (values: string[]) => Promise<number[][]>;
|
|
19
|
+
|
|
20
|
+
export function createEmbedder(config: EmbeddingProviderConfig) {
|
|
21
|
+
const url = `${config.baseURL.replace(/\/+$/, "")}/embeddings`;
|
|
22
|
+
|
|
23
|
+
return async function embed(values: string[]) {
|
|
24
|
+
const response = await fetch(url, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({ model: config.modelName, input: values }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Embedding request failed (${response.status}): ${await response.text()}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The API is free to return entries out of order; `index` is authoritative.
|
|
40
|
+
const body = (await response.json()) as { data: { index: number; embedding: number[] }[] };
|
|
41
|
+
return [...body.data]
|
|
42
|
+
.sort((left, right) => left.index - right.index)
|
|
43
|
+
.map((entry) => entry.embedding);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neo4j over its HTTP transaction endpoint, shared by the graph's writer (the
|
|
3
|
+
* CLI seeder) and its reader (the server resolver). Configuration is explicit
|
|
4
|
+
* and the transport is `fetch`, so this package stays dependency-free.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EmbedFn } from "./embed";
|
|
8
|
+
|
|
9
|
+
export type Neo4jConfig = {
|
|
10
|
+
url: string;
|
|
11
|
+
user: string;
|
|
12
|
+
password: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type Neo4jRow = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
export type Neo4jClient = {
|
|
18
|
+
/** Runs one statement and returns its rows as column-keyed objects. Throws `Neo4jQueryError` on any failure. */
|
|
19
|
+
query(cypher: string, parameters?: Record<string, unknown>): Promise<Neo4jRow[]>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class Neo4jQueryError extends Error {
|
|
23
|
+
public constructor(
|
|
24
|
+
message: string,
|
|
25
|
+
/** Neo4j's error code (`Neo.ClientError.…`), or the HTTP status when the request itself failed. */
|
|
26
|
+
public readonly code: string,
|
|
27
|
+
) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "Neo4jQueryError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parameters whose key starts with `#` are embedded before the statement runs:
|
|
35
|
+
* `{ "#embedding": "text" }` becomes `{ embedding: number[] }`.
|
|
36
|
+
*/
|
|
37
|
+
export function createNeo4jClient(config: Neo4jConfig, embed?: EmbedFn) {
|
|
38
|
+
async function query(cypher: string, parameters: Record<string, unknown> = {}) {
|
|
39
|
+
const embeddedKeys = Object.keys(parameters).filter((key) => key.startsWith("#"));
|
|
40
|
+
if (embeddedKeys.length) {
|
|
41
|
+
if (!embed) throw new Error("An embedder is required when using # parameters");
|
|
42
|
+
const embeddings = await embed(embeddedKeys.map((key) => parameters[key] as string));
|
|
43
|
+
embeddedKeys.forEach((key, index) => {
|
|
44
|
+
delete parameters[key];
|
|
45
|
+
parameters[key.slice(1)] = embeddings[index]!;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const response = await fetch(`${config.url}/db/neo4j/tx/commit`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
Authorization: `Basic ${btoa(`${config.user}:${config.password}`)}`,
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({ statements: [{ statement: cypher, parameters }] }),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Neo4jQueryError(
|
|
59
|
+
`Neo4j request failed with status ${response.status}`,
|
|
60
|
+
String(response.status),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = (await response.json()) as {
|
|
65
|
+
errors?: { code?: string; message: string }[];
|
|
66
|
+
results: { columns: string[]; data: { row: unknown[] }[] }[];
|
|
67
|
+
};
|
|
68
|
+
if (result.errors?.length) {
|
|
69
|
+
throw new Neo4jQueryError(
|
|
70
|
+
result.errors.map((error) => error.message).join("; "),
|
|
71
|
+
result.errors[0]!.code ?? "unknown",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const queryResult = result.results[0];
|
|
76
|
+
if (!queryResult) return [];
|
|
77
|
+
return queryResult.data.map((entry) =>
|
|
78
|
+
Object.fromEntries(
|
|
79
|
+
queryResult.columns.map((column, index) => [column, entry.row[index]]),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { query } satisfies Neo4jClient;
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper functions for building knowledge graph seed data. Projects author
|
|
3
|
+
* domains with these; `@m6d/cortex-server` re-exports them so authoring needs
|
|
4
|
+
* exactly one import path.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ConceptDef,
|
|
9
|
+
DomainDef,
|
|
10
|
+
EndpointDef,
|
|
11
|
+
EndpointInput,
|
|
12
|
+
RuleDef,
|
|
13
|
+
ServiceDef,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
export function defineConcept(def: Omit<ConceptDef, "__brand">) {
|
|
17
|
+
return { __brand: "concept" as const, ...def };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function defineRule(def: Omit<RuleDef, "__brand">) {
|
|
21
|
+
return { __brand: "rule" as const, ...def };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defineService(def: Omit<ServiceDef, "__brand">) {
|
|
25
|
+
return { __brand: "service" as const, ...def };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function defineDomain(def: Omit<DomainDef, "__brand">) {
|
|
29
|
+
return { __brand: "domain" as const, ...def };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function defineEndpoint(input: EndpointInput) {
|
|
33
|
+
const propertiesDescriptions = input.propertiesDescriptions ?? {};
|
|
34
|
+
|
|
35
|
+
const normalized = input.autoGenerated;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
__brand: "endpoint" as const,
|
|
39
|
+
name: input.name,
|
|
40
|
+
description: input.description ?? input.name,
|
|
41
|
+
path: input.path,
|
|
42
|
+
method: input.method,
|
|
43
|
+
propertiesDescriptions,
|
|
44
|
+
params: normalized ? [...normalized.params] : [],
|
|
45
|
+
body: normalized ? [...normalized.body] : [],
|
|
46
|
+
response: normalized ? [...normalized.response] : [],
|
|
47
|
+
successStatus: normalized?.successStatus ?? 200,
|
|
48
|
+
errorStatuses: normalized ? [...normalized.errorStatuses] : [],
|
|
49
|
+
responseKind: normalized?.responseKind ?? "object",
|
|
50
|
+
queries: input.queries,
|
|
51
|
+
mutates: input.mutates,
|
|
52
|
+
returns: input.returns,
|
|
53
|
+
dependsOn: input.dependsOn,
|
|
54
|
+
governedBy: input.governedBy,
|
|
55
|
+
metadata: input.metadata,
|
|
56
|
+
} satisfies EndpointDef;
|
|
57
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph contract — the knowledge-graph vocabulary, authoring types and
|
|
3
|
+
* clients that `@m6d/cortex-cli` writes with and `@m6d/cortex-server` reads
|
|
4
|
+
* with. `@m6d/cortex-server` re-exports all of it, so a project authoring
|
|
5
|
+
* domains still has exactly one import path.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { GraphSchema } from "./schema";
|
|
9
|
+
export { GRAPH_SCHEMA, GRAPH_SCHEMA_VERSION } from "./schema";
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
EndpointScalarType,
|
|
13
|
+
EndpointProperty,
|
|
14
|
+
ResponseKind,
|
|
15
|
+
AutoGenerated,
|
|
16
|
+
ConceptDef,
|
|
17
|
+
EndpointDef,
|
|
18
|
+
EndpointInput,
|
|
19
|
+
ServiceDef,
|
|
20
|
+
RuleDef,
|
|
21
|
+
DomainDef,
|
|
22
|
+
} from "./types";
|
|
23
|
+
|
|
24
|
+
export { defineConcept, defineRule, defineService, defineDomain, defineEndpoint } from "./helpers";
|
|
25
|
+
|
|
26
|
+
export type { Neo4jConfig, Neo4jClient, Neo4jRow } from "./clients/neo4j";
|
|
27
|
+
export { createNeo4jClient, Neo4jQueryError } from "./clients/neo4j";
|
|
28
|
+
|
|
29
|
+
export type { EmbedFn, EmbeddingProviderConfig } from "./clients/embed";
|
|
30
|
+
export { createEmbedder } from "./clients/embed";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract between the graph's writer and its reader.
|
|
3
|
+
*
|
|
4
|
+
* Seeding lives in `@m6d/cortex-cli` and resolution lives in `@m6d/cortex-server`,
|
|
5
|
+
* two packages a project installs and upgrades separately. The labels, relationship
|
|
6
|
+
* types, property names and index name they agree on are this module, here, so
|
|
7
|
+
* neither side owns the vocabulary the other has to match.
|
|
8
|
+
*
|
|
9
|
+
* A mismatch between the two is invisible at runtime: the resolver's Cypher
|
|
10
|
+
* matches nothing and retrieval comes back empty rather than failing. That is what
|
|
11
|
+
* `GRAPH_SCHEMA_VERSION` is for — bump it on any change to the shape below. The
|
|
12
|
+
* CLI stamps the version it wrote onto the graph and the server checks that stamp
|
|
13
|
+
* on its first resolve, so skew surfaces as an error instead of as silence.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const GRAPH_SCHEMA_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
export const GRAPH_SCHEMA = {
|
|
19
|
+
label: {
|
|
20
|
+
domain: "Domain",
|
|
21
|
+
concept: "Concept",
|
|
22
|
+
endpoint: "Endpoint",
|
|
23
|
+
service: "Service",
|
|
24
|
+
rule: "Rule",
|
|
25
|
+
/** Stamped with the version above, so a stale graph is detectable later. */
|
|
26
|
+
marker: "CortexGraph",
|
|
27
|
+
},
|
|
28
|
+
rel: {
|
|
29
|
+
belongsTo: "BELONGS_TO",
|
|
30
|
+
specializes: "SPECIALIZES",
|
|
31
|
+
queriedVia: "QUERIED_VIA",
|
|
32
|
+
mutatedVia: "MUTATED_VIA",
|
|
33
|
+
returns: "RETURNS",
|
|
34
|
+
dependsOn: "DEPENDS_ON",
|
|
35
|
+
governs: "GOVERNS",
|
|
36
|
+
requestedVia: "REQUESTED_VIA",
|
|
37
|
+
},
|
|
38
|
+
prop: {
|
|
39
|
+
name: "name",
|
|
40
|
+
description: "description",
|
|
41
|
+
aliases: "aliases",
|
|
42
|
+
metadata: "metadata",
|
|
43
|
+
path: "path",
|
|
44
|
+
method: "method",
|
|
45
|
+
params: "params",
|
|
46
|
+
body: "body",
|
|
47
|
+
response: "response",
|
|
48
|
+
propertiesDescriptions: "propertiesDescriptions",
|
|
49
|
+
successStatus: "successStatus",
|
|
50
|
+
errorStatuses: "errorStatuses",
|
|
51
|
+
builtInId: "builtInId",
|
|
52
|
+
embedding: "embedding",
|
|
53
|
+
/** On a RETURNS edge: which part of the response carries the concept. */
|
|
54
|
+
field: "field",
|
|
55
|
+
/** On a DEPENDS_ON edge. */
|
|
56
|
+
paramName: "paramName",
|
|
57
|
+
fromField: "fromField",
|
|
58
|
+
/** On the marker node. */
|
|
59
|
+
version: "version",
|
|
60
|
+
},
|
|
61
|
+
/** Created by the CLI's seeder, queried by name in the resolver's first hop. */
|
|
62
|
+
conceptEmbeddingsIndex: "concept_embeddings",
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export type GraphSchema = typeof GRAPH_SCHEMA;
|