@m6d/cortex-server 1.3.0 → 1.5.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.
Files changed (56) hide show
  1. package/dist/src/adapters/database.d.ts +3 -0
  2. package/dist/src/ai/active-streams.d.ts +14 -0
  3. package/dist/src/ai/context/builder.d.ts +24 -0
  4. package/dist/src/ai/context/compressor.d.ts +7 -0
  5. package/dist/src/ai/context/index.d.ts +15 -0
  6. package/dist/src/ai/context/summarizer.d.ts +5 -0
  7. package/dist/src/ai/context/token-estimator.d.ts +20 -0
  8. package/dist/src/ai/context/types.d.ts +20 -0
  9. package/dist/src/ai/index.d.ts +1 -1
  10. package/dist/src/ai/prompt.d.ts +6 -1
  11. package/dist/src/config.d.ts +4 -0
  12. package/dist/src/db/schema.d.ts +19 -1
  13. package/dist/src/graph/expand-domains.d.ts +2 -0
  14. package/dist/src/graph/helpers.d.ts +5 -0
  15. package/dist/src/graph/resolver.d.ts +2 -0
  16. package/dist/src/graph/types.d.ts +6 -0
  17. package/dist/src/index.d.ts +1 -0
  18. package/dist/src/routes/ws.d.ts +5 -1
  19. package/dist/src/types.d.ts +32 -14
  20. package/dist/src/ws/connections.d.ts +3 -3
  21. package/dist/src/ws/events.d.ts +28 -3
  22. package/dist/src/ws/index.d.ts +1 -1
  23. package/dist/src/ws/notify.d.ts +1 -1
  24. package/package.json +1 -1
  25. package/src/adapters/database.ts +3 -0
  26. package/src/adapters/mssql.ts +26 -6
  27. package/src/ai/active-streams.ts +123 -0
  28. package/src/ai/context/builder.ts +94 -0
  29. package/src/ai/context/compressor.ts +47 -0
  30. package/src/ai/context/index.ts +75 -0
  31. package/src/ai/context/summarizer.ts +50 -0
  32. package/src/ai/context/token-estimator.ts +60 -0
  33. package/src/ai/context/types.ts +28 -0
  34. package/src/ai/index.ts +124 -29
  35. package/src/ai/prompt.ts +27 -18
  36. package/src/ai/tools/query-graph.tool.ts +1 -1
  37. package/src/cli/extract-endpoints.ts +18 -18
  38. package/src/config.ts +4 -0
  39. package/src/db/migrations/20260315000000_add_context_meta/migration.sql +1 -0
  40. package/src/db/schema.ts +6 -1
  41. package/src/factory.ts +11 -1
  42. package/src/graph/expand-domains.ts +276 -0
  43. package/src/graph/generate-cypher.ts +18 -5
  44. package/src/graph/helpers.ts +1 -0
  45. package/src/graph/resolver.ts +10 -0
  46. package/src/graph/seed.ts +5 -2
  47. package/src/graph/types.ts +6 -0
  48. package/src/index.ts +2 -0
  49. package/src/routes/chat.ts +47 -2
  50. package/src/routes/threads.ts +46 -9
  51. package/src/routes/ws.ts +37 -23
  52. package/src/types.ts +37 -13
  53. package/src/ws/connections.ts +15 -9
  54. package/src/ws/events.ts +31 -3
  55. package/src/ws/index.ts +9 -1
  56. package/src/ws/notify.ts +2 -2
package/src/ai/index.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  import {
2
2
  type UIMessage,
3
3
  type ToolSet,
4
- consumeStream,
5
4
  convertToModelMessages,
6
5
  generateId,
7
6
  generateText,
8
7
  safeValidateUIMessages,
8
+ stepCountIs,
9
9
  streamText,
10
10
  } from "ai";
11
11
  import { HTTPException } from "hono/http-exception";
12
12
  import type { ResolvedCortexAgentConfig } from "../config.ts";
