@m6d/cortex-server 1.7.0 → 2.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.
Files changed (239) hide show
  1. package/README.md +8 -3
  2. package/contracts/README.md +23 -0
  3. package/contracts/graph/embed.ts +50 -0
  4. package/{src → contracts}/graph/helpers.ts +4 -10
  5. package/{src → contracts}/graph/neo4j.ts +14 -27
  6. package/contracts/graph/schema.ts +65 -0
  7. package/{src → contracts}/graph/types.ts +2 -29
  8. package/contracts/graph.ts +36 -0
  9. package/contracts/runtime.ts +208 -0
  10. package/contracts/wire.ts +143 -0
  11. package/dist/contracts/graph/embed.d.ts +22 -0
  12. package/dist/{src → contracts}/graph/helpers.d.ts +14 -12
  13. package/dist/{src → contracts}/graph/neo4j.d.ts +5 -5
  14. package/dist/contracts/graph/schema.d.ts +62 -0
  15. package/dist/{src → contracts}/graph/types.d.ts +1 -1
  16. package/dist/contracts/graph.d.ts +14 -0
  17. package/dist/contracts/runtime.d.ts +278 -0
  18. package/dist/contracts/wire.d.ts +120 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/src/lib/adapters/database/index.d.ts +61 -0
  21. package/dist/src/lib/adapters/database/message-content.d.ts +30 -0
  22. package/dist/src/lib/adapters/database/mssql/attachments.d.ts +100 -0
  23. package/dist/src/lib/adapters/database/mssql/client.d.ts +8 -0
  24. package/dist/src/lib/adapters/database/mssql/index.d.ts +194 -0
  25. package/dist/src/lib/adapters/database/mssql/llm-requests.d.ts +17 -0
  26. package/dist/src/lib/adapters/database/mssql/messages.d.ts +37 -0
  27. package/dist/src/lib/adapters/database/mssql/threads.d.ts +50 -0
  28. package/dist/src/lib/adapters/database/postgres/attachments.d.ts +113 -0
  29. package/dist/src/lib/adapters/database/postgres/client.d.ts +8 -0
  30. package/dist/src/lib/adapters/database/postgres/index.d.ts +207 -0
  31. package/dist/src/lib/adapters/database/postgres/llm-requests.d.ts +17 -0
  32. package/dist/src/lib/adapters/database/postgres/messages.d.ts +37 -0
  33. package/dist/src/lib/adapters/database/postgres/threads.d.ts +50 -0
  34. package/{src/adapters/storage.ts → dist/src/lib/adapters/storage/index.d.ts} +1 -1
  35. package/dist/src/{adapters → lib/adapters/storage}/minio.d.ts +1 -1
  36. package/dist/src/lib/ai/active-runs.d.ts +63 -0
  37. package/dist/src/lib/ai/attachments.d.ts +30 -0
  38. package/dist/src/lib/ai/backend-url.d.ts +16 -0
  39. package/dist/src/lib/ai/cc-runtime.d.ts +61 -0
  40. package/dist/src/lib/ai/commit-gate.d.ts +21 -0
  41. package/dist/src/{ai → lib/ai}/context/builder.d.ts +6 -7
  42. package/dist/src/lib/ai/context/compression-middleware.d.ts +15 -0
  43. package/dist/src/lib/ai/context/compressor.d.ts +7 -0
  44. package/dist/src/lib/ai/context/intra-turn-compressor.d.ts +24 -0
  45. package/dist/src/lib/ai/context/optimize-thread-context.d.ts +9 -0
  46. package/dist/src/lib/ai/context/summarizer.d.ts +5 -0
  47. package/dist/src/{ai → lib/ai}/context/token-estimator.d.ts +5 -8
  48. package/dist/src/{ai → lib/ai}/context/types.d.ts +2 -0
  49. package/dist/src/{ai → lib/ai}/fetch.d.ts +1 -1
  50. package/dist/src/lib/ai/finish-turn.d.ts +23 -0
  51. package/dist/src/lib/ai/helpers.d.ts +38 -0
  52. package/dist/src/lib/ai/index.d.ts +7 -0
  53. package/dist/src/lib/ai/inspector.d.ts +41 -0
  54. package/dist/src/lib/ai/interceptors/request-interceptor.d.ts +13 -0
  55. package/dist/src/{ai → lib/ai}/prompt.d.ts +5 -4
  56. package/dist/src/lib/ai/redis-runs.d.ts +41 -0
  57. package/dist/src/lib/ai/redis-stream-log.d.ts +39 -0
  58. package/dist/src/lib/ai/tools/execute-code.tool.d.ts +18 -0
  59. package/dist/src/lib/ai/tools/query-graph.tool.d.ts +13 -0
  60. package/dist/src/lib/ai/tools/read-attachment.tool.d.ts +21 -0
  61. package/dist/src/lib/ai/tools/search-common.d.ts +17 -0
  62. package/dist/src/lib/ai/tools/search-knowledge.tool.d.ts +15 -0
  63. package/dist/src/lib/ai/tools/search-services.tool.d.ts +12 -0
  64. package/dist/src/lib/ai/tools/search-tools.tool.d.ts +12 -0
  65. package/dist/src/lib/ai/turn-tools.d.ts +39 -0
  66. package/dist/src/lib/ai/vision/rasterize-pdf.d.ts +26 -0
  67. package/dist/src/lib/ai/vision/rasterize-pdf.worker.d.ts +1 -0
  68. package/dist/src/lib/ai/vision/vision-reader.d.ts +45 -0
  69. package/dist/src/lib/cc/client.d.ts +183 -0
  70. package/dist/src/lib/cc/config-cache.d.ts +20 -0
  71. package/dist/src/lib/cc/format.d.ts +23 -0
  72. package/dist/src/lib/cc/registry.d.ts +34 -0
  73. package/dist/src/lib/cc/types.d.ts +27 -0
  74. package/dist/src/lib/config.d.ts +170 -0
  75. package/dist/src/lib/db/drizzle.config.mssql.d.ts +2 -0
  76. package/dist/src/lib/db/drizzle.config.pg.d.ts +2 -0
  77. package/dist/src/lib/db/migrate.d.ts +7 -0
  78. package/dist/src/{db/schema.d.ts → lib/db/schema.mssql.d.ts} +236 -24
  79. package/dist/src/lib/db/schema.pg.d.ts +546 -0
  80. package/dist/src/{factory.d.ts → lib/factory.d.ts} +2 -12
  81. package/dist/src/lib/graph/index.d.ts +10 -0
  82. package/dist/src/{graph → lib/graph}/resolver.d.ts +2 -3
  83. package/dist/src/{index.d.ts → lib/index.d.ts} +3 -4
  84. package/dist/src/lib/redis.d.ts +19 -0
  85. package/dist/src/{routes → lib/routes}/chat.d.ts +1 -1
  86. package/dist/src/{routes → lib/routes}/files.d.ts +1 -1
  87. package/dist/src/lib/routes/owned-thread.d.ts +17 -0
  88. package/dist/src/{routes → lib/routes}/threads.d.ts +1 -1
  89. package/dist/src/{routes → lib/routes}/ws.d.ts +1 -1
  90. package/dist/src/lib/types.d.ts +58 -0
  91. package/dist/src/{ws → lib/ws}/connections.d.ts +2 -1
  92. package/index.ts +1 -1
  93. package/package.json +35 -15
  94. package/src/lib/adapters/database/index.ts +78 -0
  95. package/src/lib/adapters/database/message-content.ts +39 -0
  96. package/src/lib/adapters/database/mssql/attachments.ts +90 -0
  97. package/src/lib/adapters/database/mssql/client.ts +11 -0
  98. package/src/lib/adapters/database/mssql/index.ts +23 -0
  99. package/src/lib/adapters/database/mssql/llm-requests.ts +43 -0
  100. package/src/lib/adapters/database/mssql/messages.ts +92 -0
  101. package/src/lib/adapters/database/mssql/threads.ts +83 -0
  102. package/src/lib/adapters/database/postgres/attachments.ts +88 -0
  103. package/src/lib/adapters/database/postgres/client.ts +11 -0
  104. package/src/lib/adapters/database/postgres/index.ts +23 -0
  105. package/src/lib/adapters/database/postgres/llm-requests.ts +45 -0
  106. package/src/lib/adapters/database/postgres/messages.ts +92 -0
  107. package/src/lib/adapters/database/postgres/threads.ts +84 -0
  108. package/{dist/src/adapters/storage.d.ts → src/lib/adapters/storage/index.ts} +1 -1
  109. package/src/{adapters → lib/adapters/storage}/minio.ts +7 -6
  110. package/src/lib/ai/active-runs.ts +157 -0
  111. package/src/lib/ai/attachments.ts +144 -0
  112. package/src/lib/ai/backend-url.ts +27 -0
  113. package/src/lib/ai/cc-runtime.ts +63 -0
  114. package/src/lib/ai/commit-gate.ts +116 -0
  115. package/src/{ai → lib/ai}/context/builder.ts +12 -14
  116. package/src/lib/ai/context/compression-middleware.ts +44 -0
  117. package/src/lib/ai/context/compressor.ts +267 -0
  118. package/src/lib/ai/context/intra-turn-compressor.ts +181 -0
  119. package/src/lib/ai/context/optimize-thread-context.ts +50 -0
  120. package/src/lib/ai/context/summarizer.ts +48 -0
  121. package/src/lib/ai/context/token-estimator.ts +65 -0
  122. package/src/{ai → lib/ai}/context/types.ts +4 -1
  123. package/src/{ai → lib/ai}/fetch.ts +10 -5
  124. package/src/lib/ai/finish-turn.ts +75 -0
  125. package/src/lib/ai/helpers.ts +100 -0
  126. package/src/lib/ai/index.ts +408 -0
  127. package/src/lib/ai/inspector.ts +112 -0
  128. package/src/lib/ai/interceptors/request-interceptor.ts +89 -0
  129. package/src/lib/ai/prompt.ts +355 -0
  130. package/src/lib/ai/redis-runs.ts +167 -0
  131. package/src/lib/ai/redis-stream-log.ts +219 -0
  132. package/src/lib/ai/tools/execute-code.tool.ts +267 -0
  133. package/src/{ai → lib/ai}/tools/query-graph.tool.ts +7 -8
  134. package/src/lib/ai/tools/read-attachment.tool.ts +59 -0
  135. package/src/lib/ai/tools/search-common.ts +15 -0
  136. package/src/lib/ai/tools/search-knowledge.tool.ts +33 -0
  137. package/src/lib/ai/tools/search-services.tool.ts +31 -0
  138. package/src/lib/ai/tools/search-tools.tool.ts +29 -0
  139. package/src/lib/ai/turn-tools.ts +154 -0
  140. package/src/lib/ai/vision/rasterize-pdf.ts +69 -0
  141. package/src/lib/ai/vision/rasterize-pdf.worker.ts +81 -0
  142. package/src/lib/ai/vision/vision-reader.ts +130 -0
  143. package/src/{auth → lib/auth}/middleware.ts +12 -3
  144. package/src/lib/cc/client.ts +273 -0
  145. package/src/lib/cc/config-cache.ts +32 -0
  146. package/src/lib/cc/format.ts +96 -0
  147. package/src/lib/cc/registry.ts +62 -0
  148. package/src/lib/cc/types.ts +44 -0
  149. package/src/lib/config.ts +212 -0
  150. package/src/lib/db/drizzle.config.mssql.ts +9 -0
  151. package/src/lib/db/drizzle.config.pg.ts +9 -0
  152. package/src/lib/db/migrate.ts +56 -0
  153. package/src/{db/migrations/20260309012148_cloudy_maria_hill → lib/db/migrations/mssql/20260326231647_nice_speedball}/migration.sql +12 -0
  154. package/src/{db/migrations/20260309012148_cloudy_maria_hill → lib/db/migrations/mssql/20260326231647_nice_speedball}/snapshot.json +106 -1
  155. package/src/lib/db/migrations/mssql/20260729092726_talented_wilson_fisk/migration.sql +20 -0
  156. package/src/lib/db/migrations/mssql/20260729092726_talented_wilson_fisk/snapshot.json +497 -0
  157. package/src/lib/db/migrations/mssql/20260801003652_perpetual_blue_shield/migration.sql +1 -0
  158. package/src/lib/db/migrations/mssql/20260801003652_perpetual_blue_shield/snapshot.json +516 -0
  159. package/src/lib/db/migrations/mssql/20260812174642_user_id_text/migration.sql +2 -0
  160. package/src/lib/db/migrations/mssql/20260812174642_user_id_text/snapshot.json +516 -0
  161. package/src/lib/db/migrations/pg/20260801084117_thick_ben_grimm/migration.sql +53 -0
  162. package/src/lib/db/migrations/pg/20260801084117_thick_ben_grimm/snapshot.json +584 -0
  163. package/src/lib/db/migrations/pg/20260812174622_user_id_text/migration.sql +2 -0
  164. package/src/lib/db/migrations/pg/20260812174622_user_id_text/snapshot.json +584 -0
  165. package/src/lib/db/schema.mssql.ts +94 -0
  166. package/src/lib/db/schema.pg.ts +95 -0
  167. package/src/lib/factory.ts +127 -0
  168. package/src/lib/graph/index.ts +19 -0
  169. package/src/lib/graph/resolver.ts +387 -0
  170. package/src/{index.ts → lib/index.ts} +13 -5
  171. package/src/lib/redis.ts +44 -0
  172. package/src/lib/routes/chat.ts +115 -0
  173. package/src/lib/routes/files.ts +129 -0
  174. package/src/lib/routes/owned-thread.ts +17 -0
  175. package/src/{routes → lib/routes}/threads.ts +22 -31
  176. package/src/{routes → lib/routes}/ws.ts +9 -3
  177. package/src/lib/types.ts +76 -0
  178. package/src/{ws → lib/ws}/connections.ts +9 -1
  179. package/tsconfig.json +48 -0
  180. package/dist/src/adapters/database.d.ts +0 -30
  181. package/dist/src/adapters/mssql.d.ts +0 -3
  182. package/dist/src/ai/active-streams.d.ts +0 -14
  183. package/dist/src/ai/context/compressor.d.ts +0 -7
  184. package/dist/src/ai/context/index.d.ts +0 -15
  185. package/dist/src/ai/context/summarizer.d.ts +0 -5
  186. package/dist/src/ai/helpers.d.ts +0 -5
  187. package/dist/src/ai/index.d.ts +0 -4
  188. package/dist/src/ai/interceptors/request-interceptor.d.ts +0 -12
  189. package/dist/src/ai/tools/call-endpoint.tool.d.ts +0 -7
  190. package/dist/src/ai/tools/capture-files.tool.d.ts +0 -6
  191. package/dist/src/ai/tools/execute-code.tool.d.ts +0 -4
  192. package/dist/src/ai/tools/query-graph.tool.d.ts +0 -5
  193. package/dist/src/cli/extract-endpoints.d.ts +0 -6
  194. package/dist/src/config.d.ts +0 -165
  195. package/dist/src/db/migrate.d.ts +0 -1
  196. package/dist/src/graph/expand-domains.d.ts +0 -2
  197. package/dist/src/graph/generate-cypher.d.ts +0 -22
  198. package/dist/src/graph/index.d.ts +0 -11
  199. package/dist/src/graph/seed.d.ts +0 -19
  200. package/dist/src/graph/validate.d.ts +0 -2
  201. package/dist/src/routes/index.d.ts +0 -4
  202. package/dist/src/types.d.ts +0 -74
  203. package/dist/src/ws/events.d.ts +0 -39
  204. package/dist/src/ws/index.d.ts +0 -3
  205. package/dist/src/ws/notify.d.ts +0 -2
  206. package/src/adapters/database.ts +0 -29
  207. package/src/adapters/mssql.ts +0 -195
  208. package/src/ai/active-streams.ts +0 -123
  209. package/src/ai/context/compressor.ts +0 -47
  210. package/src/ai/context/index.ts +0 -75
  211. package/src/ai/context/summarizer.ts +0 -50
  212. package/src/ai/context/token-estimator.ts +0 -60
  213. package/src/ai/helpers.ts +0 -32
  214. package/src/ai/index.ts +0 -277
  215. package/src/ai/interceptors/request-interceptor.ts +0 -61
  216. package/src/ai/prompt.ts +0 -126
  217. package/src/ai/tools/call-endpoint.tool.ts +0 -89
  218. package/src/ai/tools/capture-files.tool.ts +0 -20
  219. package/src/ai/tools/execute-code.tool.ts +0 -153
  220. package/src/cli/extract-endpoints.ts +0 -550
  221. package/src/config.ts +0 -182
  222. package/src/db/migrate.ts +0 -21
  223. package/src/db/migrations/20260315000000_add_context_meta/migration.sql +0 -1
  224. package/src/db/schema.ts +0 -70
  225. package/src/factory.ts +0 -171
  226. package/src/graph/expand-domains.ts +0 -276
  227. package/src/graph/generate-cypher.ts +0 -192
  228. package/src/graph/index.ts +0 -47
  229. package/src/graph/resolver.ts +0 -357
  230. package/src/graph/seed.ts +0 -162
  231. package/src/graph/validate.ts +0 -78
  232. package/src/routes/chat.ts +0 -86
  233. package/src/routes/files.ts +0 -100
  234. package/src/routes/index.ts +0 -4
  235. package/src/types.ts +0 -74
  236. package/src/ws/events.ts +0 -39
  237. package/src/ws/index.ts +0 -11
  238. package/src/ws/notify.ts +0 -9
  239. /package/dist/src/{auth → lib/auth}/middleware.d.ts +0 -0
