@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,267 @@
1
+ import type { MessagePart } from "@tanstack/ai";
2
+ import { estimateTokens, CHARS_PER_TOKEN } from "./token-estimator";
3
+ import type { ChatMessage } from "@/types";
4
+
5
+ /**
6
+ * Returns a new array of messages with large tool outputs truncated
7
+ * to `maxTokensPerResult`. Does not mutate the input messages.
8
+ */
9
+ export function compressToolResults(messages: ChatMessage[], maxTokensPerResult: number) {
10
+ return messages.map((message) => {
11
+ const parts = message.parts.map((part) => compressPart(part, maxTokensPerResult));
12
+ const changed = parts.some((part, index) => part !== message.parts[index]);
13
+
14
+ return changed ? { ...message, parts } : message;
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Both halves of a tool call can carry a payload big enough to crowd the window:
20
+ * the result the tool returned, and the output stashed on the call itself when the
21
+ * client executed it.
22
+ */
23
+ function compressPart(part: MessagePart, maxTokensPerResult: number) {
24
+ if (part.type === "tool-result") {
25
+ const compressed = compressPayload(part.content, maxTokensPerResult);
26
+ return compressed === null ? part : { ...part, content: compressed };
27
+ }
28
+
29
+ if (part.type === "tool-call" && part.output !== undefined) {
30
+ const compressed = compressPayload(part.output, maxTokensPerResult);
31
+ return compressed === null ? part : { ...part, output: compressed };
32
+ }
33
+
34
+ return part;
35
+ }
36
+
37
+ /** Returns the compressed form, or null when the payload already fits. */
38
+ function compressPayload(value: unknown, maxTokens: number) {
39
+ if (estimateTokens(JSON.stringify(value)) <= maxTokens) return null;
40
+
41
+ return smartStructuralCompress(value, maxTokens * CHARS_PER_TOKEN);
42
+ }
43
+
44
+ /**
45
+ * Structurally compresses a value to fit within a character budget.
46
+ * Understands JSON arrays, error objects, and nested objects — preserving
47
+ * semantic meaning better than naive string truncation.
48
+ */
49
+ // eslint-disable-next-line m6d/no-explicit-return-type -- mutually recursive with compressString, so the return type cannot be inferred
50
+ function smartStructuralCompress(value: unknown, charBudget: number): string {
51
+ if (typeof value === "string") {
52
+ return compressString(value, charBudget);
53
+ }
54
+
55
+ if (Array.isArray(value)) {
56
+ return compressArray(value, charBudget);
57
+ }
58
+
59
+ if (typeof value === "object" && value !== null) {
60
+ return compressObject(value as Record<string, unknown>, charBudget);
61
+ }
62
+
63
+ // Primitives
64
+ const str = String(value);
65
+ if (str.length <= charBudget) return str;
66
+ return str.slice(0, charBudget - 20) + "\n[...truncated]";
67
+ }
68
+
69
+ function compressString(value: string, charBudget: number) {
70
+ if (value.length <= charBudget) return value;
71
+
72
+ // Try to parse as JSON first — if it's a stringified structure, compress structurally
73
+ try {
74
+ const parsed: unknown = JSON.parse(value);
75
+ if (typeof parsed === "object" && parsed !== null) {
76
+ const result = smartStructuralCompress(parsed, charBudget);
77
+ return result;
78
+ }
79
+ } catch {
80
+ // Not JSON, just truncate
81
+ }
82
+
83
+ return value.slice(0, charBudget - 20) + "\n[...truncated]";
84
+ }
85
+
86
+ function compressArray(value: unknown[], charBudget: number) {
87
+ const totalCount = value.length;
88
+
89
+ if (totalCount === 0) return "[]";
90
+
91
+ // Infer schema from first object element (if items are objects)
92
+ const firstObj = value.find(
93
+ (item): item is Record<string, unknown> =>
94
+ typeof item === "object" && item !== null && !Array.isArray(item),
95
+ );
96
+ const schemaKeys = firstObj ? Object.keys(firstObj) : null;
97
+
98
+ // Keep as many items as fit within ~60% of budget, leaving room for the summary footer
99
+ const itemBudget = Math.floor(charBudget * 0.6);
100
+ const items: unknown[] = [];
101
+ let accumulated = 0;
102
+
103
+ for (const item of value) {
104
+ const itemStr = JSON.stringify(item);
105
+ if (accumulated + itemStr.length > itemBudget && items.length > 0) break;
106
+ accumulated += itemStr.length;
107
+ items.push(item);
108
+ }
109
+
110
+ const shownStr = JSON.stringify(items, null, 2);
111
+ const remaining = totalCount - items.length;
112
+
113
+ if (remaining === 0 && shownStr.length <= charBudget) return shownStr;
114
+
115
+ const schemaHint = schemaKeys ? ` Schema: {${schemaKeys.join(", ")}}` : "";
116
+ const footer = `\n[COMPRESSED: ${remaining} more items of ${totalCount} total not shown.${schemaHint} Re-execute with filters or pagination to access specific items.]`;
117
+
118
+ const result = shownStr + footer;
119
+ if (result.length <= charBudget) return result;
120
+
121
+ // If still too large, fall back to a tighter representation
122
+ const tightStr = JSON.stringify(items);
123
+ return tightStr.slice(0, charBudget - footer.length - 20) + footer;
124
+ }
125
+
126
+ function compressObject(value: Record<string, unknown>, charBudget: number) {
127
+ // Check if it looks like an error — keep message, drop stack
128
+ if (isErrorLike(value)) {
129
+ return compressErrorObject(value, charBudget);
130
+ }
131
+
132
+ const fullStr = JSON.stringify(value, null, 2);
133
+ if (fullStr.length <= charBudget) return fullStr;
134
+
135
+ const allKeys = Object.keys(value);
136
+
137
+ // Phase 1: Partition keys into scalar (cheap) and complex (expensive)
138
+ const scalarEntries: string[] = [];
139
+ const complexKeys: string[] = [];
140
+ let scalarCharsUsed = 0;
141
+
142
+ for (const key of allKeys) {
143
+ const val = value[key];
144
+ const valStr = JSON.stringify(val);
145
+
146
+ if (valStr === undefined) {
147
+ const entry = ` "${key}": null`;
148
+ scalarEntries.push(entry);
149
+ scalarCharsUsed += entry.length + 2; // +2 for comma and newline
150
+ } else if (isScalar(val)) {
151
+ const entry = ` "${key}": ${valStr}`;
152
+ scalarEntries.push(entry);
153
+ scalarCharsUsed += entry.length + 2;
154
+ } else {
155
+ complexKeys.push(key);
156
+ }
157
+ }
158
+
159
+ // Phase 2: Distribute remaining budget to complex values
160
+ const compressionHeader = ` "__compressed": "Some values were compressed. If you need the full data for a specific key, re-execute the tool returning only that key."`;
161
+ const headerCost = compressionHeader.length + 2;
162
+ const structuralOverhead = 4; // { } and newlines
163
+ const remainingBudget = charBudget - scalarCharsUsed - headerCost - structuralOverhead;
164
+ const complexBudget = Math.floor(
165
+ Math.max(remainingBudget, 100) / Math.max(complexKeys.length, 1),
166
+ );
167
+
168
+ const complexEntries: string[] = [];
169
+ const truncatedKeys: string[] = [];
170
+
171
+ for (const key of complexKeys) {
172
+ const val = value[key];
173
+ const valStr = JSON.stringify(val);
174
+
175
+ if (valStr !== undefined && valStr.length <= complexBudget) {
176
+ complexEntries.push(` "${key}": ${valStr}`);
177
+ } else {
178
+ truncatedKeys.push(key);
179
+ complexEntries.push(` "${key}": ${summarizeValue(val)}`);
180
+ }
181
+ }
182
+
183
+ // Build result with compression notice
184
+ const allEntries = [...scalarEntries, ...complexEntries];
185
+
186
+ if (truncatedKeys.length > 0) {
187
+ allEntries.push(compressionHeader);
188
+ allEntries.push(` "__truncatedKeys": ${JSON.stringify(truncatedKeys)}`);
189
+ }
190
+
191
+ const result = `{\n${allEntries.join(",\n")}\n}`;
192
+ if (result.length <= charBudget) return result;
193
+
194
+ // Final fallback: keep scalars + summaries, trim from the complex entries end
195
+ return result.slice(0, charBudget - 20) + "\n[...truncated]";
196
+ }
197
+
198
+ function isScalar(val: unknown) {
199
+ return (
200
+ val === null ||
201
+ typeof val === "string" ||
202
+ typeof val === "number" ||
203
+ typeof val === "boolean"
204
+ );
205
+ }
206
+
207
+ function isErrorLike(value: Record<string, unknown>) {
208
+ return "error" in value || "message" in value || "stack" in value || "stackTrace" in value;
209
+ }
210
+
211
+ function compressErrorObject(value: Record<string, unknown>, charBudget: number) {
212
+ // Keep error/message fields, drop stack traces
213
+ const { stack: _stack, stackTrace: _stackTrace, ...rest } = value;
214
+ const compressed = JSON.stringify(rest, null, 2);
215
+
216
+ if (compressed.length <= charBudget) return compressed;
217
+ return compressed.slice(0, charBudget - 20) + "\n[...truncated]";
218
+ }
219
+
220
+ /**
221
+ * Produces a short human-readable summary of a value's shape and key data.
222
+ * Used both for inline object-key placeholders and for tool result one-liners.
223
+ */
224
+ /** The first element stands in for the rest, which is the point of a summary. */
225
+ function summarizeArray(val: unknown[]) {
226
+ const first = val[0];
227
+ const schemaHint =
228
+ typeof first === "object" && first !== null
229
+ ? ` {${Object.keys(first).slice(0, 5).join(", ")}}`
230
+ : "";
231
+
232
+ return `[Array(${val.length})${schemaHint}]`;
233
+ }
234
+
235
+ /** A status/success envelope says more as its status plus a count than as its keys. */
236
+ function summarizeStatusEnvelope(obj: Record<string, unknown>, keys: string[]) {
237
+ const status = obj["status"] ?? obj["success"];
238
+ const dataKey = keys.find((k) => k === "data" || k === "result" || k === "results");
239
+ const data = dataKey ? obj[dataKey] : null;
240
+ const count = Array.isArray(data) ? `, ${data.length} items` : "";
241
+
242
+ return `{${String(status)}${count}}`;
243
+ }
244
+
245
+ function summarizeObject(obj: Record<string, unknown>) {
246
+ const keys = Object.keys(obj);
247
+
248
+ if ("status" in obj || "success" in obj) {
249
+ return summarizeStatusEnvelope(obj, keys);
250
+ }
251
+
252
+ const shown = keys.slice(0, 5).join(", ");
253
+ const ellipsis = keys.length > 5 ? ", ..." : "";
254
+ return `{Object(${keys.length} keys: ${shown}${ellipsis})}`;
255
+ }
256
+
257
+ export function summarizeValue(val: unknown) {
258
+ if (Array.isArray(val)) return summarizeArray(val);
259
+ if (typeof val === "object" && val !== null) {
260
+ return summarizeObject(val as Record<string, unknown>);
261
+ }
262
+ if (typeof val === "string") {
263
+ return val.length > 80 ? `"${val.slice(0, 77)}..."` : JSON.stringify(val);
264
+ }
265
+
266
+ return String(val);
267
+ }
@@ -0,0 +1,181 @@
1
+ import { chat } from "@tanstack/ai";
2
+ import type { ModelMessage } from "@tanstack/ai";
3
+ import { createModel } from "@/ai/helpers";
4
+ import { estimateTokens } from "./token-estimator";
5
+ import { summarizeValue } from "./compressor";
6
+ import type { ContextConfig } from "./types";
7
+
8
+ type SummarizationModelConfig = NonNullable<ContextConfig["summarizationModel"]>;
9
+
10
+ type ToolCallOrigin = { name: string; step: number };
11
+
12
+ /**
13
+ * A tool message names only the call it answers, so both the tool's own name and the
14
+ * step it belongs to have to be recovered from the assistant message that asked for
15
+ * it. One tool message carries one result, so a step that issued three calls in
16
+ * parallel arrives as three messages; regrouping them into their step is what stops
17
+ * a step's own results from ageing each other out.
18
+ */
19
+ function toolStepsOf(messages: ModelMessage[]) {
20
+ const calls = new Map<string, ToolCallOrigin>();
21
+ const steps: number[][] = [];
22
+
23
+ for (const [index, message] of messages.entries()) {
24
+ for (const call of message.toolCalls ?? []) {
25
+ calls.set(call.id, { name: call.function.name, step: steps.length });
26
+ }
27
+ if (message.role !== "tool") continue;
28
+
29
+ // A result whose call was never announced belongs to the step in progress;
30
+ // filing it anywhere else would invent a step that issued nothing.
31
+ const step = calls.get(message.toolCallId ?? "")?.step ?? steps.length;
32
+ (steps[step] ??= []).push(index);
33
+ }
34
+
35
+ return { calls, steps };
36
+ }
37
+
38
+ function toolNameOf(message: ModelMessage, calls: Map<string, ToolCallOrigin>) {
39
+ return calls.get(message.toolCallId ?? "")?.name ?? "tool";
40
+ }
41
+
42
+ /**
43
+ * A message's content is either a plain string or content parts, of which only the
44
+ * text ones can be digested — which is all a one-line stand-in was going to keep.
45
+ */
46
+ function contentText(content: ModelMessage["content"]) {
47
+ if (typeof content === "string") return content;
48
+ if (content === null) return "";
49
+
50
+ return content.flatMap((part) => (part.type === "text" ? [part.content] : [])).join(" ");
51
+ }
52
+
53
+ function extractPreview(text: string) {
54
+ // Try to parse as JSON for structured summary
55
+ try {
56
+ const parsed: unknown = JSON.parse(text);
57
+ if (typeof parsed === "object" && parsed !== null) {
58
+ return summarizeValue(parsed);
59
+ }
60
+ } catch {
61
+ // Not JSON
62
+ }
63
+
64
+ if (text.length <= 150) return text;
65
+ return text.slice(0, 147) + "...";
66
+ }
67
+
68
+ /**
69
+ * Compresses tool results from older steps in the prompt.
70
+ *
71
+ * On step N, tool results from steps 1..N-2 are aggressively compressed
72
+ * to short one-liners. Only the most recent step's results stay in full.
73
+ * Does not mutate the input — returns a new array.
74
+ */
75
+ export function compressIntraTurnToolResults(
76
+ messages: ModelMessage[],
77
+ toolResultMaxTokens: number,
78
+ ) {
79
+ const { calls, steps } = toolStepsOf(messages);
80
+
81
+ // Below three there is nothing genuinely "old" to compress once the most
82
+ // recent step is kept intact.
83
+ if (steps.length < 3) return messages;
84
+
85
+ const result = [...messages];
86
+
87
+ for (const index of steps.slice(0, -1).flat()) {
88
+ const message = messages[index]!;
89
+ const text = contentText(message.content);
90
+ if (estimateTokens(text) <= toolResultMaxTokens) continue;
91
+
92
+ const tag = `[COMPRESSED prior ${toolNameOf(message, calls)} result]`;
93
+ result[index] = { ...message, content: `${tag} ${extractPreview(text)}` };
94
+ }
95
+
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Estimates the total token count of tool results in the prompt.
101
+ */
102
+ export function estimateToolResultTokens(messages: ModelMessage[]) {
103
+ let total = 0;
104
+ for (const message of messages) {
105
+ if (message.role === "tool") total += estimateTokens(contentText(message.content));
106
+ }
107
+
108
+ return total;
109
+ }
110
+
111
+ const SUMMARIZER_SYSTEM_PROMPT = `You are a tool result summarizer. Given previous tool call results from an AI agent's workflow, produce a concise summary preserving:
112
+ - Key data values, IDs, and counts
113
+ - Success/failure status of each operation
114
+ - Any error messages
115
+ - Data that subsequent tool calls may need
116
+
117
+ Maximum 300 tokens. Use bullet points. No preamble.`;
118
+
119
+ function requestSummary(resultTexts: string[], modelConfig: SummarizationModelConfig) {
120
+ return chat({
121
+ adapter: createModel(modelConfig, { name: "summarization" }),
122
+ systemPrompts: [SUMMARIZER_SYSTEM_PROMPT],
123
+ messages: [
124
+ {
125
+ role: "user",
126
+ content: `Summarize these tool results:\n\n${resultTexts.join("\n\n")}`,
127
+ },
128
+ ],
129
+ stream: false,
130
+ });
131
+ }
132
+
133
+ /**
134
+ * When accumulated tool result tokens exceed the threshold, summarizes
135
+ * older step results using a lightweight LLM call.
136
+ *
137
+ * Replaces every tool result before the last two steps with a single
138
+ * synthetic summary in the oldest tool message.
139
+ */
140
+ export async function summarizeOldStepResults(
141
+ messages: ModelMessage[],
142
+ thresholdTokens: number,
143
+ modelConfig: SummarizationModelConfig,
144
+ ) {
145
+ if (estimateToolResultTokens(messages) <= thresholdTokens) return messages;
146
+
147
+ const { calls, steps } = toolStepsOf(messages);
148
+
149
+ // Summarization keeps the last two steps intact — one more than compression
150
+ // does, because a summary loses more detail than a truncated one-liner.
151
+ if (steps.length < 3) return messages;
152
+
153
+ // An emptied tool message has nothing to contribute to the digest, and nothing
154
+ // to gain from being pointed at one.
155
+ const indices = steps
156
+ .slice(0, -2)
157
+ .flat()
158
+ .filter((index) => contentText(messages[index]!.content) !== "");
159
+ if (indices.length === 0) return messages;
160
+
161
+ const resultTexts = indices.map((index) => {
162
+ const message = messages[index]!;
163
+ return `[${toolNameOf(message, calls)}]: ${contentText(message.content)}`;
164
+ });
165
+
166
+ const summary = await requestSummary(resultTexts, modelConfig);
167
+
168
+ // The first old tool message carries the summary and the rest point at it, so
169
+ // the model reads the digest once rather than once per collapsed message.
170
+ const result = [...messages];
171
+ indices.forEach((index, position) => {
172
+ const content =
173
+ position === 0
174
+ ? `[Summary of ${indices.length} earlier tool results]:\n${summary}`
175
+ : "[see summary above]";
176
+
177
+ result[index] = { ...messages[index]!, content };
178
+ });
179
+
180
+ return result;
181
+ }
@@ -0,0 +1,50 @@
1
+ import type { ResolvedCortexAgentConfig } from "@/config";
2
+ import type { ChatMessage, Thread } from "@/types";
3
+ import type { ThreadContextMeta } from "./types";
4
+ import { estimateMessagesTokens } from "./token-estimator";
5
+ import { summarizeMessages } from "./summarizer";
6
+
7
+ /**
8
+ * Post-response context optimization, called fire-and-forget from onFinish.
9
+ *
10
+ * Below the summarization threshold this only refreshes the token estimate —
11
+ * summarizing early would burn a model call to compress context that still fits.
12
+ */
13
+ export async function optimizeThreadContext(
14
+ thread: Thread,
15
+ messages: ChatMessage[],
16
+ config: ResolvedCortexAgentConfig,
17
+ ) {
18
+ const contextConfig = config.context;
19
+ const estimates = estimateMessagesTokens(messages);
20
+ const totalEstimatedTokens = estimates.reduce((sum, e) => sum + e.tokens, 0);
21
+ const threshold = contextConfig.maxContextTokens * contextConfig.summarizationThreshold;
22
+
23
+ if (totalEstimatedTokens <= threshold) {
24
+ await config.db.threads.updateContextMeta(thread.id, {
25
+ summary: thread.contextMeta?.summary ?? null,
26
+ summaryUpToMessageId: thread.contextMeta?.summaryUpToMessageId ?? null,
27
+ totalEstimatedTokens,
28
+ lastOptimizedAt: new Date().toISOString(),
29
+ } satisfies ThreadContextMeta);
30
+ return;
31
+ }
32
+
33
+ // The most recent messages stay unsummarized — they're the hot context.
34
+ const recentCount = Math.min(contextConfig.recentMessagesToKeep, messages.length);
35
+ const messagesToSummarize = messages.slice(0, messages.length - recentCount);
36
+ if (messagesToSummarize.length === 0) return;
37
+
38
+ const summary = await summarizeMessages(
39
+ messagesToSummarize,
40
+ thread.contextMeta?.summary ?? null,
41
+ contextConfig.summarizationModel ?? config.model,
42
+ );
43
+
44
+ await config.db.threads.updateContextMeta(thread.id, {
45
+ summary,
46
+ summaryUpToMessageId: messagesToSummarize.at(-1)?.id ?? null,
47
+ totalEstimatedTokens,
48
+ lastOptimizedAt: new Date().toISOString(),
49
+ } satisfies ThreadContextMeta);
50
+ }
@@ -0,0 +1,48 @@
1
+ import { chat } from "@tanstack/ai";
2
+ import { createModel } from "@/ai/helpers";
3
+ import type { ChatMessage } from "@/types";
4
+ import type { ContextConfig } from "./types";
5
+
6
+ type SummarizationModelConfig = NonNullable<ContextConfig["summarizationModel"]>;
7
+
8
+ const SYSTEM_PROMPT = `You are a precise conversation summarizer. Produce a concise summary that preserves:
9
+ - Key decisions and conclusions
10
+ - Important entities (names, IDs, URLs, values)
11
+ - User intent and goals
12
+ - Any unresolved questions or next steps
13
+
14
+ Maximum 500 tokens. Use bullet points. Do not include preamble.`;
15
+
16
+ export function summarizeMessages(
17
+ messages: ChatMessage[],
18
+ existingSummary: string | null,
19
+ modelConfig: SummarizationModelConfig,
20
+ ) {
21
+ const conversationText = messages
22
+ .map(function (msg) {
23
+ const text = msg.parts
24
+ .flatMap((part) => (part.type === "text" ? [part.content] : []))
25
+ .join(" ");
26
+ return `[${msg.role}]: ${text}`;
27
+ })
28
+ .join("\n");
29
+
30
+ const summaryContext = existingSummary
31
+ ? `\nPrior summary of earlier messages:\n${existingSummary}\n`
32
+ : "";
33
+
34
+ return chat({
35
+ adapter: createModel(modelConfig, { name: "summarization" }),
36
+ systemPrompts: [SYSTEM_PROMPT],
37
+ messages: [
38
+ {
39
+ role: "user",
40
+ content: `${summaryContext}
41
+ Summarize the following conversation:
42
+
43
+ ${conversationText}`,
44
+ },
45
+ ],
46
+ stream: false,
47
+ });
48
+ }
@@ -0,0 +1,65 @@
1
+ import type { MessagePart } from "@tanstack/ai";
2
+ import type { ChatMessage } from "@/types";
3
+
4
+ /** Average characters per token for English text. Used by the heuristic estimator. */
5
+ export const CHARS_PER_TOKEN = 4;
6
+
7
+ /**
8
+ * Estimates token count for a string using the chars/4 heuristic.
9
+ * ~10% accuracy for English text — good enough for budget decisions.
10
+ */
11
+ export function estimateTokens(text: string) {
12
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
13
+ }
14
+
15
+ /**
16
+ * Media and UI parts carry a source, not text. What the provider actually bills
17
+ * for them is set by its own tokenizer — tiles for an image, pages for a document —
18
+ * and the length of a base64 payload says nothing useful about it, so they are
19
+ * charged a flat rate instead.
20
+ */
21
+ const OPAQUE_PART_TOKENS = 5;
22
+
23
+ function estimatePartTokens(part: MessagePart) {
24
+ switch (part.type) {
25
+ case "text":
26
+ case "thinking":
27
+ return estimateTokens(part.content);
28
+ case "tool-call": {
29
+ // `arguments` is already the JSON the model emitted. `output` is only
30
+ // set for client-executed tools and for calls resolved after approval.
31
+ const output: unknown = part.output;
32
+ const outputTokens = output === undefined ? 0 : estimateTokens(JSON.stringify(output));
33
+
34
+ return estimateTokens(part.arguments) + outputTokens;
35
+ }
36
+ case "tool-result":
37
+ return estimateTokens(
38
+ typeof part.content === "string" ? part.content : JSON.stringify(part.content),
39
+ );
40
+ case "structured-output":
41
+ return estimateTokens(part.raw);
42
+ default:
43
+ return OPAQUE_PART_TOKENS;
44
+ }
45
+ }
46
+
47
+ export function estimateMessageTokens(message: ChatMessage) {
48
+ const parts = message.parts.reduce((sum, part) => sum + estimatePartTokens(part), 0);
49
+
50
+ // Per-message overhead (role, metadata framing)
51
+ return parts + 4;
52
+ }
53
+
54
+ /**
55
+ * Estimates token counts for an array of messages.
56
+ * Returns per-message estimates in the same order.
57
+ */
58
+ export function estimateMessagesTokens(messages: ChatMessage[]) {
59
+ return messages.map(function (message) {
60
+ return {
61
+ message,
62
+ tokens: estimateMessageTokens(message),
63
+ };
64
+ });
65
+ }
@@ -10,6 +10,8 @@ export type ContextConfig = {
10
10
  };
11
11
  toolResultMaxTokens: number;
12
12
  recentMessagesToKeep: number;
13
+ /** Token threshold for triggering intra-turn summarization of older tool results */
14
+ intraTurnSummarizationThresholdTokens: number;
13
15
  };
14
16
 
15
17
  export type ThreadContextMeta = {
@@ -23,6 +25,7 @@ export const DEFAULT_CONTEXT_CONFIG: ContextConfig = {
23
25
  maxContextTokens: 120_000,
24
26
  reservedTokenBudget: 8_000,
25
27
  summarizationThreshold: 0.75,
26
- toolResultMaxTokens: 2_000,
28
+ toolResultMaxTokens: 1_000,
27
29
  recentMessagesToKeep: 6,
30
+ intraTurnSummarizationThresholdTokens: 20_000,
28
31
  };
@@ -1,4 +1,5 @@
1
- import type { ResolvedCortexAgentConfig } from "../config.ts";
1
+ import type { ResolvedCortexAgentConfig } from "@/config";
2
+ import { resolveBackendUrl } from "./backend-url";
2
3
 
3
4
  export async function fetchBackend(
4
5
  path: string,
@@ -14,18 +15,22 @@ export async function fetchBackend(
14
15
  finalOptions?.body &&
15
16
  typeof finalOptions.body === "string"
16
17
  ) {
18
+ let parsed: Record<string, unknown> | undefined;
17
19
  try {
18
- const parsed = JSON.parse(finalOptions.body) as Record<string, unknown>;
20
+ parsed = JSON.parse(finalOptions.body) as Record<string, unknown>;
21
+ } catch {
22
+ // Non-JSON bodies pass through unchanged.
23
+ }
24
+
25
+ if (parsed !== undefined) {
19
26
  const transformed = await backendFetch.transformRequestBody(parsed, {
20
27
  token,
21
28
  });
22
29
  finalOptions = { ...finalOptions, body: JSON.stringify(transformed) };
23
- } catch {
24
- // If body isn't valid JSON, pass through as-is
25
30
  }
26
31
  }
27
32
 
28
- return fetch(`${backendFetch.baseUrl}${path}`, {
33
+ return fetch(resolveBackendUrl(backendFetch.baseUrl, path), {
29
34
  ...finalOptions,
30
35
  headers: {
31
36
  "Content-Type": "application/json",