13
- import type { Thread } from "../types.ts";
13
+ import type { MessageMetadata, Thread } from "../types.ts";
14
14
  import { createModel, createEmbeddingModel } from "./helpers.ts";
15
- import { buildSystemPrompt } from "./prompt.ts";
15
+ import { buildSystemPrompt, resolveSession } from "./prompt.ts";
16
16
  import { createQueryGraphTool } from "./tools/query-graph.tool.ts";
17
17
  import { createCallEndpointTool } from "./tools/call-endpoint.tool.ts";
18
18
  import { createExecuteCodeTool } from "./tools/execute-code.tool.ts";
@@ -21,6 +21,15 @@ import { createRequestInterceptor } from "./interceptors/request-interceptor.ts"
21
21
  import { createNeo4jClient } from "../graph/neo4j.ts";
22
22
  import { resolveFromGraph } from "../graph/resolver.ts";
23
23
  import { notify } from "../ws/index.ts";
24
+ import { buildContextMessages } from "./context/builder.ts";
25
+ import { optimizeThreadContext, estimateTokens, trimMessagesToFit } from "./context/index.ts";
26
+ import {
27
+ registerStream,
28
+ attachSseStream,
29
+ removeStream,
30
+ isStreamRunning,
31
+ } from "./active-streams.ts";
32
+ import { toThreadSummary } from "../types.ts";
24
33
 