@@ -0,0 +1,75 @@
1
+ import type { ResolvedCortexAgentConfig } from "@/config";
2
+ import type { ChatMessage, Thread } from "@/types";
3
+ import { notify } from "@/ws/connections";
4
+ import { toThreadSummary } from "@/types";
5
+ import { optimizeThreadContext } from "./context/optimize-thread-context";
6
+ import type { CapturedStep } from "./inspector";
7
+
8
+ type FinishTurnOptions = {
9
+ /** What this run produced — the only messages the turn writes. */
10
+ messages: ChatMessage[];
11
+ /** The thread including them, which is what a summary has to be judged against. */
12
+ threadMessages: ChatMessage[];
13
+ isAborted: boolean;
14
+ assistantMessageId: string | undefined;
15
+ thread: Thread;
16
+ userId: string;
17
+ capturedSteps: CapturedStep[];
18
+ config: ResolvedCortexAgentConfig;
19
+ };
20
+
21
+ /**
22
+ * Persists everything a completed turn produced, then tells the client to refetch.
23
+ *
24
+ * The thread is re-read first: it may have been deleted mid-stream, in which case
25
+ * there is nothing to write to and the whole turn is discarded.
26
+ */
27
+ export async function finishTurn(options: FinishTurnOptions) {
28
+ const { messages, threadMessages, isAborted, assistantMessageId, thread, userId } = options;
29
+ const { capturedSteps, config } = options;
30
+
31
+ const persistedThread = await config.db.threads.getById(userId, thread.id);
32
+ if (!persistedThread) return;
33
+
34
+ await config.db.messages.upsert(thread.id, messages);
35
+ await persistCapturedSteps(capturedSteps, assistantMessageId, config);
36
+
37
+ config.onStreamFinish?.({ messages: threadMessages, isAborted });
38
+
39
+ // The turn's terminal event is held back until this write lands, so a client
40
+ // that refetches on stream end already sees committed rows. This event exists
41
+ // for the other tabs and devices, which have no stream of their own.
42
+ notify(userId, persistedThread.agentId, {
43
+ type: "thread:messages-updated",
44
+ payload: { threadId: thread.id, thread: toThreadSummary(persistedThread, false) },
45
+ });
46
+
47
+ // Fire-and-forget: the terminal event is waiting on this function, and
48
+ // summarizing the thread must not be what a user waits for.
49
+ optimizeThreadContext(thread, threadMessages, config).catch((err: unknown) => {
50
+ console.error("[cortex-server] Context optimization failed:", err);
51
+ });
52
+ }
53
+
54
+ /** Debug data only — losing it must never fail an otherwise-good turn. */
55
+ async function persistCapturedSteps(
56
+ capturedSteps: CapturedStep[],
57
+ messageId: string | undefined,
58
+ config: ResolvedCortexAgentConfig,
59
+ ) {
60
+ if (capturedSteps.length === 0 || !messageId) return;
61
+
62
+ try {
63
+ await config.db.llmRequests.insert(
64
+ capturedSteps.map((step, index) => ({
65
+ messageId,
66
+ stepNumber: index,
67
+ prompt: step.prompt,
68
+ output: step.output,
69
+ tokenUsage: step.tokenUsage,
70
+ })),
71
+ );
72
+ } catch (err) {
73
+ console.error("[cortex-server] Failed to persist AI requests:", err);
74
+ }
75
+ }
@@ -0,0 +1,100 @@
1
+ import { OpenAICompatibleChatAdapter } from "@tanstack/ai-openai/compatible";
2
+ import OpenAI from "openai";
3
+
4
+ /** Everything cortex needs to reach one OpenAI-compatible chat endpoint. */
5
+ export type ProviderConfig = {
6
+ baseURL: string;
7
+ apiKey: string;
8
+ modelName: string;
9
+ providerName?: string;
10
+ };
11
+
12
+ /**
13
+ * `openaiCompatible()` drops streamed reasoning: its `extractReasoning` hook is
14
+ * a no-op and nothing shipped overrides it. Cortex renders thinking parts, so it
15
+ * takes the sanctioned subclass seam and reads the `reasoning_content` delta.
16
+ */
17
+ class ReasoningChatAdapter extends OpenAICompatibleChatAdapter<string> {
18
+ protected override extractReasoning(chunk: unknown) {
19
+ const delta = (chunk as { choices?: { delta?: { reasoning_content?: string } }[] })
20
+ .choices?.[0]?.delta;
21
+ return delta?.reasoning_content ? { text: delta.reasoning_content } : undefined;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * `fetch` is how the caller observes or rewrites the raw HTTP exchange — the LLM
27
+ * inspector captures through it, the vision model adapts `max_tokens` through it.
28
+ */
29
+ export function createModel(
30
+ config: ProviderConfig,
31
+ options: { name?: string; fetch?: typeof globalThis.fetch } = {},
32
+ ) {
33
+ const client = new OpenAI({
34
+ baseURL: config.baseURL,
35
+ apiKey: config.apiKey,
36
+ ...(options.fetch ? { fetch: options.fetch } : {}),
37
+ });
38
+
39
+ return new ReasoningChatAdapter(
40
+ client,
41
+ config.modelName,
42
+ config.providerName ?? options.name ?? "default",
43
+ );
44
+ }
45
+
46
+ const visionModels = new WeakMap<object, ReturnType<typeof createModel>>();
47
+
48
+ /** Vision calls cap output tokens, so they need the max_tokens parameter shim. */
49
+ export function createVisionModel(config: ProviderConfig) {
50
+ const cached = visionModels.get(config);
51
+ if (cached) return cached;
52
+
53
+ const model = createModel(config, {
54
+ name: "vision",
55
+ fetch: createMaxTokensAdaptingFetch(),
56
+ });
57
+ visionModels.set(config, model);
58
+ return model;
59
+ }
60
+
61
+ export async function streamToBase64(stream: ReadableStream<Uint8Array>) {
62
+ return new Response(stream)
63
+ .arrayBuffer()
64
+ .then((buffer) => Buffer.from(buffer).toString("base64"));
65
+ }
66
+
67
+ /**
68
+ * OpenAI's newer models reject `max_tokens` in favor of `max_completion_tokens`,
69
+ * while most OpenAI-compatible gateways only accept `max_tokens`. Retry once on
70
+ * that specific rejection and remember the answer for this fetch's lifetime.
71
+ */
72
+ export function createMaxTokensAdaptingFetch() {
73
+ let renameMaxTokens = false;
74
+
75
+ function renamed(init?: RequestInit) {
76
+ if (typeof init?.body !== "string") return init;
77
+ const body = JSON.parse(init.body) as Record<string, unknown>;
78
+ if (!("max_tokens" in body)) return init;
79
+ body["max_completion_tokens"] = body["max_tokens"];
80
+ delete body["max_tokens"];
81
+ return { ...init, body: JSON.stringify(body) };
82
+ }
83
+
84
+ async function adaptiveFetch(input: string | URL | Request, init?: RequestInit) {
85
+ if (renameMaxTokens) return fetch(input, renamed(init));
86
+
87
+ const response = await fetch(input, init);
88
+ if (response.status === 400) {
89
+ const text = await response.clone().text();
90
+ if (text.includes("max_completion_tokens")) {
91
+ renameMaxTokens = true;
92
+ return fetch(input, renamed(init));
93
+ }
94
+ }
95
+ return response;
96
+ }
97
+
98
+ // Bun's fetch type carries preconnect; delegate so the provider accepts the shim.
99
+ return Object.assign(adaptiveFetch, { preconnect: fetch.preconnect });
100
+ }
@@ -0,0 +1,408 @@
1
+ import {
2
+ chat,
3
+ chatParamsFromRequest,
4
+ convertMessagesToModelMessages,
5
+ generateMessageId,
6
+ maxIterations,
7
+ mergeAgentTools,
8
+ normalizeToUIMessage,
9
+ resumeServerSentEventsResponse,
10
+ toServerSentEventsResponse,
11
+ type UIMessage,
12
+ } from "@tanstack/ai";
13
+ import { HTTPException } from "hono/http-exception";
14
+ import type { ResolvedCortexAgentConfig, ToolSet } from "@/config";
15
+ import type { ChatMessage, MessageMetadata, Thread } from "@/types";
16
+ import { createModel } from "./helpers";
17
+ import { buildSystemPrompt, resolveSession } from "./prompt";
18
+ import { ControlCenterClient } from "@/cc/client";
19
+ import { getControlCenterConfig } from "@/cc/config-cache";
20
+ import { buildPromptVariables } from "@/cc/format";
21
+ import { getRecentCcTools } from "@/cc/registry";
22
+ import { createEmbedder, createNeo4jClient } from "@cortex/contracts/graph";
23
+ import { resolveFromGraph } from "@/graph/resolver";
24
+ import { notify } from "@/ws/connections";
25
+ import { buildContextMessages, trimMessagesToFit } from "./context/builder";
26
+ import { estimateTokens } from "./context/token-estimator";
27
+ import { createCompressionMiddleware } from "./context/compression-middleware";
28
+ import { createCcRuntime } from "./cc-runtime";
29
+ import {
30
+ buildTurnTools,
31
+ createToolInstrumentation,
32
+ hasDefaultAttachmentInterceptor,
33
+ } from "./turn-tools";
34
+ import { createInspector } from "./inspector";
35
+ import { commitBeforeTerminal } from "./commit-gate";
36
+ import { finishTurn } from "./finish-turn";
37
+ import { endRun, isRunning, joinLog, producerLog, startRun } from "./active-runs";
38
+ import { toThreadSummary } from "@/types";
39
+ import { buildAttachmentListing, prepareAttachmentsForTurn } from "./attachments";
40
+
41
+ /** The AG-UI request body, as the route parsed it. */
42
+ export type TurnParams = Awaited<ReturnType<typeof chatParamsFromRequest>>;
43
+
44
+ /** Cortex's agentic loop cap; the SDK's own default is 5. */
45
+ const MAX_ITERATIONS = 25;
46
+
47
+ export async function startTurn(
48
+ params: TurnParams,
49
+ thread: Thread,
50
+ userId: string,
51
+ token: string,
52
+ requestContext: Record<string, unknown>,
53
+ config: ResolvedCortexAgentConfig,
54
+ ) {
55
+ const run = await startRun(thread.id);
56
+ try {
57
+ // The client owns its own messages, and the assistant turn it sends back
58
+ // once a client-side tool has answered — without that answer stored, the
59
+ // next turn replays an assistant message whose tool calls nothing ever
60
+ // resolved, and the provider rejects the thread from there on. Everything
61
+ // else the server generated stays the server's; the repository keeps the
62
+ // usage and model id only it ever knew.
63
+ const incoming = params.messages
64
+ .map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
65
+ .filter((message) => message.role === "user" || answersToolCalls(message));
66
+ await config.db.messages.upsert(thread.id, incoming);
67
+
68
+ const lastUserMessage = incoming.findLast((message) => message.role === "user");
69
+ const prompt = textOf(lastUserMessage);
70
+ if (thread.title === null && prompt) {
71
+ void generateTitle(thread.id, prompt, userId, config);
72
+ }
73
+
74
+ const updatedThread = await config.db.threads.touch(thread.id);
75
+
76
+ // A stop can land while the claim exists but before the run is
77
+ // announced; announcing anyway would put run-started after the abort's
78
+ // run-finished and pin the thread as running on every other tab. No
79
+ // await sits between this check and the notify, so once it passes the
80
+ // abort's run-finished can only follow the announcement.
81
+ if (run.abortController.signal.aborted) {
82
+ releaseRun(thread.id, run.runId);
83
+ return null;
84
+ }
85
+
86
+ notify(userId, thread.agentId, {
87
+ type: "thread:run-started",
88
+ payload: { thread: toThreadSummary(updatedThread, true) },
89
+ });
90
+
91
+ const threadAttachments = lastUserMessage
92
+ ? await prepareAttachmentsForTurn({
93
+ config,
94
+ threadId: thread.id,
95
+ userId,
96
+ message: lastUserMessage,
97
+ abortSignal: run.abortController.signal,
98
+ })
99
+ : await config.db.attachments.listByThread(thread.id, userId);
100
+
101
+ // Claiming rewrites the message the user just sent, and the tabs that did
102
+ // not send it are already showing that message — with the run underway,
103
+ // this is their only chance to learn which files it carries.
104
+ if (threadAttachments.some((attachment) => attachment.messageId === lastUserMessage?.id)) {
105
+ notify(userId, thread.agentId, {
106
+ type: "thread:messages-updated",
107
+ payload: { threadId: thread.id, thread: toThreadSummary(updatedThread, true) },
108
+ });
109
+ }
110
+
111
+ const turnKey = lastUserMessage?.id ?? "0";
112
+
113
+ const inspector = createInspector();
114
+ const { model, embed, neo4j, ccClient } = createTurnProviders(config, inspector.fetch);
115
+
116
+ // Run independent operations in parallel
117
+ const [contextResult, resolved, session, ccConfig, ccResolved] = await Promise.all([
118
+ // Branch A: Load messages + build token-aware context window
119
+ buildContextMessages(userId, thread, config.db, config.context),
120
+ // Branch B: Resolve graph context (400-2000ms, the bottleneck)
121
+ neo4j && embed
122
+ ? resolveFromGraph(prompt, { neo4j, embed, reranker: config.reranker })
123
+ : Promise.resolve(null),
124
+ // Branch C: Resolve session data
125
+ resolveSession(config, thread, token),
126
+ // Branch D: Control Center config (ETag-revalidated, last-known-good on outage)
127
+ ccClient
128
+ ? getControlCenterConfig(ccClient, config.agentId, run.abortController.signal)
129
+ : Promise.resolve(null),
130
+ // Branch E: Control Center /resolve — on failure the turn degrades to
131
+ // the search meta-tools, it never blocks
132
+ ccClient && prompt
133
+ ? ccClient.resolve(
134
+ config.agentId,
135
+ {
136
+ query: prompt,
137
+ hints: {
138
+ threadId: thread.id,
139
+ recentToolNames: getRecentCcTools(thread.id),
140
+ },
141
+ },
142
+ run.abortController.signal,
143
+ )
144
+ : Promise.resolve(null),
145
+ ]);
146
+
147
+ const cc = createCcRuntime({
148
+ ccClient,
149
+ ccConfig,
150
+ ccResolved,
151
+ config,
152
+ thread,
153
+ userId,
154
+ token,
155
+ turnKey,
156
+ abortSignal: run.abortController.signal,
157
+ getStepIndex: () => inspector.steps.length,
158
+ onToolProgress: (toolName, toolCallId, status) =>
159
+ emitToolProgress(userId, thread, toolName, toolCallId, status),
160
+ });
161
+
162
+ const { messages: contextMessages, allMessages } = contextResult;
163
+
164
+ const turnToolsOptions = {
165
+ config,
166
+ cc,
167
+ neo4j,
168
+ thread,
169
+ userId,
170
+ token,
171
+ session,
172
+ requestContext,
173
+ threadAttachments,
174
+ };
175
+ const tools = buildTurnTools(turnToolsOptions);
176
+ const instrumentation = createToolInstrumentation({
177
+ ...turnToolsOptions,
178
+ onToolStart: (toolName, toolCallId) =>
179
+ emitToolProgress(userId, thread, toolName, toolCallId, "started"),
180
+ onToolFinish: (toolName, toolCallId) =>
181
+ emitToolProgress(userId, thread, toolName, toolCallId, "finished"),
182
+ });
183
+
184
+ const systemPrompt = await buildSystemPrompt(
185
+ config,
186
+ resolved,
187
+ { session, requestContext },
188
+ cc
189
+ ? {
190
+ config: cc.config,
191
+ resolved: ccResolved,
192
+ variables: buildPromptVariables(cc.config.promptVariables, {
193
+ ...session,
194
+ ...requestContext,
195
+ locale: cc.locale,
196
+ }),
197
+ }
198
+ : undefined,
199
+ buildAttachmentListing(threadAttachments, lastUserMessage?.id, {
200
+ readAttachment: tools.some((tool) => tool.name === "readAttachment"),
201
+ attachmentSentinels: hasDefaultAttachmentInterceptor(config),
202
+ }),
203
+ );
204
+
205
+ const stream = chat({
206
+ adapter: model,
207
+ // Client tool declarations ride in on every request, so the server
208
+ // never re-declares them.
209
+ tools: mergeAgentTools(tools, params.tools),
210
+ messages: convertMessagesToModelMessages(
211
+ fitToContextWindow(contextMessages, systemPrompt, tools, config),
212
+ ),
213
+ systemPrompts: [systemPrompt],
214
+ agentLoopStrategy: maxIterations(MAX_ITERATIONS),
215
+ abortController: run.abortController,
216
+ middleware: [
217
+ createCompressionMiddleware(config),
218
+ ...(instrumentation ? [instrumentation] : []),
219
+ inspector.middleware,
220
+ ],
221
+ threadId: thread.id,
222
+ runId: run.runId,
223
+ });
224
+
225
+ const producer = toServerSentEventsResponse(
226
+ commitBeforeTerminal(stream, {
227
+ isAborted: () => run.abortController.signal.aborted,
228
+ commit: async function (produced) {
229
+ const messages = produced.map(toChatMessage).map(withTurnMetadata);
230
+ await finishTurn({
231
+ messages,
232
+ // Summarization and the consumer hook judge the thread, not
233
+ // the turn — the run's own output is only its tail.
234
+ threadMessages: [...allMessages, ...messages],
235
+ isAborted: run.abortController.signal.aborted,
236
+ assistantMessageId: messages.at(-1)?.id,
237
+ thread,
238
+ userId,
239
+ capturedSteps: inspector.steps,
240
+ config,
241
+ });
242
+ },
243
+ }),
244
+ {
245
+ // Append per chunk rather than per batch of 32: a client joining
246
+ // the log is waiting on real tokens, not on a buffer filling up.
247
+ durability: { adapter: producerLog(run.runId), batch: 1 },
248
+ abortController: run.abortController,
249
+ },
250
+ );
251
+
252
+ function withTurnMetadata(message: ChatMessage, index: number, all: ChatMessage[]) {
253
+ if (index !== all.length - 1 || message.role !== "assistant") return message;
254
+
255
+ return {
256
+ ...message,
257
+ metadata: {
258
+ modelId: inspector.modelId,
259
+ isAborted: run.abortController.signal.aborted,
260
+ tokenUsage: inspector.tokenUsage,
261
+ } satisfies MessageMetadata,
262
+ };
263
+ }
264
+
265
+ // Generation outlives the request that started it, so a reload mid-turn
266
+ // rejoins the log instead of restarting the model. Draining into a sink
267
+ // that never cancels is what detaches the producer from this response;
268
+ // the client gets a reader on the same log.
269
+ void producer
270
+ .body!.pipeTo(new WritableStream())
271
+ .catch(() => undefined)
272
+ .finally(() => releaseRun(thread.id, run.runId));
273
+
274
+ return resumeServerSentEventsResponse({ adapter: joinLog(thread.id, run.runId) });
275
+ } catch (error) {
276
+ releaseRun(thread.id, run.runId);
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ /** Releasing the claim must never mask the turn's own error or reject unhandled. */
282
+ function releaseRun(threadId: string, runId: string) {
283
+ endRun(threadId, runId).catch((error: unknown) =>
284
+ console.error("[cortex-server] Failed to release run claim:", error),
285
+ );
286
+ }
287
+
288
+ /** Drops the SDK's transport shape into the envelope cortex persists and serves. */
289
+ function toChatMessage(message: UIMessage) {
290
+ return { id: message.id, role: message.role, parts: message.parts } satisfies ChatMessage;
291
+ }
292
+
293
+ /**
294
+ * An assistant turn the client is sending back because it resolved a tool call.
295
+ *
296
+ * The wire fans that turn out into a `role: "tool"` message per result, and each
297
+ * of those normalizes back into an assistant message carrying only the result,
298
+ * under a freshly generated id. Stored as rows of their own they would replay as
299
+ * assistant turns answering calls that no message makes, so the turn is picked
300
+ * out by holding both halves of the exchange.
301
+ */
302
+ function answersToolCalls(message: ChatMessage) {
303
+ return (
304
+ message.role === "assistant" &&
305
+ message.parts.some((part) => part.type === "tool-call") &&
306
+ message.parts.some((part) => part.type === "tool-result")
307
+ );
308
+ }
309
+
310
+ function textOf(message: ChatMessage | undefined) {
311
+ const part = message?.parts.find((candidate) => candidate.type === "text");
312
+ return part?.type === "text" ? part.content : "";
313
+ }
314
+
315
+ /**
316
+ * Builds the clients one turn needs. Neo4j runs vector search through an embedding
317
+ * model, so configuring a graph without one is a misconfiguration we surface
318
+ * loudly rather than quietly serving every turn with no graph context.
319
+ */
320
+ function createTurnProviders(config: ResolvedCortexAgentConfig, fetch: typeof globalThis.fetch) {
321
+ if (config.neo4j && !config.embedding) {
322
+ throw new HTTPException(500, {
323
+ message: "embedding config is required when neo4j is configured",
324
+ });
325
+ }
326
+
327
+ const embed = config.embedding ? createEmbedder(config.embedding) : undefined;
328
+
329
+ return {
330
+ // The inspector's fetch is per-turn, so the model is too.
331
+ model: createModel(config.model, { fetch }),
332
+ embed,
333
+ neo4j: config.neo4j && embed ? createNeo4jClient(config.neo4j, embed) : undefined,
334
+ ccClient: config.controlCenter ? new ControlCenterClient(config.controlCenter) : null,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * The context builder reserved a static token budget for the system prompt and
340
+ * tools before either existed. Now that both are real, drop the oldest messages
341
+ * if the reserve fell short.
342
+ */
343
+ function fitToContextWindow(
344
+ contextMessages: ChatMessage[],
345
+ systemPrompt: string,
346
+ tools: ToolSet,
347
+ config: ResolvedCortexAgentConfig,
348
+ ) {
349
+ const actualFixedCost = estimateTokens(systemPrompt) + estimateTokens(JSON.stringify(tools));
350
+ const { reservedTokenBudget, maxContextTokens } = config.context;
351
+ if (actualFixedCost <= reservedTokenBudget) return contextMessages;
352
+
353
+ return trimMessagesToFit(contextMessages, maxContextTokens - actualFixedCost);
354
+ }
355
+
356
+ const TITLE_SYSTEM_PROMPT = `You are an expert in generating titles for threads of chats
357
+ given the first message in that thread.
358
+
359
+ When asked, only respond with the title without saying you're
360
+ going to do so or any other speech. Spit out only the title.`;
361
+
362
+ export async function generateTitle(
363
+ threadId: string,
364
+ prompt: string,
365
+ userId: string,
366
+ config: ResolvedCortexAgentConfig,
367
+ ) {
368
+ const title = await chat({
369
+ adapter: createModel(config.model, { name: "title" }),
370
+ stream: false,
371
+ systemPrompts: [TITLE_SYSTEM_PROMPT],
372
+ messages: [
373
+ {
374
+ id: generateMessageId(),
375
+ role: "user",
376
+ parts: [{ type: "text", content: `Generate a title for this prompt: ${prompt}` }],
377
+ },
378
+ ],
379
+ });
380
+
381
+ // A failed generation resolves to empty text rather than throwing, and the
382
+ // only trigger for a retry is the title still being null — so writing "" here
383
+ // would leave the thread permanently unnamed.
384
+ if (!title) return;
385
+
386
+ await config.db.threads.updateTitle(threadId, title);
387
+
388
+ const thread = await config.db.threads.getById(userId, threadId);
389
+ if (!thread) return;
390
+
391
+ notify(userId, thread.agentId, {
392
+ type: "thread:title-updated",
393
+ payload: { thread: toThreadSummary(thread, await isRunning(thread.id)) },
394
+ });
395
+ }
396
+
397
+ function emitToolProgress(
398
+ userId: string,
399
+ thread: Thread,
400
+ toolName: string,
401
+ toolCallId: string,
402
+ status: "started" | "finished",
403
+ ) {
404
+ notify(userId, thread.agentId, {
405
+ type: "thread:tool-progress",
406
+ payload: { threadId: thread.id, toolCallId, toolName, status },
407
+ });
408
+ }
@@ -0,0 +1,112 @@
1
+ import type { ChatMiddleware, TokenUsage as ProviderTokenUsage } from "@tanstack/ai";
2
+ import type { TokenUsage } from "@/types";
3
+
4
+ /** One model call's raw exchange, as the LLM request inspector renders it. */
5
+ export type CapturedStep = {
6
+ prompt: string;
7
+ output: string | null;
8
+ tokenUsage: TokenUsage | null;
9
+ };
10
+
11
+ /**
12
+ * Watches one turn's model calls. The SDK's `debug` logger reports call metadata
13
+ * but never raw bodies, so the raw exchange is captured where it actually
14
+ * exists — on the HTTP client — while usage arrives through middleware.
15
+ */
16
+ export function createInspector() {
17
+ const steps: CapturedStep[] = [];
18
+ let modelId = "";
19
+ let tokenUsage: TokenUsage | undefined;
20
+
21
+ async function capturingFetch(input: string | URL | Request, init?: RequestInit) {
22
+ const step: CapturedStep = {
23
+ prompt: typeof init?.body === "string" ? init.body : "",
24
+ output: null,
25
+ tokenUsage: null,
26
+ };
27
+ steps.push(step);
28
+
29
+ const response = await fetch(input, init);
30
+ // Drain a tee rather than the response itself: the model call must keep
31
+ // streaming at full speed, and losing debug data must never fail a turn.
32
+ void response
33
+ .clone()
34
+ .text()
35
+ .then(function (text) {
36
+ step.output = text;
37
+ })
38
+ .catch(() => undefined);
39
+
40
+ return response;
41
+ }
42
+
43
+ const middleware: ChatMiddleware = {
44
+ name: "cortex-inspector",
45
+ onUsage(ctx, usage) {
46
+ modelId = ctx.model;
47
+ const stepUsage = toTokenUsage(usage);
48
+ const lastStep = steps.at(-1);
49
+ if (lastStep) lastStep.tokenUsage = stepUsage;
50
+ tokenUsage = addTokenUsage(tokenUsage, stepUsage);
51
+ },
52
+ };
53
+
54
+ return {
55
+ steps,
56
+ middleware,
57
+ // Bun's fetch type carries preconnect; delegate so the client accepts the shim.
58
+ fetch: Object.assign(capturingFetch, { preconnect: fetch.preconnect }),
59
+ get modelId() {
60
+ return modelId;
61
+ },
62
+ get tokenUsage() {
63
+ return tokenUsage;
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Flattens the provider's usage shape into ours. The provider reports cached and
70
+ * cache-written prompt tokens but no uncached counter, so that dimension is the
71
+ * remainder — which is what makes the client's breakdown add up to the total.
72
+ */
73
+ export function toTokenUsage(usage: ProviderTokenUsage) {
74
+ const cacheRead = usage.promptTokensDetails?.cachedTokens ?? 0;
75
+ const cacheWrite = usage.promptTokensDetails?.cacheWriteTokens ?? 0;
76
+ const reasoning = usage.completionTokensDetails?.reasoningTokens ?? 0;
77
+
78
+ return {
79
+ input: {
80
+ noCache: Math.max(usage.promptTokens - cacheRead - cacheWrite, 0),
81
+ cacheRead,
82
+ cacheWrite,
83
+ total: usage.promptTokens,
84
+ },
85
+ output: {
86
+ reasoning,
87
+ text: usage.completionTokensDetails?.textTokens ?? usage.completionTokens - reasoning,
88
+ total: usage.completionTokens,
89
+ },
90
+ total: usage.totalTokens,
91
+ } satisfies TokenUsage;
92
+ }
93
+
94
+ /** The agent loop reports usage per iteration; a turn's cost is their sum. */
95
+ function addTokenUsage(left: TokenUsage | undefined, right: TokenUsage) {
96
+ if (!left) return right;
97
+
98
+ return {
99
+ input: {
100
+ noCache: left.input.noCache + right.input.noCache,
101
+ cacheRead: left.input.cacheRead + right.input.cacheRead,
102
+ cacheWrite: left.input.cacheWrite + right.input.cacheWrite,
103
+ total: left.input.total + right.input.total,
104
+ },
105
+ output: {
106
+ reasoning: left.output.reasoning + right.output.reasoning,
107
+ text: left.output.text + right.output.text,
108
+ total: left.output.total + right.output.total,
109
+ },
110
+ total: left.total + right.total,
111
+ } satisfies TokenUsage;
112
+ }