@m6d/cortex-server 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 +64 -0
- package/dist/index.d.ts +1 -0
- package/dist/src/adapters/database.d.ts +27 -0
- package/dist/src/adapters/minio.d.ts +10 -0
- package/dist/src/adapters/mssql.d.ts +3 -0
- package/dist/src/adapters/storage.d.ts +6 -0
- package/dist/src/ai/fetch.d.ts +2 -0
- package/dist/src/ai/helpers.d.ts +5 -0
- package/dist/src/ai/index.d.ts +4 -0
- package/dist/src/ai/interceptors/resolve-captured-files.d.ts +11 -0
- package/dist/src/ai/prompt.d.ts +4 -0
- package/dist/src/ai/tools/call-endpoint.tool.d.ts +7 -0
- package/dist/src/ai/tools/capture-files.tool.d.ts +6 -0
- package/dist/src/ai/tools/execute-code.tool.d.ts +4 -0
- package/dist/src/ai/tools/query-graph.tool.d.ts +5 -0
- package/dist/src/auth/middleware.d.ts +4 -0
- package/dist/src/cli/extract-endpoints.d.ts +6 -0
- package/dist/src/config.d.ts +145 -0
- package/dist/src/db/migrate.d.ts +1 -0
- package/dist/src/db/schema.d.ts +345 -0
- package/dist/src/factory.d.ts +17 -0
- package/dist/src/graph/generate-cypher.d.ts +22 -0
- package/dist/src/graph/helpers.d.ts +60 -0
- package/dist/src/graph/index.d.ts +11 -0
- package/dist/src/graph/neo4j.d.ts +18 -0
- package/dist/src/graph/resolver.d.ts +51 -0
- package/dist/src/graph/seed.d.ts +19 -0
- package/dist/src/graph/types.d.ts +104 -0
- package/dist/src/graph/validate.d.ts +2 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/routes/chat.d.ts +3 -0
- package/dist/src/routes/files.d.ts +3 -0
- package/dist/src/routes/index.d.ts +4 -0
- package/dist/src/routes/threads.d.ts +3 -0
- package/dist/src/routes/ws.d.ts +3 -0
- package/dist/src/types.d.ts +56 -0
- package/dist/src/ws/connections.d.ts +4 -0
- package/dist/src/ws/events.d.ts +8 -0
- package/dist/src/ws/index.d.ts +3 -0
- package/dist/src/ws/notify.d.ts +2 -0
- package/index.ts +1 -0
- package/package.json +57 -0
- package/src/adapters/database.ts +33 -0
- package/src/adapters/minio.ts +89 -0
- package/src/adapters/mssql.ts +203 -0
- package/src/adapters/storage.ts +6 -0
- package/src/ai/fetch.ts +39 -0
- package/src/ai/helpers.ts +36 -0
- package/src/ai/index.ts +145 -0
- package/src/ai/interceptors/resolve-captured-files.ts +64 -0
- package/src/ai/prompt.ts +120 -0
- package/src/ai/tools/call-endpoint.tool.ts +96 -0
- package/src/ai/tools/capture-files.tool.ts +22 -0
- package/src/ai/tools/execute-code.tool.ts +108 -0
- package/src/ai/tools/query-graph.tool.ts +35 -0
- package/src/auth/middleware.ts +63 -0
- package/src/cli/extract-endpoints.ts +588 -0
- package/src/config.ts +155 -0
- package/src/db/migrate.ts +21 -0
- package/src/db/migrations/20260309012148_cloudy_maria_hill/migration.sql +36 -0
- package/src/db/migrations/20260309012148_cloudy_maria_hill/snapshot.json +305 -0
- package/src/db/schema.ts +77 -0
- package/src/factory.ts +159 -0
- package/src/graph/generate-cypher.ts +179 -0
- package/src/graph/helpers.ts +68 -0
- package/src/graph/index.ts +47 -0
- package/src/graph/neo4j.ts +117 -0
- package/src/graph/resolver.ts +357 -0
- package/src/graph/seed.ts +172 -0
- package/src/graph/types.ts +152 -0
- package/src/graph/validate.ts +80 -0
- package/src/index.ts +27 -0
- package/src/routes/chat.ts +38 -0
- package/src/routes/files.ts +105 -0
- package/src/routes/index.ts +4 -0
- package/src/routes/threads.ts +69 -0
- package/src/routes/ws.ts +33 -0
- package/src/types.ts +50 -0
- package/src/ws/connections.ts +23 -0
- package/src/ws/events.ts +6 -0
- package/src/ws/index.ts +7 -0
- package/src/ws/notify.ts +9 -0
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ToolSet,
|
|
3
|
+
convertToModelMessages,
|
|
4
|
+
generateId,
|
|
5
|
+
generateText,
|
|
6
|
+
safeValidateUIMessages,
|
|
7
|
+
streamText,
|
|
8
|
+
} from "ai";
|
|
9
|
+
import { HTTPException } from "hono/http-exception";
|
|
10
|
+
import type { ResolvedCortexAgentConfig } from "../config.ts";
|
|
11
|
+
import type { Thread } from "../types.ts";
|
|
12
|
+
import { createModel, createEmbeddingModel } from "./helpers.ts";
|
|
13
|
+
import { buildSystemPrompt } from "./prompt.ts";
|
|
14
|
+
import { createQueryGraphTool } from "./tools/query-graph.tool.ts";
|
|
15
|
+
import { createCallEndpointTool } from "./tools/call-endpoint.tool.ts";
|
|
16
|
+
import { createExecuteCodeTool } from "./tools/execute-code.tool.ts";
|
|
17
|
+
import { captureFilesTool } from "./tools/capture-files.tool.ts";
|
|
18
|
+
import { createCapturedFileInterceptor } from "./interceptors/resolve-captured-files.ts";
|
|
19
|
+
import { createNeo4jClient } from "../graph/neo4j.ts";
|
|
20
|
+
import { resolveFromGraph } from "../graph/resolver.ts";
|
|
21
|
+
import { notify } from "../ws/index.ts";
|
|
22
|
+
|
|
23
|
+
export async function stream(
|
|
24
|
+
messages: unknown[],
|
|
25
|
+
thread: Thread,
|
|
26
|
+
userId: string,
|
|
27
|
+
token: string,
|
|
28
|
+
config: ResolvedCortexAgentConfig,
|
|
29
|
+
) {
|
|
30
|
+
const validationResult = await safeValidateUIMessages({ messages });
|
|
31
|
+
if (!validationResult.success) {
|
|
32
|
+
throw new HTTPException(423, { message: "Invalid messages format" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const validatedMessages = validationResult.data;
|
|
36
|
+
await config.db.messages.upsert(thread.id, validatedMessages);
|
|
37
|
+
|
|
38
|
+
const originalMessages = await config.db.messages
|
|
39
|
+
.list(userId, thread.id, { limit: 20 })
|
|
40
|
+
.then((x) => x.map((y) => y.content));
|
|
41
|
+
|
|
42
|
+
const recentMessages = await convertToModelMessages(originalMessages);
|
|
43
|
+
|
|
44
|
+
const prompt =
|
|
45
|
+
originalMessages
|
|
46
|
+
.filter((x) => x.role === "user")
|
|
47
|
+
.at(-1)
|
|
48
|
+
?.parts.find((x) => x.type === "text")?.text ?? "";
|
|
49
|
+
|
|
50
|
+
const model = createModel(config.model);
|
|
51
|
+
const embeddingModel = createEmbeddingModel(config.embedding);
|
|
52
|
+
const neo4j = createNeo4jClient(config.neo4j, embeddingModel);
|
|
53
|
+
|
|
54
|
+
// Pre-resolve graph context
|
|
55
|
+
const resolved = await resolveFromGraph(prompt, {
|
|
56
|
+
neo4j,
|
|
57
|
+
embeddingModel,
|
|
58
|
+
reranker: config.reranker,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Build tools
|
|
62
|
+
const builtInTools: Record<string, unknown> = {
|
|
63
|
+
captureFiles: captureFilesTool,
|
|
64
|
+
queryGraph: createQueryGraphTool(neo4j),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (config.backendFetch) {
|
|
68
|
+
const backendFetchWithInterceptor = {
|
|
69
|
+
...config.backendFetch,
|
|
70
|
+
transformRequestBody:
|
|
71
|
+
config.backendFetch.transformRequestBody ??
|
|
72
|
+
createCapturedFileInterceptor(config.db, config.storage),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
builtInTools["callEndpoint"] = createCallEndpointTool(
|
|
76
|
+
backendFetchWithInterceptor,
|
|
77
|
+
token,
|
|
78
|
+
);
|
|
79
|
+
builtInTools["executeCode"] = createExecuteCodeTool(
|
|
80
|
+
backendFetchWithInterceptor,
|
|
81
|
+
token,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tools = {
|
|
86
|
+
...builtInTools,
|
|
87
|
+
...config.tools,
|
|
88
|
+
} as ToolSet;
|
|
89
|
+
|
|
90
|
+
const systemPrompt = await buildSystemPrompt(
|
|
91
|
+
config,
|
|
92
|
+
prompt,
|
|
93
|
+
thread,
|
|
94
|
+
token,
|
|
95
|
+
resolved,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const result = streamText({
|
|
99
|
+
model,
|
|
100
|
+
system: systemPrompt,
|
|
101
|
+
tools,
|
|
102
|
+
messages: recentMessages,
|
|
103
|
+
onAbort: () => {
|
|
104
|
+
console.log("Stream aborted");
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return result.toUIMessageStreamResponse({
|
|
109
|
+
originalMessages,
|
|
110
|
+
generateMessageId: generateId,
|
|
111
|
+
onFinish: async ({ messages: finishedMessages, isAborted }) => {
|
|
112
|
+
if (isAborted) {
|
|
113
|
+
console.log("Stream was aborted");
|
|
114
|
+
}
|
|
115
|
+
await config.db.messages.upsert(thread.id, finishedMessages);
|
|
116
|
+
config.onStreamFinish?.({ messages: finishedMessages, isAborted });
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function generateTitle(
|
|
122
|
+
threadId: string,
|
|
123
|
+
prompt: string,
|
|
124
|
+
userId: string,
|
|
125
|
+
config: ResolvedCortexAgentConfig,
|
|
126
|
+
) {
|
|
127
|
+
const model = createModel(config.model);
|
|
128
|
+
|
|
129
|
+
const { output } = await generateText({
|
|
130
|
+
model,
|
|
131
|
+
system: `You are an expert in generating titles for threads of chats
|
|
132
|
+
given the first message in that thread.
|
|
133
|
+
|
|
134
|
+
When asked, only respond with the title without saying you're
|
|
135
|
+
going to do so or any other speech. Spit out only the title.`,
|
|
136
|
+
prompt: `Generate a title for this prompt: ${prompt}`,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await config.db.threads.updateTitle(threadId, output ?? "");
|
|
140
|
+
|
|
141
|
+
notify(userId, {
|
|
142
|
+
type: "thread:title-updated",
|
|
143
|
+
payload: { threadId, title: output ?? "" },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from "../../adapters/database.ts";
|
|
2
|
+
import type { StorageAdapter } from "../../adapters/storage.ts";
|
|
3
|
+
import { streamToBase64, tokenToUserId } from "../helpers.ts";
|
|
4
|
+
|
|
5
|
+
export type ResolvedFile = {
|
|
6
|
+
name: string;
|
|
7
|
+
bytes: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createCapturedFileInterceptor(
|
|
11
|
+
db: DatabaseAdapter,
|
|
12
|
+
storage: StorageAdapter,
|
|
13
|
+
options?: { transformFile?: (file: ResolvedFile) => unknown },
|
|
14
|
+
) {
|
|
15
|
+
const transformFile =
|
|
16
|
+
options?.transformFile ?? ((file: ResolvedFile) => file);
|
|
17
|
+
|
|
18
|
+
return async function resolveCapturedFiles(
|
|
19
|
+
body: Record<string, unknown>,
|
|
20
|
+
context: { token: string },
|
|
21
|
+
) {
|
|
22
|
+
const userId = tokenToUserId(context.token);
|
|
23
|
+
|
|
24
|
+
async function traverse(obj: Record<string, unknown>) {
|
|
25
|
+
await Promise.all(
|
|
26
|
+
Object.keys(obj).map(async (key) => {
|
|
27
|
+
const value = obj[key];
|
|
28
|
+
if (typeof value === "string" && value.startsWith("capturedFile")) {
|
|
29
|
+
const [_, uploadId] = value.split("#");
|
|
30
|
+
const file = await db.capturedFiles.getById(uploadId!, userId);
|
|
31
|
+
if (file) {
|
|
32
|
+
const fileStream = await storage.stream(
|
|
33
|
+
`captured_files/${uploadId}`,
|
|
34
|
+
);
|
|
35
|
+
const bytes = await streamToBase64(fileStream);
|
|
36
|
+
obj[key] = transformFile({ name: file.name, bytes });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
await Promise.all(
|
|
42
|
+
value.map(async (child) => {
|
|
43
|
+
if (typeof child === "object" && child !== null) {
|
|
44
|
+
await traverse(child as Record<string, unknown>);
|
|
45
|
+
}
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
typeof value === "object" &&
|
|
52
|
+
value !== null &&
|
|
53
|
+
!Array.isArray(value)
|
|
54
|
+
) {
|
|
55
|
+
await traverse(value as Record<string, unknown>);
|
|
56
|
+
}
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await traverse(body);
|
|
62
|
+
return body;
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/ai/prompt.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ResolvedContext } from "../graph/resolver.ts";
|
|
2
|
+
import type { ResolvedCortexAgentConfig } from "../config.ts";
|
|
3
|
+
import type { Thread } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export async function buildSystemPrompt(
|
|
6
|
+
config: ResolvedCortexAgentConfig,
|
|
7
|
+
prompt: string,
|
|
8
|
+
thread: Thread,
|
|
9
|
+
token: string,
|
|
10
|
+
resolved: ResolvedContext | null,
|
|
11
|
+
) {
|
|
12
|
+
// Resolve the consumer's base system prompt
|
|
13
|
+
let basePrompt: string;
|
|
14
|
+
if (typeof config.systemPrompt === "function") {
|
|
15
|
+
// Resolve session data with caching
|
|
16
|
+
let session: Record<string, unknown> | null = thread.session as Record<
|
|
17
|
+
string,
|
|
18
|
+
unknown
|
|
19
|
+
> | null;
|
|
20
|
+
|
|
21
|
+
if (!session && config.loadSessionData) {
|
|
22
|
+
session = await config.loadSessionData(token);
|
|
23
|
+
// Persist to DB for future cache hits
|
|
24
|
+
await config.db.threads.updateSession(thread.id, session);
|
|
25
|
+
thread.session = session;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
basePrompt = await config.systemPrompt(session);
|
|
29
|
+
} else {
|
|
30
|
+
basePrompt = config.systemPrompt;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sections: string[] = [basePrompt];
|
|
34
|
+
|
|
35
|
+
// Pre-resolved endpoints from knowledge graph
|
|
36
|
+
if (resolved) {
|
|
37
|
+
sections.push(buildResolvedSection(resolved));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return sections.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildResolvedSection(resolved: ResolvedContext) {
|
|
44
|
+
const parts: string[] = [
|
|
45
|
+
`
|
|
46
|
+
## Pre-resolved API Endpoints
|
|
47
|
+
The following endpoints were automatically matched to the user's message.`,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const ep of resolved.readEndpoints) {
|
|
51
|
+
const rules =
|
|
52
|
+
ep.rules.length > 0 ? `\n Rules: ${ep.rules.join("; ")}` : "";
|
|
53
|
+
const deps =
|
|
54
|
+
ep.dependencies.length > 0
|
|
55
|
+
? "\n Dependencies:\n" +
|
|
56
|
+
ep.dependencies
|
|
57
|
+
.map(
|
|
58
|
+
(d) =>
|
|
59
|
+
` - Call ${d.depMethod} ${d.depPath} first → use its "${d.fromField}" as "${d.paramName}"`,
|
|
60
|
+
)
|
|
61
|
+
.join("\n")
|
|
62
|
+
: "";
|
|
63
|
+
parts.push(
|
|
64
|
+
`
|
|
65
|
+
### ${ep.concept} (read)
|
|
66
|
+
- Endpoint: ${ep.name}
|
|
67
|
+
- ${ep.method} ${ep.path}
|
|
68
|
+
- Params: ${ep.params}
|
|
69
|
+
- Body: ${ep.body}
|
|
70
|
+
- Response: ${ep.response}${rules}${deps}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const ep of resolved.writeEndpoints) {
|
|
75
|
+
const rules =
|
|
76
|
+
ep.rules.length > 0 ? `\n Rules: ${ep.rules.join("; ")}` : "";
|
|
77
|
+
const deps =
|
|
78
|
+
ep.dependencies.length > 0
|
|
79
|
+
? "\n Dependencies:\n" +
|
|
80
|
+
ep.dependencies
|
|
81
|
+
.map(
|
|
82
|
+
(d) =>
|
|
83
|
+
` - Call ${d.depMethod} ${d.depPath} first → use its "${d.fromField}" as "${d.paramName}"`,
|
|
84
|
+
)
|
|
85
|
+
.join("\n")
|
|
86
|
+
: "";
|
|
87
|
+
parts.push(
|
|
88
|
+
`
|
|
89
|
+
### ${ep.concept} (write)
|
|
90
|
+
- Endpoint: ${ep.name}
|
|
91
|
+
- ${ep.method} ${ep.path}
|
|
92
|
+
- Params: ${ep.params}
|
|
93
|
+
- Body: ${ep.body}
|
|
94
|
+
- Response: ${ep.response}${rules}${deps}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const svc of resolved.services) {
|
|
99
|
+
const rules =
|
|
100
|
+
svc.rules.length > 0 ? `\n Rules: ${svc.rules.join("; ")}` : "";
|
|
101
|
+
parts.push(
|
|
102
|
+
`
|
|
103
|
+
### ${svc.concept} via ${svc.serviceName} (service)
|
|
104
|
+
- Built-in ID: ${svc.builtInId}
|
|
105
|
+
- Description: ${svc.description || "N/A"}${rules}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
resolved.readEndpoints.length === 0 &&
|
|
111
|
+
resolved.writeEndpoints.length === 0 &&
|
|
112
|
+
resolved.services.length === 0
|
|
113
|
+
) {
|
|
114
|
+
parts.push(
|
|
115
|
+
"\nNo matching endpoints found. Use queryGraph to search the knowledge graph manually.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return parts.join("");
|
|
120
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import type { ResolvedCortexAgentConfig } from "../../config.ts";
|
|
4
|
+
import { fetchBackend } from "../fetch.ts";
|
|
5
|
+
|
|
6
|
+
export function createCallEndpointTool(
|
|
7
|
+
backendFetch: NonNullable<ResolvedCortexAgentConfig["backendFetch"]>,
|
|
8
|
+
token: string,
|
|
9
|
+
) {
|
|
10
|
+
return tool({
|
|
11
|
+
title: "Call an API endpoint",
|
|
12
|
+
description:
|
|
13
|
+
"Call an API endpoint on the backend. Use queryGraph first to discover the correct endpoint, parameters, and business rules. For write operations (POST/PUT/DELETE), ALWAYS get explicit user confirmation before calling.",
|
|
14
|
+
inputSchema: z.object({
|
|
15
|
+
path: z
|
|
16
|
+
.string()
|
|
17
|
+
.describe(
|
|
18
|
+
'The API path including path parameters. Example: "/items/list" or "/resources/{id}"',
|
|
19
|
+
),
|
|
20
|
+
method: z
|
|
21
|
+
.enum(["GET", "POST", "PUT", "DELETE"])
|
|
22
|
+
.describe("The HTTP method"),
|
|
23
|
+
queryParams: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Optional JSON-encoded string of query parameters."),
|
|
27
|
+
body: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe(
|
|
31
|
+
"Optional JSON-encoded string of request body for POST/PUT. For parameters of type file, use `capturedFile#[uploadId]` as the value.",
|
|
32
|
+
),
|
|
33
|
+
}),
|
|
34
|
+
execute: async ({ path, method, queryParams, body }) => {
|
|
35
|
+
let fullPath = path;
|
|
36
|
+
if (queryParams) {
|
|
37
|
+
const params = JSON.parse(queryParams) as Record<string, unknown>;
|
|
38
|
+
const searchParams = new URLSearchParams();
|
|
39
|
+
for (const [key, value] of Object.entries(params)) {
|
|
40
|
+
if (value == null || value === "") continue;
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
for (const item of value) {
|
|
43
|
+
searchParams.append(key, String(item));
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
searchParams.set(key, String(value));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const qs = searchParams.toString();
|
|
50
|
+
if (qs) fullPath += `?${qs}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const options: RequestInit = { method };
|
|
54
|
+
if (body && (method === "POST" || method === "PUT")) {
|
|
55
|
+
options.body = body;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const response = await fetchBackend(
|
|
59
|
+
fullPath,
|
|
60
|
+
backendFetch,
|
|
61
|
+
token,
|
|
62
|
+
options,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
let message: string;
|
|
67
|
+
let details: unknown = undefined;
|
|
68
|
+
try {
|
|
69
|
+
const errorBody = (await response.json()) as Record<string, unknown>;
|
|
70
|
+
message =
|
|
71
|
+
(errorBody.message as string) ||
|
|
72
|
+
(errorBody.title as string) ||
|
|
73
|
+
JSON.stringify(errorBody);
|
|
74
|
+
if (errorBody.errors) {
|
|
75
|
+
details = errorBody.errors;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
message = `Request failed with status ${response.status}`;
|
|
79
|
+
}
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
error: true,
|
|
82
|
+
status: response.status,
|
|
83
|
+
message,
|
|
84
|
+
...(details ? { details } : {}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (response.status === 204) {
|
|
89
|
+
return JSON.stringify({ success: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
return JSON.stringify(data);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
|
|
4
|
+
export const captureFilesTool = tool({
|
|
5
|
+
description: "Let the user upload a file",
|
|
6
|
+
inputSchema: z.object({
|
|
7
|
+
files: z.array(
|
|
8
|
+
z.object({
|
|
9
|
+
label: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe(
|
|
12
|
+
"A label to view to the user to know what file they need to upload",
|
|
13
|
+
),
|
|
14
|
+
id: z
|
|
15
|
+
.string()
|
|
16
|
+
.describe(
|
|
17
|
+
"An ID to the file for you to use it when calling apis or later processing",
|
|
18
|
+
),
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import type { ResolvedCortexAgentConfig } from "../../config.ts";
|
|
4
|
+
import { fetchBackend } from "../fetch.ts";
|
|
5
|
+
|
|
6
|
+
type ApiHelper = {
|
|
7
|
+
get: (
|
|
8
|
+
path: string,
|
|
9
|
+
queryParams?: Record<string, unknown>,
|
|
10
|
+
) => Promise<unknown>;
|
|
11
|
+
post: (path: string, body?: unknown) => Promise<unknown>;
|
|
12
|
+
put: (path: string, body?: unknown) => Promise<unknown>;
|
|
13
|
+
del: (path: string) => Promise<unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function buildQueryString(params: Record<string, unknown>) {
|
|
17
|
+
const searchParams = new URLSearchParams();
|
|
18
|
+
for (const [key, value] of Object.entries(params)) {
|
|
19
|
+
if (value == null || value === "") continue;
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
for (const item of value) {
|
|
22
|
+
searchParams.append(key, String(item));
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
searchParams.set(key, String(value));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const qs = searchParams.toString();
|
|
29
|
+
return qs ? `?${qs}` : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createApiHelper(
|
|
33
|
+
backendFetch: NonNullable<ResolvedCortexAgentConfig["backendFetch"]>,
|
|
34
|
+
token: string,
|
|
35
|
+
) {
|
|
36
|
+
async function request(method: string, path: string, body?: unknown) {
|
|
37
|
+
const options: RequestInit = { method };
|
|
38
|
+
if (body !== undefined && (method === "POST" || method === "PUT")) {
|
|
39
|
+
options.body = JSON.stringify(body);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const response = await fetchBackend(path, backendFetch, token, options);
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
let message: string;
|
|
46
|
+
try {
|
|
47
|
+
const errorBody = await response.json();
|
|
48
|
+
message = JSON.stringify(errorBody);
|
|
49
|
+
} catch {
|
|
50
|
+
message = `HTTP ${response.status}`;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(
|
|
53
|
+
`API ${method} ${path} failed (${response.status}): ${message}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (response.status === 204) return { success: true };
|
|
58
|
+
return response.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
get: (path, queryParams) => {
|
|
63
|
+
const fullPath = queryParams
|
|
64
|
+
? path + buildQueryString(queryParams)
|
|
65
|
+
: path;
|
|
66
|
+
return request("GET", fullPath);
|
|
67
|
+
},
|
|
68
|
+
post: (path, body) => request("POST", path, body),
|
|
69
|
+
put: (path, body) => request("PUT", path, body),
|
|
70
|
+
del: (path) => request("DELETE", path),
|
|
71
|
+
} satisfies ApiHelper;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const AsyncFunction: new (
|
|
75
|
+
...args: [...paramNames: string[], body: string]
|
|
76
|
+
) => (...args: unknown[]) => Promise<unknown> = Object.getPrototypeOf(
|
|
77
|
+
async function () {},
|
|
78
|
+
).constructor;
|
|
79
|
+
|
|
80
|
+
export function createExecuteCodeTool(
|
|
81
|
+
backendFetch: NonNullable<ResolvedCortexAgentConfig["backendFetch"]>,
|
|
82
|
+
token: string,
|
|
83
|
+
) {
|
|
84
|
+
return tool({
|
|
85
|
+
title: "Executes JavaScript code",
|
|
86
|
+
description:
|
|
87
|
+
"Run a JavaScript script that calls APIs and returns only relevant data. The script has an `api` helper: api.get(path, params?), api.post(path, body?), api.put(path, body?), api.del(path). Each returns parsed JSON and throws on error. For parameters of type file, use `capturedFile#[uploadId]` as the value.",
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
code: z
|
|
90
|
+
.string()
|
|
91
|
+
.describe(
|
|
92
|
+
"Async JS function body. Use the `api` helper to call endpoints. Return only the data needed for your response.",
|
|
93
|
+
),
|
|
94
|
+
}),
|
|
95
|
+
execute: async ({ code }) => {
|
|
96
|
+
const apiHelper = createApiHelper(backendFetch, token);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const fn = new AsyncFunction("api", code);
|
|
100
|
+
const result = await fn(apiHelper);
|
|
101
|
+
return JSON.stringify(result);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
104
|
+
return JSON.stringify({ error: true, message });
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import type { Neo4jClient } from "../../graph/neo4j.ts";
|
|
4
|
+
|
|
5
|
+
export function createQueryGraphTool(neo4j: Neo4jClient) {
|
|
6
|
+
return tool({
|
|
7
|
+
title: "Query existing Knowledge Graph",
|
|
8
|
+
description: `Run a Cypher query against the Neo4j knowledge graph to find relevant
|
|
9
|
+
API endpoints, business rules, and system concepts.
|
|
10
|
+
|
|
11
|
+
Concepts' descriptions are embedded (vectorized), always use this method to search unknowns; use regular keywords only if you received it before or you know it for a fact:
|
|
12
|
+
\`\`\`cypher
|
|
13
|
+
CALL db.index.vector.queryNodes('concept_embeddings', 10, $embedding)
|
|
14
|
+
YIELD node, score
|
|
15
|
+
WHERE score > 0.85
|
|
16
|
+
MATCH (node)-[:SPECIALIZES*0..3]->(ancestor)
|
|
17
|
+
MATCH (ancestor)-[:QUERIED_VIA|MUTATED_VIA]->(e)
|
|
18
|
+
RETURN node, e
|
|
19
|
+
ORDER BY score DESC;
|
|
20
|
+
\`\`\``,
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
query: z.string().describe("The Cypher query to execute"),
|
|
23
|
+
parameters: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe(
|
|
27
|
+
'Optional JSON-encoded string of query parameters. Example: `{"name": "LeaveBalance"}` if you know the exact name; for parameters that need to be embedded first prepend the name with `#`, e.g., `{"#paramName": "Text to be embedded before passed to query"}`',
|
|
28
|
+
),
|
|
29
|
+
}),
|
|
30
|
+
execute: async ({ query, parameters }) => {
|
|
31
|
+
const params = parameters ? JSON.parse(parameters) : undefined;
|
|
32
|
+
return neo4j.query(query, params);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { getCookie } from "hono/cookie";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
5
|
+
import type { AppEnv, AuthedAppEnv } from "../types";
|
|
6
|
+
import type { CortexConfig } from "../config";
|
|
7
|
+
|
|
8
|
+
export function createUserLoaderMiddleware(authConfig: CortexConfig["auth"]) {
|
|
9
|
+
const jwks = createRemoteJWKSet(new URL(authConfig.jwksUri));
|
|
10
|
+
|
|
11
|
+
return createMiddleware<AppEnv>(async (c, next) => {
|
|
12
|
+
let token: string | null = null;
|
|
13
|
+
|
|
14
|
+
if (authConfig.tokenExtractor) {
|
|
15
|
+
token = authConfig.tokenExtractor(c.req.raw);
|
|
16
|
+
} else {
|
|
17
|
+
// 1. Authorization header
|
|
18
|
+
const authHeader = c.req.header("Authorization");
|
|
19
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
20
|
+
token = authHeader.slice(7);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. Query parameter
|
|
24
|
+
if (!token) {
|
|
25
|
+
const url = new URL(c.req.url);
|
|
26
|
+
token = url.searchParams.get("token");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 3. Cookie
|
|
30
|
+
if (!token && authConfig.cookieName) {
|
|
31
|
+
token = getCookie(c, authConfig.cookieName) ?? null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!token) {
|
|
36
|
+
await next();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
42
|
+
issuer: authConfig.issuer,
|
|
43
|
+
clockTolerance: Infinity,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (payload.sub) {
|
|
47
|
+
c.set("user", { id: payload.sub, token });
|
|
48
|
+
}
|
|
49
|
+
await next();
|
|
50
|
+
} catch {
|
|
51
|
+
await next();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const requireAuth = createMiddleware<AuthedAppEnv>(async (c, next) => {
|
|
57
|
+
const user = c.get("user");
|
|
58
|
+
if (!user) {
|
|
59
|
+
throw new HTTPException(401, { message: "Unauthorized" });
|
|
60
|
+
}
|
|
61
|
+
c.set("user", user);
|
|
62
|
+
await next();
|
|
63
|
+
});
|