25
34
  export async function stream(
26
35
  messages: unknown[],
@@ -28,8 +37,9 @@ export async function stream(
28
37
  userId: string,
29
38
  token: string,
30
39
  config: ResolvedCortexAgentConfig,
31
- abortSignal?: AbortSignal,
32
40
  ) {
41
+ const abortController = new AbortController();
42
+
33
43
  const validationResult = await safeValidateUIMessages({ messages });
34
44
  if (!validationResult.success) {
35
45
  throw new HTTPException(423, { message: "Invalid messages format" });
@@ -37,15 +47,19 @@ export async function stream(
37
47
 
38
48
  const validatedMessages = validationResult.data;
39
49
  await config.db.messages.upsert(thread.id, validatedMessages);
50
+ const updatedThread = await config.db.threads.touch(thread.id);
40
51
 
41
- const originalMessages = await config.db.messages
42
- .list(userId, thread.id, { limit: 20 })
43
- .then((x) => x.map((y) => y.content));
52
+ const activeStream = registerStream(thread.id, abortController);
44
53
 
45
- const recentMessages = await convertToModelMessages(originalMessages);
54
+ notify(userId, thread.agentId, {
55
+ type: "thread:run-started",
56
+ payload: { thread: toThreadSummary(updatedThread, true) },
57
+ });
46
58
 
59
+ // Extract prompt from the just-upserted messages (last user message)
60
+ // so we can start graph resolution without waiting for history fetch
47
61
  const prompt =
48
- originalMessages
62
+ validatedMessages
49
63
  .filter((x) => x.role === "user")
50
64
  .at(-1)
51
65
  ?.parts.find((x) => x.type === "text")?.text ?? "";
@@ -54,15 +68,24 @@ export async function stream(
54
68
  const embeddingModel = createEmbeddingModel(config.embedding);
55
69
  const neo4j = createNeo4jClient(config.neo4j, embeddingModel);
56
70
 
57
- // Pre-resolve graph context
58
- const resolved = await resolveFromGraph(prompt, {
59
- neo4j,
60
- embeddingModel,
61
- reranker: config.reranker,
62
- });
71
+ // Run independent operations in parallel
72
+ const [contextResult, resolved, session] = await Promise.all([
73
+ // Branch A: Load messages + build token-aware context window
74
+ buildContextMessages(userId, thread, config.db, config.context),
75
+ // Branch B: Resolve graph context (400-2000ms, the bottleneck)
76
+ resolveFromGraph(prompt, {
77
+ neo4j,
78
+ embeddingModel,
79
+ reranker: config.reranker,
80
+ }),
81
+ // Branch C: Resolve session data
82
+ resolveSession(config, thread, token),
83
+ ]);
84
+
85
+ const { messages: contextMessages, allMessages: originalMessages } = contextResult;
63
86
 
64
87
  // Build tools
65
- const builtInTools: Record<string, unknown> = {
88
+ const builtInTools: ToolSet = {
66
89
  captureFiles: captureFilesTool,
67
90
  queryGraph: createQueryGraphTool(neo4j),
68
91
  };
@@ -88,36 +111,105 @@ export async function stream(
88
111
  ...config.tools,
89
112
  } as ToolSet;
90
113
 
91
- const systemPrompt = await buildSystemPrompt(config, prompt, thread, token, resolved);
114
+ const systemPrompt = await buildSystemPrompt(config, resolved, session);
115
+
116
+ // The context builder reserved a static token budget for the system prompt + tools.
117
+ // Now that we have the actual values, verify the reserve was sufficient and trim
118
+ // the oldest messages if it wasn't.
119
+ const actualFixedCost = estimateTokens(systemPrompt) + estimateTokens(JSON.stringify(tools));
120
+ const { reservedTokenBudget, maxContextTokens } = config.context;
121
+ const trimmedMessages =
122
+ actualFixedCost > reservedTokenBudget
123
+ ? trimMessagesToFit(contextMessages, maxContextTokens - actualFixedCost)
124
+ : contextMessages;
125
+
126
+ const recentMessages = await convertToModelMessages(trimmedMessages);
92
127
 
93
128
  const result = streamText({
94
129
  model,
95
130
  system: systemPrompt,
96
131
  tools,
97
132
  messages: recentMessages,
98
- abortSignal,
133
+ abortSignal: abortController.signal,
134
+ stopWhen: stepCountIs(50),
99
135
  });
100
136
 
101
- return result.toUIMessageStreamResponse({
137
+ return result.toUIMessageStreamResponse<UIMessage<MessageMetadata>>({
102
138
  originalMessages,
103
139
  generateMessageId: generateId,
104
- consumeSseStream: consumeStream,
140
+ consumeSseStream: ({ stream: sseStream }) => {
141
+ attachSseStream(thread.id, sseStream);
142
+ },
105
143
  onFinish: async ({ messages: finishedMessages, isAborted }) => {
106
144
  if (isAborted) {
107
145
  finalizeAbortedMessages(finishedMessages);
108
146
  }
147
+
148
+ // Record token usage (result promises reject on abort, so skip)
149
+ const lastAssistantMessage = finishedMessages
150
+ .filter((x) => x.role === "assistant")
151
+ .at(-1);
152
+ if (lastAssistantMessage && !isAborted) {
153
+ const providerMetadata = await result.providerMetadata;
154
+ const response = await result.response;
155
+ const usage = await result.totalUsage;
156
+ let metadata: MessageMetadata = {
157
+ isAborted,
158
+ providerMetadata,
159
+ modelId: response.modelId,
160
+ tokenUsage: {
161
+ input: {
162
+ noCache: usage.inputTokenDetails.noCacheTokens ?? 0,
163
+ cacheRead: usage.inputTokenDetails.cacheReadTokens ?? 0,
164
+ cacheWrite: usage.inputTokenDetails.cacheWriteTokens ?? 0,
165
+ total: usage.inputTokens ?? 0,
166
+ },
167
+ output: {
168
+ reasoning: usage.outputTokenDetails.reasoningTokens ?? 0,
169
+ text: usage.outputTokenDetails.textTokens ?? 0,
170
+ total: usage.outputTokens ?? 0,
171
+ },
172
+ total: usage.totalTokens ?? 0,
173
+ },
174
+ };
175
+
176
+ lastAssistantMessage.metadata = metadata;
177
+ } else if (lastAssistantMessage) {
178
+ lastAssistantMessage.metadata = {
179
+ isAborted,
180
+ modelId: "",
181
+ providerMetadata: undefined,
182
+ };
183
+ }
184
+
185
+ const persistedThread = await config.db.threads.getById(userId, thread.id);
186
+ if (!persistedThread) {
187
+ removeStream(thread.id);
188
+ return;
189
+ }
190
+
109
191
  await config.db.messages.upsert(thread.id, finishedMessages);
110
192
  config.onStreamFinish?.({ messages: finishedMessages, isAborted });
111
193
 
112
194
  // XXX: we need to notify the user so that the client can
113
195
  // fetch new messages. The client can't fetch messages
114
- // immediately after abort because messages may not have been
115
- // saved yet.
116
- if (isAborted) {
117
- notify(userId, {
118
- type: "thread:messages-updated",
119
- payload: { threadId: thread.id },
120
- });
196
+ // immediately because messages may not have been saved yet.
197
+ notify(userId, persistedThread.agentId, {
198
+ type: "thread:messages-updated",
199
+ payload: {
200
+ threadId: thread.id,
201
+ thread: toThreadSummary(persistedThread, false),
202
+ },
203
+ });
204
+
205
+ setTimeout(() => removeStream(thread.id, activeStream.id), 10_000);
206
+
207
+ // Fire-and-forget: optimize context for next request
208
+ // Runs after response is delivered — no perceived latency
209
+ try {
210
+ optimizeThreadContext(thread, finishedMessages, config);
211
+ } catch (err) {
212
+ console.error("[cortex-server] Context optimization failed:", err);
121
213
  }
122
214
  },
123
215
  });
@@ -143,9 +235,12 @@ going to do so or any other speech. Spit out only the title.`,
143
235
 
144
236
  await config.db.threads.updateTitle(threadId, output ?? "");
145
237
 
146
- notify(userId, {
238
+ const thread = await config.db.threads.getById(userId, threadId);
239
+ if (!thread) return;
240
+
241
+ notify(userId, thread.agentId, {
147
242
  type: "thread:title-updated",
148
- payload: { threadId, title: output ?? "" },
243
+ payload: { thread: toThreadSummary(thread, isStreamRunning(thread.id)) },
149
244
  });
150
245
  }
151
246
 
package/src/ai/prompt.ts CHANGED
@@ -2,29 +2,35 @@ import type { ResolvedContext } from "../graph/resolver.ts";
2
2
  import type { ResolvedCortexAgentConfig } from "../config.ts";
3
3
  import type { Thread } from "../types.ts";
4
4
 
5
- export async function buildSystemPrompt(
5
+ /**
6
+ * Resolves session data for the thread, loading from the configured
7
+ * session loader if not already cached on the thread.
8
+ */
9
+ export async function resolveSession(
6
10
  config: ResolvedCortexAgentConfig,
7
- prompt: string,
8
11
  thread: Thread,
9
12
  token: string,
13
+ ) {
14
+ let session = thread.session;
15
+
16
+ if (!session && config.loadSessionData) {
17
+ session = await config.loadSessionData(token);
18
+ // Persist to DB for future cache hits
19
+ await config.db.threads.updateSession(thread.id, session);
20
+ thread.session = session;
21
+ }
22
+
23
+ return session;
24
+ }
25
+
26
+ export async function buildSystemPrompt(
27
+ config: ResolvedCortexAgentConfig,
10
28
  resolved: ResolvedContext | null,
29
+ session: Record<string, unknown> | null,
11
30
  ) {
12
31
  // Resolve the consumer's base system prompt
13
32
  let basePrompt: string;
14
33
  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
34
  basePrompt = await config.systemPrompt(session);
29
35
  } else {
30
36
  basePrompt = config.systemPrompt;
@@ -59,6 +65,7 @@ The following endpoints were automatically matched to the user's message.`,
59
65
  )
60
66
  .join("\n")
61
67
  : "";
68
+ const meta = ep.metadata !== "{}" ? `\n- Metadata: ${ep.metadata}` : "";
62
69
  parts.push(
63
70
  `
64
71
  ### ${ep.concept} (read)
@@ -66,7 +73,7 @@ The following endpoints were automatically matched to the user's message.`,
66
73
  - ${ep.method} ${ep.path}
67
74
  - Params: ${ep.params}
68
75
  - Body: ${ep.body}
69
- - Response: ${ep.response}${rules}${deps}`,
76
+ - Response: ${ep.response}${rules}${deps}${meta}`,
70
77
  );
71
78
  }
72
79
 
@@ -82,6 +89,7 @@ The following endpoints were automatically matched to the user's message.`,
82
89
  )
83
90
  .join("\n")
84
91
  : "";
92
+ const meta = ep.metadata !== "{}" ? `\n- Metadata: ${ep.metadata}` : "";
85
93
  parts.push(
86
94
  `
87
95
  ### ${ep.concept} (write)
@@ -89,17 +97,18 @@ The following endpoints were automatically matched to the user's message.`,
89
97
  - ${ep.method} ${ep.path}
90
98
  - Params: ${ep.params}
91
99
  - Body: ${ep.body}
92
- - Response: ${ep.response}${rules}${deps}`,
100
+ - Response: ${ep.response}${rules}${deps}${meta}`,
93
101
  );
94
102
  }
95
103
 
96
104
  for (const svc of resolved.services) {
97
105
  const rules = svc.rules.length > 0 ? `\n Rules: ${svc.rules.join("; ")}` : "";
106
+ const meta = svc.metadata !== "{}" ? `\n- Metadata: ${svc.metadata}` : "";
98
107
  parts.push(
99
108
  `
100
109
  ### ${svc.concept} via ${svc.serviceName} (service)
101
110
  - Built-in ID: ${svc.builtInId}
102
- - Description: ${svc.description || "N/A"}${rules}`,
111
+ - Description: ${svc.description || "N/A"}${rules}${meta}`,
103
112
  );
104
113
  }
105
114
 
@@ -24,7 +24,7 @@ ORDER BY score DESC;
24
24
  .string()
25
25
  .optional()
26
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"}`',
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., `{"#embedding": "Text to be embedded before passed to query"}`',
28
28
  ),
29
29
  }),
30
30
  execute: async ({ query, parameters }) => {
@@ -39,7 +39,7 @@ const AUTO_START = "// @auto-generated-start";
39
39
  const AUTO_END = "// @auto-generated-end";
40
40
  const MAX_DEPTH = 8;
41
41
 
42
- const toCamelCase = (s: string): string =>
42
+ const toCamelCase = (s: string) =>
43
43
  s
44
44
  .split(".")
45
45
  .map((seg) => seg.replace(/^[A-Z]/, (c) => c.toLowerCase()))
@@ -54,13 +54,13 @@ const dedupe = (items: Prop[]): Prop[] => {
54
54
  return items.filter((x) => (seen.has(x.name) ? false : (seen.add(x.name), true)));
55
55
  };
56
56
 
57
- function normalizePath(raw: string): string {
57
+ function normalizePath(raw: string) {
58
58
  let p = raw.startsWith("/") ? raw : `/${raw}`;
59
59
  p = p.replace(/\{([^}:?]+)(?::[^}]+)?\??\}/g, "{$1}").replace(/^\/api(?=\/|$)/i, "") || "/";
60
60
  return p.length > 1 ? p.replace(/\/+$/, "") : p;
61
61
  }
62
62
 
63
- function walkFiles(dir: string, accept: (filePath: string) => boolean): string[] {
63
+ function walkFiles(dir: string, accept: (filePath: string) => boolean) {
64
64
  if (!fs.existsSync(dir)) return [];
65
65
  const out: string[] = [];
66
66
  function walk(d: string) {
@@ -74,7 +74,7 @@ function walkFiles(dir: string, accept: (filePath: string) => boolean): string[]
74
74
  return out;
75
75
  }
76
76
 
77
- function endpointFiles(root: string): Map<string, string> {
77
+ function endpointFiles(root: string) {
78
78
  const out = new Map<string, string>();
79
79
  for (const filePath of walkFiles(root, (p) => p.endsWith(".endpoint.ts"))) {
80
80
  const c = fs.readFileSync(filePath, "utf8");
@@ -85,7 +85,7 @@ function endpointFiles(root: string): Map<string, string> {
85
85
  return out;
86
86
  }
87
87
 
88
- function resolvePointer(doc: unknown, ref: string): unknown {
88
+ function resolvePointer(doc: unknown, ref: string) {
89
89
  if (!ref.startsWith("#/")) return null;
90
90
  let cur: unknown = doc;
91
91
  for (const part of ref.slice(2).split("/")) {
@@ -96,7 +96,7 @@ function resolvePointer(doc: unknown, ref: string): unknown {
96
96
  return cur;
97
97
  }
98
98
 
99
- function deref(doc: unknown, v: unknown): Obj {
99
+ function deref(doc: unknown, v: unknown) {
100
100
  if (!isObj(v)) return {};
101
101
  let x = v;
102
102
  const seen = new Set<string>();
@@ -108,7 +108,7 @@ function deref(doc: unknown, v: unknown): Obj {
108
108
  return x;
109
109
  }
110
110
 
111
- function schemaType(s: Obj): string {
111
+ function schemaType(s: Obj) {
112
112
  const t = Array.isArray(s.type)
113
113
  ? s.type.find((x) => typeof x === "string" && x !== "null")
114
114
  : s.type;
@@ -133,7 +133,7 @@ function isJsonNodeSchema(s: Obj) {
133
133
  );
134
134
  }
135
135
 
136
- function scalarType(s: Obj): string {
136
+ function scalarType(s: Obj) {
137
137
  if (Array.isArray(s.enum) && s.enum.every((v) => typeof v === "string")) {
138
138
  return s.enum.map((v) => `'${String(v).replace(/'/g, "\\'")}'`).join(" | ");
139
139
  }
@@ -149,7 +149,7 @@ function scalarType(s: Obj): string {
149
149
  return "unknown";
150
150
  }
151
151
 
152
- function toProps(doc: unknown, schemaIn: unknown, depth = 0): Prop[] {
152
+ function toProps(doc: unknown, schemaIn: unknown, depth = 0) {
153
153
  if (depth > MAX_DEPTH) return [];
154
154
  const s = deref(doc, schemaIn);
155
155
  if (isJsonNodeRef(schemaIn) || isJsonNodeSchema(s)) return [];
@@ -198,7 +198,7 @@ function toProp(doc: unknown, name: string, schemaIn: unknown, required: boolean
198
198
  return { name, required: req, type: scalarType(s) };
199
199
  }
200
200
 
201
- function pickContent(contentIn: unknown): { schema: unknown; mimes: string[] } | null {
201
+ function pickContent(contentIn: unknown) {
202
202
  const content = obj(contentIn);
203
203
  const mimes = Object.keys(content);
204
204
  if (!mimes.length) return null;
@@ -319,7 +319,7 @@ function extractEndpoint(
319
319
  method: HttpMethod,
320
320
  pathItemIn: unknown,
321
321
  operationIn: unknown,
322
- ): Endpoint {
322
+ ) {
323
323
  const pathItem = obj(pathItemIn);
324
324
  const operation = obj(operationIn);
325
325
 
@@ -393,10 +393,10 @@ function extractEndpoint(
393
393
  responseKind: parsed.responseKind,
394
394
  successStatus: parsed.successStatus,
395
395
  errorStatuses: parsed.errorStatuses,
396
- };
396
+ } satisfies Endpoint;
397
397
  }
398
398
 
399
- function parseSwaggerEndpoints(swagger: unknown): Map<string, Endpoint> {
399
+ function parseSwaggerEndpoints(swagger: unknown) {
400
400
  const out = new Map<string, Endpoint>();
401
401
  const doc = obj(swagger);
402
402
  for (const [route, pathItem] of Object.entries(obj(doc.paths))) {
@@ -410,7 +410,7 @@ function parseSwaggerEndpoints(swagger: unknown): Map<string, Endpoint> {
410
410
  return out;
411
411
  }
412
412
 
413
- function serializeProps(props: Prop[], depth: number): string {
413
+ function serializeProps(props: Prop[], depth: number) {
414
414
  return props
415
415
  .map((p) => {
416
416
  const indent = " ".repeat(depth);
@@ -431,7 +431,7 @@ function serializeProps(props: Prop[], depth: number): string {
431
431
  .join("\n");
432
432
  }
433
433
 
434
- function blockFor(endpoint: Endpoint): string {
434
+ function blockFor(endpoint: Endpoint) {
435
435
  return ` autoGenerated: {
436
436
  params: [
437
437
  ${serializeProps(endpoint.params, 2)}
@@ -461,7 +461,7 @@ function formatGeneratedFiles(cwd: string, filePaths: string[]) {
461
461
  }
462
462
  }
463
463
 
464
- async function fetchJson(url: string): Promise<unknown> {
464
+ async function fetchJson(url: string) {
465
465
  const response = await fetch(url, {
466
466
  headers: { Accept: "application/json" },
467
467
  });
@@ -471,7 +471,7 @@ async function fetchJson(url: string): Promise<unknown> {
471
471
  return response.json();
472
472
  }
473
473
 
474
- function resolveEndpoint(key: string, extracted: Map<string, Endpoint>): Endpoint | undefined {
474
+ function resolveEndpoint(key: string, extracted: Map<string, Endpoint>) {
475
475
  const [method, route] = key.split(":");
476
476
  if (!method || !route) return undefined;
477
477
  return (
@@ -483,7 +483,7 @@ function resolveEndpoint(key: string, extracted: Map<string, Endpoint>): Endpoin
483
483
  );
484
484
  }
485
485
 
486
- export async function extractEndpoints(options: ExtractEndpointsOptions): Promise<void> {
486
+ export async function extractEndpoints(options: ExtractEndpointsOptions) {
487
487
  const { swaggerUrl, domainsDir, write = false } = options;
488
488
 
489
489
  console.log(`Fetching Swagger from ${swaggerUrl}`);
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@ import type { DatabaseAdapter } from "./adapters/database";
3
3
  import type { StorageAdapter } from "./adapters/storage";
4
4
  import type { DomainDef } from "./graph/types.ts";
5
5
  import type { RequestInterceptorOptions } from "./ai/interceptors/request-interceptor.ts";
6
+ import type { ContextConfig } from "./ai/context/types.ts";
6
7
 
7
8
  export type KnowledgeConfig = {
8
9
  swagger?: { url: string };
@@ -64,6 +65,7 @@ export type CortexAgentDefinition = {
64
65
  url: string;
65
66
  apiKey: string;
66
67
  };
68
+ context?: Partial<ContextConfig>;
67
69
  knowledge?: KnowledgeConfig | null;
68
70
  };
69
71
 
@@ -110,6 +112,7 @@ export type ResolvedCortexAgentConfig = {
110
112
  args: Record<string, unknown>;
111
113
  }) => void;
112
114
  onStreamFinish?: (result: { messages: UIMessage[]; isAborted: boolean }) => void;
115
+ context: ContextConfig;
113
116
  knowledge?: KnowledgeConfig;
114
117
  };
115
118
 
@@ -144,6 +147,7 @@ export type CortexConfig = {
144
147
  url: string;
145
148
  apiKey: string;
146
149
  };
150
+ context?: Partial<ContextConfig>;
147
151
  knowledge?: KnowledgeConfig;
148
152
  agents: Record<string, CortexAgentDefinition>;
149
153
  };
@@ -0,0 +1 @@
1
+ ALTER TABLE [ai].[threads] ADD [context_meta] nvarchar(max);
package/src/db/schema.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ToolUIPart, UIMessage } from "ai";
2
2
  import { sql } from "drizzle-orm";
3
3
  import { customType, datetime2, index, mssqlSchema, nvarchar } from "drizzle-orm/mssql-core";
4
+ import type { MessageMetadata } from "src/types";
5
+ import type { ThreadContextMeta } from "../ai/context/types.ts";
4
6
 
5
7
  const uniqueIdentifier = customType<{ data: string }>({
6
8
  dataType() {
@@ -28,6 +30,7 @@ export const threads = aiSchema.table("threads", {
28
30
  agentId: nvarchar({ length: 128 }).notNull().default("default"),
29
31
  title: nvarchar({ length: 256 }),
30
32
  session: nvarchar({ mode: "json", length: "max" }).$type<Record<string, unknown>>(),
33
+ contextMeta: nvarchar({ mode: "json", length: "max" }).$type<ThreadContextMeta>(),
31
34
  ...auditColumns,
32
35
  });
33
36
 
@@ -37,7 +40,9 @@ export const messages = aiSchema.table("messages", {
37
40
  .references(() => threads.id, { onDelete: "cascade" })
38
41
  .notNull(),
39
42
  text: nvarchar({ length: "max" }),
40
- content: nvarchar({ mode: "json", length: "max" }).notNull().$type<UIMessage>(),
43
+ content: nvarchar({ mode: "json", length: "max" })
44
+ .notNull()
45
+ .$type<UIMessage<MessageMetadata>>(),
41
46
  role: nvarchar({
42
47
  enum: ["system", "user", "assistant", "tool"],
43
48
  length: 16,
package/src/factory.ts CHANGED
@@ -2,6 +2,8 @@ import { Hono } from "hono";
2
2
  import { websocket } from "hono/bun";
3
3
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
4
  import type { CortexConfig, ResolvedCortexAgentConfig } from "./config.ts";
5
+ import { DEFAULT_CONTEXT_CONFIG } from "./ai/context/types.ts";
6
+ import type { ContextConfig } from "./ai/context/types.ts";
5
7
  import type { AppEnv, CortexAppEnv } from "./types.ts";
6
8
  import type { DomainDef } from "./graph/types.ts";
7
9
  import { createUserLoaderMiddleware } from "./auth/middleware.ts";
@@ -61,7 +63,7 @@ export function createCortex(config: CortexConfig) {
61
63
  const userLoader = createUserLoaderMiddleware(config.auth);
62
64
  app.use("*", userLoader);
63
65
 
64
- // WebSocket route (global, not agent-scoped)
66
+ // Compatibility WebSocket route
65
67
  app.route("/", createWsRoute());
66
68
 
67
69
  // Agent-scoped routes
@@ -72,6 +74,12 @@ export function createCortex(config: CortexConfig) {
72
74
  const agentDef = config.agents[agentId];
73
75
  if (!agentDef) return c.json({ error: "Agent not found" }, 404);
74
76
 
77
+ const resolvedContext: ContextConfig = {
78
+ ...DEFAULT_CONTEXT_CONFIG,
79
+ ...config.context,
80
+ ...agentDef.context,
81
+ };
82
+
75
83
  const resolvedConfig: ResolvedCortexAgentConfig = {
76
84
  db,
77
85
  storage,
@@ -89,6 +97,7 @@ export function createCortex(config: CortexConfig) {
89
97
  loadSessionData: agentDef.loadSessionData,
90
98
  onToolCall: agentDef.onToolCall,
91
99
  onStreamFinish: agentDef.onStreamFinish,
100
+ context: resolvedContext,
92
101
  };
93
102
 
94
103
  c.set("agentConfig", resolvedConfig);
@@ -99,6 +108,7 @@ export function createCortex(config: CortexConfig) {
99
108
  agentApp.route("/", createThreadRoutes());
100
109
  agentApp.route("/", createChatRoutes());
101
110
  agentApp.route("/", createFileRoutes());
111
+ agentApp.route("/", createWsRoute({ useAgentParam: true }));
102
112
 
103
113
  app.route("/agents/:agentId", agentApp);
104
114