@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,89 @@
1
+ import type { DatabaseAdapter } from "@/adapters/database/index";
2
+ import type { StorageAdapter } from "@/adapters/storage/index";
3
+ import { streamToBase64 } from "@/ai/helpers";
4
+
5
+ export type ResolvedFile = {
6
+ name: string;
7
+ bytes: string;
8
+ };
9
+
10
+ export type RequestInterceptorOptions = {
11
+ transformFile?: (file: ResolvedFile) => unknown;
12
+ };
13
+
14
+ export function createRequestInterceptor(
15
+ db: DatabaseAdapter,
16
+ storage: StorageAdapter,
17
+ options: RequestInterceptorOptions & { threadId: string; userId: string },
18
+ ) {
19
+ const transformFile = options.transformFile ?? ((file: ResolvedFile) => file);
20
+
21
+ return async function resolveRequestBody(body: Record<string, unknown>) {
22
+ const resolvedAttachments = new Map<string, { value: unknown }>();
23
+ const pending = Object.entries(body).map(([key, value]) => ({
24
+ value,
25
+ replace: (resolved: unknown) => {
26
+ body[key] = resolved;
27
+ },
28
+ }));
29
+
30
+ async function resolveAttachment(id: string) {
31
+ const cached = resolvedAttachments.get(id);
32
+ if (cached) return cached;
33
+
34
+ const attachment = await db.attachments.getById(id, options.userId);
35
+ if (!attachment || attachment.threadId !== options.threadId) {
36
+ throw new Error(`Attachment "${id}" is not available in this thread`);
37
+ }
38
+
39
+ let bytes: string;
40
+ try {
41
+ bytes = await streamToBase64(await storage.stream(attachment.storageKey));
42
+ } catch (error) {
43
+ throw new Error(`Attachment "${id}" could not be loaded from storage`, {
44
+ cause: error,
45
+ });
46
+ }
47
+ const resolved = { value: transformFile({ name: attachment.filename, bytes }) };
48
+ resolvedAttachments.set(id, resolved);
49
+ return resolved;
50
+ }
51
+
52
+ while (pending.length) {
53
+ const { value, replace } = pending.pop()!;
54
+
55
+ if (Array.isArray(value)) {
56
+ const items: unknown[] = value;
57
+ pending.push(
58
+ ...items.map((child, index) => ({
59
+ value: child,
60
+ replace: (resolved: unknown) => {
61
+ items[index] = resolved;
62
+ },
63
+ })),
64
+ );
65
+ continue;
66
+ }
67
+
68
+ if (typeof value === "object" && value !== null) {
69
+ const object = value as Record<string, unknown>;
70
+ pending.push(
71
+ ...Object.entries(object).map(([key, child]) => ({
72
+ value: child,
73
+ replace: (resolved: unknown) => {
74
+ object[key] = resolved;
75
+ },
76
+ })),
77
+ );
78
+ continue;
79
+ }
80
+
81
+ if (typeof value !== "string" || !value.startsWith("attachment#")) continue;
82
+
83
+ const resolved = await resolveAttachment(value.slice("attachment#".length));
84
+ replace(resolved.value);
85
+ }
86
+
87
+ return body;
88
+ };
89
+ }
@@ -0,0 +1,355 @@
1
+ import type { ResolvedContext, ResolvedEndpoint, ResolvedService } from "@/graph/resolver";
2
+ import type { PromptContext, ResolvedCortexAgentConfig } from "@/config";
3
+ import type { Thread } from "@/types";
4
+ import type { CcPromptInput } from "@/cc/format";
5
+ import { buildCcSection } from "@/cc/format";
6
+
7
+ /**
8
+ * Resolves session data for the thread, loading from the configured
9
+ * session loader if not already cached on the thread.
10
+ */
11
+ export async function resolveSession(
12
+ config: ResolvedCortexAgentConfig,
13
+ thread: Thread,
14
+ token: string,
15
+ ) {
16
+ let session = thread.session;
17
+
18
+ if (!session && config.loadSessionData) {
19
+ session = await config.loadSessionData(token);
20
+ // Persist to DB for future cache hits
21
+ await config.db.threads.updateSession(thread.id, session);
22
+ thread.session = session;
23
+ }
24
+
25
+ return session;
26
+ }
27
+
28
+ export async function buildSystemPrompt(
29
+ config: ResolvedCortexAgentConfig,
30
+ resolved: ResolvedContext | null,
31
+ promptContext: PromptContext,
32
+ cc?: CcPromptInput,
33
+ attachmentListing?: string,
34
+ ) {
35
+ // Resolve the consumer's base system prompt
36
+ let basePrompt: string;
37
+ if (typeof config.systemPrompt === "function") {
38
+ basePrompt = await config.systemPrompt(promptContext);
39
+ } else {
40
+ basePrompt = config.systemPrompt ?? "";
41
+ }
42
+
43
+ const sections: string[] = basePrompt ? [basePrompt] : [];
44
+
45
+ // Pre-resolved endpoints from knowledge graph
46
+ if (resolved) {
47
+ sections.push(buildResolvedSection(resolved));
48
+ }
49
+
50
+ // Control Center: published prompt + per-turn resolved catalog section
51
+ if (cc) {
52
+ const ccSection = buildCcSection(cc);
53
+ if (ccSection) sections.push(ccSection);
54
+ }
55
+
56
+ if (attachmentListing) {
57
+ sections.push(attachmentListing);
58
+ }
59
+
60
+ return sections.join("\n");
61
+ }
62
+
63
+ type SchemaField = {
64
+ name: string;
65
+ required?: boolean;
66
+ type?: string;
67
+ isArray?: boolean;
68
+ properties?: SchemaField[];
69
+ };
70
+
71
+ /** Compression layer 2. Compact param/body: `name?: type, nested?: {a: string, b: number}[]` */
72
+ function compactParamFields(fields: SchemaField[]): string {
73
+ return fields
74
+ .map((f) => {
75
+ const opt = f.required ? "" : "?";
76
+ const arr = f.isArray ? "[]" : "";
77
+ if (f.properties && f.properties.length > 0) {
78
+ return `${f.name}${opt}: {${compactParamFields(f.properties)}}${arr}`;
79
+ }
80
+ return `${f.name}${opt}: ${f.type ?? "unknown"}${arr}`;
81
+ })
82
+ .join(", ");
83
+ }
84
+
85
+ function compactParams(jsonStr: string) {
86
+ try {
87
+ const fields = JSON.parse(jsonStr) as SchemaField[];
88
+ if (!Array.isArray(fields) || fields.length === 0) return "(none)";
89
+ return compactParamFields(fields);
90
+ } catch {
91
+ return jsonStr;
92
+ }
93
+ }
94
+
95
+ /** Compact response: `items[].{id, employee.{id, name.{ar, en}}}, count` */
96
+ function compactResponseFields(fields: SchemaField[]): string {
97
+ return fields
98
+ .map((f) => {
99
+ const arr = f.isArray ? "[]" : "";
100
+ if (f.properties && f.properties.length > 0) {
101
+ return `${f.name}${arr}.{${compactResponseFields(f.properties)}}`;
102
+ }
103
+ return f.name;
104
+ })
105
+ .join(", ");
106
+ }
107
+
108
+ function compactResponse(jsonStr: string) {
109
+ try {
110
+ const fields = JSON.parse(jsonStr) as SchemaField[];
111
+ if (!Array.isArray(fields) || fields.length === 0) return "(none)";
112
+ return compactResponseFields(fields);
113
+ } catch {
114
+ return jsonStr;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Compression layer 1. Endpoints reached via several concepts arrive as
120
+ * duplicates sharing one method+path; collapsing them first means every later
121
+ * layer works on one entry per real endpoint.
122
+ */
123
+ type GroupedEndpoint = Omit<ResolvedEndpoint, "concept"> & {
124
+ concepts: string[];
125
+ };
126
+
127
+ function addUnique<T>(list: T[], value: T, matches: (a: T, b: T) => boolean = Object.is) {
128
+ if (!list.some((item) => matches(item, value))) {
129
+ list.push(value);
130
+ }
131
+ }
132
+
133
+ /** Folds a second endpoint sharing this method and path into the group already collected. */
134
+ function mergeEndpoint(existing: GroupedEndpoint, ep: ResolvedEndpoint) {
135
+ addUnique(existing.concepts, ep.concept);
136
+
137
+ for (const rule of ep.rules) {
138
+ addUnique(existing.rules, rule);
139
+ }
140
+ for (const dep of ep.dependencies) {
141
+ addUnique(
142
+ existing.dependencies,
143
+ dep,
144
+ (a, b) => a.depPath === b.depPath && a.paramName === b.paramName,
145
+ );
146
+ }
147
+ }
148
+
149
+ /** The arrays are copied because merging mutates them, and the source endpoint is shared. */
150
+ function toGroup(ep: ResolvedEndpoint) {
151
+ const { concept, ...rest } = ep;
152
+ return {
153
+ ...rest,
154
+ concepts: [concept],
155
+ dependencies: [...ep.dependencies],
156
+ rules: [...ep.rules],
157
+ };
158
+ }
159
+
160
+ function groupEndpointsBySignature(endpoints: ResolvedEndpoint[]) {
161
+ const groups = new Map<string, GroupedEndpoint>();
162
+
163
+ for (const ep of endpoints) {
164
+ const key = `${ep.method}|${ep.path}`;
165
+ const existing = groups.get(key);
166
+
167
+ if (existing) {
168
+ mergeEndpoint(existing, ep);
169
+ } else {
170
+ groups.set(key, toGroup(ep));
171
+ }
172
+ }
173
+
174
+ return Array.from(groups.values());
175
+ }
176
+
177
+ /**
178
+ * Compression layer 3. Hoists rules repeated across endpoints into one global
179
+ * section, so a rule governing twenty endpoints is stated once, not twenty times.
180
+ */
181
+ function extractSharedRules(groups: GroupedEndpoint[], services: ResolvedService[]) {
182
+ const ruleCounts = new Map<string, number>();
183
+
184
+ for (const ep of groups) {
185
+ for (const rule of ep.rules) {
186
+ ruleCounts.set(rule, (ruleCounts.get(rule) ?? 0) + 1);
187
+ }
188
+ }
189
+ for (const svc of services) {
190
+ for (const rule of svc.rules) {
191
+ ruleCounts.set(rule, (ruleCounts.get(rule) ?? 0) + 1);
192
+ }
193
+ }
194
+
195
+ const sharedRules = [...ruleCounts.entries()]
196
+ .filter(([, count]) => count >= 2)
197
+ .map(([rule]) => rule);
198
+
199
+ const sharedSet = new Set(sharedRules);
200
+
201
+ return {
202
+ sharedRules,
203
+ groups: groups.map((ep) => ({
204
+ ...ep,
205
+ rules: ep.rules.filter((r) => !sharedSet.has(r)),
206
+ })),
207
+ services: services.map((svc) => ({
208
+ ...svc,
209
+ rules: svc.rules.filter((r) => !sharedSet.has(r)),
210
+ })),
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Compression layer 4. Endpoints returning an identical response schema get one
216
+ * named shape definition and a reference each, instead of repeating the schema.
217
+ */
218
+ type ResponseShapeRefs = {
219
+ shapeDefs: { name: string; content: string }[];
220
+ refMap: Map<string, string>;
221
+ };
222
+
223
+ function buildResponseShapeRefs(groups: GroupedEndpoint[]) {
224
+ // Key on the raw JSON string (byte-identical for endpoints sharing the same
225
+ // Endpoint node in Neo4j). Fall back to the compact string for cases where
226
+ // different raw JSON produces the same compact shape.
227
+ const rawToEndpoints = new Map<string, string[]>();
228
+
229
+ for (const ep of groups) {
230
+ if (ep.response === "[]") continue;
231
+ const existing = rawToEndpoints.get(ep.response);
232
+ if (existing) {
233
+ existing.push(`${ep.method} ${ep.path}`);
234
+ } else {
235
+ rawToEndpoints.set(ep.response, [`${ep.method} ${ep.path}`]);
236
+ }
237
+ }
238
+
239
+ // Second pass: also group by compact output to catch different raw JSON
240
+ // that normalizes to the same shape
241
+ const compactToRaws = new Map<string, Set<string>>();
242
+ for (const raw of rawToEndpoints.keys()) {
243
+ const compact = compactResponse(raw);
244
+ const existing = compactToRaws.get(compact);
245
+ if (existing) {
246
+ existing.add(raw);
247
+ } else {
248
+ compactToRaws.set(compact, new Set([raw]));
249
+ }
250
+ }
251
+
252
+ // Merge: a shape is shared if its raw string appears on 2+ endpoints,
253
+ // OR if multiple raw strings compact to the same output
254
+ const shapeDefs: { name: string; content: string }[] = [];
255
+ const refMap = new Map<string, string>();
256
+ let idx = 1;
257
+
258
+ for (const [compact, raws] of compactToRaws) {
259
+ let totalEndpoints = 0;
260
+ for (const raw of raws) {
261
+ totalEndpoints += rawToEndpoints.get(raw)!.length;
262
+ }
263
+
264
+ if (totalEndpoints >= 2) {
265
+ const name = `$R${idx++}`;
266
+ shapeDefs.push({ name, content: compact });
267
+ refMap.set(compact, name);
268
+ }
269
+ }
270
+
271
+ return { shapeDefs, refMap };
272
+ }
273
+
274
+ /** A heading and its lines, or nothing at all when there are no lines to show. */
275
+ function renderSection(heading: string, lines: string[]) {
276
+ if (lines.length === 0) return [];
277
+ return [`\n### ${heading}`, ...lines.map((line) => `\n${line}`)];
278
+ }
279
+
280
+ function renderEndpoint(ep: GroupedEndpoint, refMap: ResponseShapeRefs["refMap"]) {
281
+ const compactResp = compactResponse(ep.response);
282
+ const body = compactParams(ep.body);
283
+
284
+ const lines = [
285
+ `\n### ${ep.name} — ${ep.method} ${ep.path} (${ep.relation})`,
286
+ `\n- Concepts: ${ep.concepts.join(", ")}`,
287
+ `\n- Params: ${compactParams(ep.params)}`,
288
+ body === "(none)" ? "" : `\n- Body: ${body}`,
289
+ `\n- Response: ${refMap.get(compactResp) ?? compactResp}`,
290
+ ep.rules.length > 0 ? `\n Rules: ${ep.rules.join("; ")}` : "",
291
+ renderDependencies(ep.dependencies),
292
+ ep.metadata === "{}" ? "" : `\n Metadata: ${ep.metadata}`,
293
+ ];
294
+
295
+ return lines.join("");
296
+ }
297
+
298
+ function renderDependencies(dependencies: GroupedEndpoint["dependencies"]) {
299
+ if (dependencies.length === 0) return "";
300
+
301
+ const calls = dependencies.map(
302
+ (d) =>
303
+ ` - Call ${d.depMethod} ${d.depPath} first → use its "${d.fromField}" as "${d.paramName}"`,
304
+ );
305
+ return `\n Dependencies:\n${calls.join("\n")}`;
306
+ }
307
+
308
+ function renderService(svc: ResolvedService) {
309
+ const rules = svc.rules.length > 0 ? `\n Rules: ${svc.rules.join("; ")}` : "";
310
+ const meta = svc.metadata === "{}" ? "" : `\n- Metadata: ${svc.metadata}`;
311
+
312
+ return (
313
+ `\n### ${svc.concept} via ${svc.serviceName} (service)` +
314
+ `\n- Built-in ID: ${svc.builtInId}` +
315
+ `\n- Description: ${svc.description || "N/A"}${rules}${meta}`
316
+ );
317
+ }
318
+
319
+ function buildResolvedSection(resolved: ResolvedContext) {
320
+ const allEndpoints = [...resolved.readEndpoints, ...resolved.writeEndpoints];
321
+
322
+ if (allEndpoints.length === 0 && resolved.services.length === 0) {
323
+ return "\n## Pre-resolved API Endpoints\nNo matching endpoints found. Use queryGraph to search the knowledge graph manually.";
324
+ }
325
+
326
+ // Layer 1: deduplicate endpoints by method+path
327
+ const grouped = groupEndpointsBySignature(allEndpoints);
328
+
329
+ // Layer 3: hoist repeated rules to a shared section
330
+ const {
331
+ sharedRules,
332
+ groups: cleanedGroups,
333
+ services: cleanedServices,
334
+ } = extractSharedRules(grouped, resolved.services);
335
+
336
+ // Layer 4: deduplicate identical response schemas
337
+ const { shapeDefs, refMap } = buildResponseShapeRefs(cleanedGroups);
338
+
339
+ return [
340
+ `
341
+ ## Pre-resolved API Endpoints
342
+ The following endpoints were automatically matched to the user's message.`,
343
+ ...renderSection(
344
+ "General Rules",
345
+ sharedRules.map((rule) => `- ${rule}`),
346
+ ),
347
+ ...renderSection(
348
+ "Response Shapes",
349
+ shapeDefs.map((def) => `${def.name}: ${def.content}`),
350
+ ),
351
+ // Layer 2: compact schemas
352
+ ...cleanedGroups.map((ep) => renderEndpoint(ep, refMap)),
353
+ ...cleanedServices.map(renderService),
354
+ ].join("");
355
+ }
@@ -0,0 +1,167 @@
1
+ import type { RedisConnections } from "@/redis";
2
+ import type { ActiveRun, RunCoordinator } from "./active-runs";
3
+ import { JOIN_FIRST_CHUNK_TIMEOUT_MS } from "./active-runs";
4
+ import { offsetBelongsToRun, redisStreamLog } from "./redis-stream-log";
5
+
6
+ /**
7
+ * The claim is the producer's liveness signal: a heartbeat refreshes its short
8
+ * TTL while the run is live, so a claim that expired means the producer died —
9
+ * that is how joiners on an orphaned run learn to stop tailing, and how a
10
+ * crashed instance's threads free up within a minute instead of fifteen. The
11
+ * stream TTL governs how long a run's chunk log stays replayable.
12
+ */
13
+ const CLAIM_TTL_SECONDS = 60;
14
+ const CLAIM_HEARTBEAT_MS = 20_000;
15
+ const DEFAULT_STREAM_TTL_SECONDS = 24 * 60 * 60;
16
+
17
+ /**
18
+ * Cross-instance abort: the producer's AbortController lives in-process on
19
+ * whichever instance runs the turn, but a stop may land on any instance — so
20
+ * abort publishes the run id and every instance listens, aborting the run if
21
+ * it owns the controller. Single-instance works too: the publisher's own
22
+ * subscriber receives the message.
23
+ */
24
+ const ABORT_CHANNEL = "cortex:chat:abort";
25
+
26
+ /**
27
+ * The claim doubles as mutex and discovery pointer: a joiner that only knows
28
+ * the thread id finds the live run here.
29
+ */
30
+ function claimKey(threadId: string) {
31
+ return `cortex:chat:active:${threadId}`;
32
+ }
33
+
34
+ /**
35
+ * Atomically take the claim and learn who held it. Two concurrent starts on
36
+ * one thread each see the other exactly once, so the loser is always aborted —
37
+ * a GET-then-SET pair could interleave and leave both producers running.
38
+ */
39
+ const CLAIM_SWAP = `local previous = redis.call("get", KEYS[1])
40
+ redis.call("set", KEYS[1], ARGV[1], "EX", ARGV[2])
41
+ return previous`;
42
+
43
+ /** Release only if still held by this run — a superseded run finishing late
44
+ * must not evict its replacement. GET-then-DEL would race; the script cannot. */
45
+ const RELEASE_IF_HELD = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
46
+
47
+ /** The heartbeat must never resurrect a claim that abort or supersede released. */
48
+ const REFRESH_IF_HELD = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end`;
49
+
50
+ type RedisRunOptions = {
51
+ streamTtlSeconds?: number;
52
+ };
53
+
54
+ export function createRedisRuns(connections: RedisConnections, options: RedisRunOptions = {}) {
55
+ const { publisher, subscriber } = connections;
56
+ const streamTtlMs = (options.streamTtlSeconds ?? DEFAULT_STREAM_TTL_SECONDS) * 1000;
57
+ const logOptions = { streamTtlMs, firstChunkDeadlineMs: JOIN_FIRST_CHUNK_TIMEOUT_MS };
58
+ const aborters = new Map<string, AbortController>();
59
+ const heartbeats = new Map<string, ReturnType<typeof setInterval>>();
60
+
61
+ subscriber.on("message", function (channel: string, runId: string) {
62
+ if (channel === ABORT_CHANNEL) aborters.get(runId)?.abort();
63
+ });
64
+
65
+ /**
66
+ * Lazy and retried, never memoized-rejected: caching one boot-time
67
+ * subscribe promise would leave every future startRun rethrowing a single
68
+ * transient outage forever. Once it resolves, ioredis re-subscribes by
69
+ * itself across reconnects.
70
+ */
71
+ let abortSubscription: Promise<unknown> | undefined;
72
+ function ensureAbortSubscription() {
73
+ abortSubscription ??= subscriber.subscribe(ABORT_CHANNEL).catch((error: unknown) => {
74
+ abortSubscription = undefined;
75
+ throw error;
76
+ });
77
+ return abortSubscription;
78
+ }
79
+
80
+ async function abortRun(threadId: string) {
81
+ const runId = await publisher.get(claimKey(threadId));
82
+ if (!runId) return false;
83
+ // The local abort is immediate; the publish reaches the other instances.
84
+ aborters.get(runId)?.abort();
85
+ await publisher.publish(ABORT_CHANNEL, runId);
86
+ await publisher.eval(RELEASE_IF_HELD, 1, claimKey(threadId), runId);
87
+ return true;
88
+ }
89
+
90
+ function startHeartbeat(threadId: string, runId: string) {
91
+ const timer = setInterval(function () {
92
+ void publisher
93
+ .eval(REFRESH_IF_HELD, 1, claimKey(threadId), runId, String(CLAIM_TTL_SECONDS))
94
+ .catch((error: unknown) =>
95
+ console.error("[cortex-server] redis claim heartbeat:", error),
96
+ );
97
+ }, CLAIM_HEARTBEAT_MS);
98
+ heartbeats.set(runId, timer);
99
+ }
100
+
101
+ function stopHeartbeat(runId: string) {
102
+ const timer = heartbeats.get(runId);
103
+ if (timer !== undefined) clearInterval(timer);
104
+ heartbeats.delete(runId);
105
+ }
106
+
107
+ return {
108
+ async startRun(threadId) {
109
+ // A producer must be able to hear aborts before it may exist, or a
110
+ // stop from another instance could slip by while we boot.
111
+ await ensureAbortSubscription();
112
+ const run: ActiveRun = {
113
+ runId: crypto.randomUUID(),
114
+ abortController: new AbortController(),
115
+ };
116
+ // Registered before the claim exists, so an abort cannot land in
117
+ // between; unregistered again if the claim never materializes.
118
+ aborters.set(run.runId, run.abortController);
119
+ try {
120
+ const superseded = (await publisher.eval(
121
+ CLAIM_SWAP,
122
+ 1,
123
+ claimKey(threadId),
124
+ run.runId,
125
+ String(CLAIM_TTL_SECONDS),
126
+ )) as string | null;
127
+ if (superseded !== null) {
128
+ aborters.get(superseded)?.abort();
129
+ await publisher.publish(ABORT_CHANNEL, superseded);
130
+ }
131
+ } catch (error) {
132
+ aborters.delete(run.runId);
133
+ throw error;
134
+ }
135
+ startHeartbeat(threadId, run.runId);
136
+ return run;
137
+ },
138
+ async getRunId(threadId) {
139
+ return (await publisher.get(claimKey(threadId))) ?? undefined;
140
+ },
141
+ async runningThreadIds(threadIds) {
142
+ if (threadIds.length === 0) return new Set<string>();
143
+ const claims = await publisher.mget(threadIds.map(claimKey));
144
+ return new Set(threadIds.filter((_, index) => claims[index] !== null));
145
+ },
146
+ abortRun,
147
+ async endRun(threadId, runId) {
148
+ stopHeartbeat(runId);
149
+ aborters.delete(runId);
150
+ await publisher.eval(RELEASE_IF_HELD, 1, claimKey(threadId), runId);
151
+ },
152
+ producerLog(runId) {
153
+ return redisStreamLog(publisher, runId, { resumeOffset: null, ...logOptions });
154
+ },
155
+ joinLog(threadId, runId, offset = "-1") {
156
+ return redisStreamLog(publisher, runId, {
157
+ resumeOffset: offset,
158
+ // The claim is the producer's pulse: while this run holds it the
159
+ // reader may park between chunks indefinitely, and once it is
160
+ // gone with the log still unclosed, the producer died.
161
+ isLive: async () => (await publisher.get(claimKey(threadId))) === runId,
162
+ ...logOptions,
163
+ });
164
+ },
165
+ offsetBelongsToRun,
166
+ } satisfies RunCoordinator;
167
+ }