@oneuptime/common 11.5.8 → 11.5.9

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 (195) hide show
  1. package/Models/DatabaseModels/AIAgentTaskPullRequest.ts +15 -6
  2. package/Models/DatabaseModels/AIRun.ts +37 -0
  3. package/Models/DatabaseModels/AIRunEvent.ts +34 -1
  4. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +215 -0
  5. package/Models/DatabaseModels/Project.ts +25 -0
  6. package/Models/DatabaseModels/StatusPage.ts +42 -0
  7. package/Server/API/AIAgentTaskAPI.ts +38 -12
  8. package/Server/API/AIAgentTaskLogAPI.ts +49 -0
  9. package/Server/API/AIChatAPI.ts +16 -0
  10. package/Server/API/AIReadinessAPI.ts +84 -0
  11. package/Server/API/CodeFixRunAPI.ts +182 -0
  12. package/Server/API/TelemetryExceptionAPI.ts +6 -1
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1784124919694-AddAITaskNumberAndRunTranscript.ts +95 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1784135099754-AllowNullAiAgentOnPullRequest.ts +52 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1784137457184-AddEnableMcpServerToStatusPage.ts +30 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1784200000000-AddSnmpV3ColumnsToNetworkDeviceDiscoveryScan.ts +49 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  18. package/Server/Services/AIRunEventService.ts +15 -1
  19. package/Server/Services/AIRunService.ts +38 -1
  20. package/Server/Services/LlmProviderService.ts +2 -0
  21. package/Server/Services/ProjectService.ts +42 -0
  22. package/Server/Services/StatusPageService.ts +87 -0
  23. package/Server/Services/TelemetryExceptionService.ts +19 -90
  24. package/Server/Types/Markdown.ts +9 -9
  25. package/Server/Types/MarkdownSlugify.ts +47 -0
  26. package/Server/Utils/AI/AIRunTranscript.ts +240 -0
  27. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +9 -0
  28. package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +119 -5
  29. package/Server/Utils/AI/CodeFix/CodeFixAgentCompletion.ts +75 -0
  30. package/Server/Utils/AI/CodeFix/CodeFixReadiness.ts +235 -0
  31. package/Server/Utils/AI/SRE/Insights/FixRouting.ts +2 -3
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +883 -0
  33. package/Server/Utils/AI/Toolbox/CodeWriteTools.ts +519 -0
  34. package/Server/Utils/AI/Toolbox/Index.ts +27 -0
  35. package/Server/Utils/AI/Toolbox/ScheduledMaintenanceTools.ts +279 -0
  36. package/Server/Utils/AnalyticsDatabase/ClickhouseCapacity.ts +19 -1
  37. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +600 -0
  38. package/Server/Utils/CodeRepository/StackTraceRepoResolver.ts +7 -2
  39. package/Server/Utils/LLM/LLMService.ts +397 -18
  40. package/Server/Utils/Monitor/NetworkDeviceHydrationUtil.ts +4 -11
  41. package/Tests/Models/StatusPageEnableMcpServer.test.ts +125 -0
  42. package/Tests/Server/Services/StatusPageServiceMcp.test.ts +223 -0
  43. package/Tests/Server/Services/TelemetryExceptionAIFixReadiness.test.ts +26 -3
  44. package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +12 -0
  45. package/Tests/Server/Types/Markdown.test.ts +106 -0
  46. package/Tests/Server/Utils/AI/AIRunTranscript.test.ts +182 -0
  47. package/Tests/Server/Utils/AI/CodeFixReadiness.test.ts +411 -0
  48. package/Tests/Server/Utils/AI/CodeTools.test.ts +596 -0
  49. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +475 -0
  50. package/Tests/Server/Utils/AI/LLMServiceModelCompatibility.test.ts +548 -0
  51. package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +142 -0
  52. package/Tests/Server/Utils/AI/ScheduledMaintenanceTools.test.ts +176 -0
  53. package/Tests/Server/Utils/AnalyticsDatabase/ClickhouseCapacity.test.ts +63 -0
  54. package/Tests/Server/Utils/GitHubGetFileContent.test.ts +197 -0
  55. package/Tests/Server/Utils/Monitor/NetworkDeviceHydrationUtil.test.ts +502 -0
  56. package/Tests/Types/AI/AIAgentTaskStatus.test.ts +7 -6
  57. package/Tests/Types/AI/AIChatPageContext.test.ts +0 -0
  58. package/Tests/Types/AI/AIRunStatus.test.ts +42 -0
  59. package/Tests/Types/Log/LogSeverity.test.ts +78 -0
  60. package/Tests/UI/Components/BasicFormDropdownNormalization.test.tsx +167 -0
  61. package/Tests/UI/Components/Charts/AxisTickColor.test.tsx +66 -0
  62. package/Tests/UI/Utils/AIChatExport/ChatWidgetData.test.ts +319 -0
  63. package/Tests/UI/Utils/AIChatExport/ChatWidgetMarkdown.test.ts +188 -0
  64. package/Types/AI/AIAgentTaskStatus.ts +13 -0
  65. package/Types/AI/AIChatPageContext.ts +137 -0
  66. package/Types/AI/AIChatTypes.ts +44 -0
  67. package/Types/AI/AIFixReadiness.ts +46 -0
  68. package/Types/AI/AIRunStatus.ts +11 -0
  69. package/Types/Log/LogSeverity.ts +61 -0
  70. package/Types/Monitor/SnmpMonitor/SnmpVersion.ts +40 -0
  71. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +8 -2
  72. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +3 -1
  73. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +8 -2
  74. package/UI/Components/Forms/BasicForm.tsx +26 -8
  75. package/UI/Styles/Theme.css +1314 -0
  76. package/UI/Utils/AIChatExport/ChatWidgetData.ts +316 -0
  77. package/UI/Utils/AIChatExport/ChatWidgetMarkdown.ts +349 -0
  78. package/UI/Utils/AIChatExport/ConversationMarkdown.ts +177 -0
  79. package/UI/Utils/AIChatExport/MarkdownBlocks.ts +327 -0
  80. package/UI/Utils/AIChatExport/MarkdownSafety.ts +215 -0
  81. package/UI/Utils/DownloadFile.ts +54 -0
  82. package/build/dist/Models/DatabaseModels/AIAgentTaskPullRequest.js +15 -7
  83. package/build/dist/Models/DatabaseModels/AIAgentTaskPullRequest.js.map +1 -1
  84. package/build/dist/Models/DatabaseModels/AIRun.js +38 -0
  85. package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/AIRunEvent.js +31 -0
  87. package/build/dist/Models/DatabaseModels/AIRunEvent.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +225 -0
  89. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/Project.js +27 -0
  91. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/StatusPage.js +43 -0
  93. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  94. package/build/dist/Server/API/AIAgentTaskAPI.js +37 -11
  95. package/build/dist/Server/API/AIAgentTaskAPI.js.map +1 -1
  96. package/build/dist/Server/API/AIAgentTaskLogAPI.js +32 -7
  97. package/build/dist/Server/API/AIAgentTaskLogAPI.js.map +1 -1
  98. package/build/dist/Server/API/AIChatAPI.js +9 -0
  99. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  100. package/build/dist/Server/API/AIReadinessAPI.js +57 -0
  101. package/build/dist/Server/API/AIReadinessAPI.js.map +1 -0
  102. package/build/dist/Server/API/CodeFixRunAPI.js +142 -0
  103. package/build/dist/Server/API/CodeFixRunAPI.js.map +1 -1
  104. package/build/dist/Server/API/TelemetryExceptionAPI.js +4 -0
  105. package/build/dist/Server/API/TelemetryExceptionAPI.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784124919694-AddAITaskNumberAndRunTranscript.js +80 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784124919694-AddAITaskNumberAndRunTranscript.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784135099754-AllowNullAiAgentOnPullRequest.js +37 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784135099754-AllowNullAiAgentOnPullRequest.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784137457184-AddEnableMcpServerToStatusPage.js +23 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784137457184-AddEnableMcpServerToStatusPage.js.map +1 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784200000000-AddSnmpV3ColumnsToNetworkDeviceDiscoveryScan.js +22 -0
  113. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784200000000-AddSnmpV3ColumnsToNetworkDeviceDiscoveryScan.js.map +1 -0
  114. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  116. package/build/dist/Server/Services/AIRunEventService.js +8 -0
  117. package/build/dist/Server/Services/AIRunEventService.js.map +1 -1
  118. package/build/dist/Server/Services/AIRunService.js +29 -0
  119. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  120. package/build/dist/Server/Services/LlmProviderService.js +2 -0
  121. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  122. package/build/dist/Server/Services/ProjectService.js +39 -0
  123. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  124. package/build/dist/Server/Services/StatusPageService.js +81 -0
  125. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  126. package/build/dist/Server/Services/TelemetryExceptionService.js +14 -61
  127. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  128. package/build/dist/Server/Types/Markdown.js +8 -8
  129. package/build/dist/Server/Types/Markdown.js.map +1 -1
  130. package/build/dist/Server/Types/MarkdownSlugify.js +47 -0
  131. package/build/dist/Server/Types/MarkdownSlugify.js.map +1 -0
  132. package/build/dist/Server/Utils/AI/AIRunTranscript.js +180 -0
  133. package/build/dist/Server/Utils/AI/AIRunTranscript.js.map +1 -0
  134. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +1 -0
  135. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  136. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +103 -5
  137. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
  138. package/build/dist/Server/Utils/AI/CodeFix/CodeFixAgentCompletion.js +55 -0
  139. package/build/dist/Server/Utils/AI/CodeFix/CodeFixAgentCompletion.js.map +1 -1
  140. package/build/dist/Server/Utils/AI/CodeFix/CodeFixReadiness.js +211 -0
  141. package/build/dist/Server/Utils/AI/CodeFix/CodeFixReadiness.js.map +1 -0
  142. package/build/dist/Server/Utils/AI/SRE/Insights/FixRouting.js.map +1 -1
  143. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +654 -0
  144. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -0
  145. package/build/dist/Server/Utils/AI/Toolbox/CodeWriteTools.js +389 -0
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeWriteTools.js.map +1 -0
  147. package/build/dist/Server/Utils/AI/Toolbox/Index.js +19 -0
  148. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  149. package/build/dist/Server/Utils/AI/Toolbox/ScheduledMaintenanceTools.js +239 -0
  150. package/build/dist/Server/Utils/AI/Toolbox/ScheduledMaintenanceTools.js.map +1 -0
  151. package/build/dist/Server/Utils/AnalyticsDatabase/ClickhouseCapacity.js +19 -1
  152. package/build/dist/Server/Utils/AnalyticsDatabase/ClickhouseCapacity.js.map +1 -1
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +439 -0
  154. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  155. package/build/dist/Server/Utils/CodeRepository/StackTraceRepoResolver.js +1 -1
  156. package/build/dist/Server/Utils/CodeRepository/StackTraceRepoResolver.js.map +1 -1
  157. package/build/dist/Server/Utils/LLM/LLMService.js +275 -19
  158. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  159. package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js +2 -11
  160. package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js.map +1 -1
  161. package/build/dist/Types/AI/AIAgentTaskStatus.js +12 -0
  162. package/build/dist/Types/AI/AIAgentTaskStatus.js.map +1 -1
  163. package/build/dist/Types/AI/AIChatPageContext.js +101 -0
  164. package/build/dist/Types/AI/AIChatPageContext.js.map +1 -0
  165. package/build/dist/Types/AI/AIChatTypes.js +2 -0
  166. package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
  167. package/build/dist/Types/AI/AIFixReadiness.js +9 -0
  168. package/build/dist/Types/AI/AIFixReadiness.js.map +1 -0
  169. package/build/dist/Types/AI/AIRunStatus.js +11 -0
  170. package/build/dist/Types/AI/AIRunStatus.js.map +1 -1
  171. package/build/dist/Types/Log/LogSeverity.js +56 -0
  172. package/build/dist/Types/Log/LogSeverity.js.map +1 -1
  173. package/build/dist/Types/Monitor/SnmpMonitor/SnmpVersion.js +38 -0
  174. package/build/dist/Types/Monitor/SnmpMonitor/SnmpVersion.js.map +1 -1
  175. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +9 -2
  176. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  177. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +3 -1
  178. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  179. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +9 -2
  180. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  181. package/build/dist/UI/Components/Forms/BasicForm.js +24 -6
  182. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  183. package/build/dist/UI/Utils/AIChatExport/ChatWidgetData.js +217 -0
  184. package/build/dist/UI/Utils/AIChatExport/ChatWidgetData.js.map +1 -0
  185. package/build/dist/UI/Utils/AIChatExport/ChatWidgetMarkdown.js +234 -0
  186. package/build/dist/UI/Utils/AIChatExport/ChatWidgetMarkdown.js.map +1 -0
  187. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +111 -0
  188. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -0
  189. package/build/dist/UI/Utils/AIChatExport/MarkdownBlocks.js +233 -0
  190. package/build/dist/UI/Utils/AIChatExport/MarkdownBlocks.js.map +1 -0
  191. package/build/dist/UI/Utils/AIChatExport/MarkdownSafety.js +134 -0
  192. package/build/dist/UI/Utils/AIChatExport/MarkdownSafety.js.map +1 -0
  193. package/build/dist/UI/Utils/DownloadFile.js +41 -0
  194. package/build/dist/UI/Utils/DownloadFile.js.map +1 -0
  195. package/package.json +5 -1
@@ -0,0 +1,177 @@
1
+ import AIConversationMessage from "../../../Models/DatabaseModels/AIConversationMessage";
2
+ import AIRun from "../../../Models/DatabaseModels/AIRun";
3
+ import AIChatMessageRole from "../../../Types/AI/AIChatMessageRole";
4
+ import AIChatMessageStatus from "../../../Types/AI/AIChatMessageStatus";
5
+ import {
6
+ AIChatCitation,
7
+ AIChatToolAction,
8
+ AIChatWidget,
9
+ } from "../../../Types/AI/AIChatTypes";
10
+ import OneUptimeDate from "../../../Types/Date";
11
+ import neutralizeAssistantMarkdown from "./MarkdownSafety";
12
+ import {
13
+ citationsToMarkdown,
14
+ toolActionsToMarkdown,
15
+ widgetsToMarkdown,
16
+ } from "./ChatWidgetMarkdown";
17
+
18
+ /*
19
+ * Renders a whole AI chat conversation as a markdown document.
20
+ *
21
+ * The shape follows the chat itself: alternating turns, each assistant turn
22
+ * carrying the widgets, actions and citations that were shown beneath it. What
23
+ * the reader gets is the transcript plus the evidence behind it, in a file that
24
+ * reads fine in a plain text editor.
25
+ */
26
+
27
+ export interface ConversationMarkdownOptions {
28
+ title: string;
29
+ messages: Array<AIConversationMessage>;
30
+ latestRun?: AIRun | undefined;
31
+ exportedAt: Date;
32
+ }
33
+
34
+ // Mirrors the cost footer the chat shows under the newest completed answer.
35
+ function runFooter(run: AIRun): string {
36
+ const parts: Array<string> = [];
37
+
38
+ if (run.egressManifest?.modelName) {
39
+ parts.push(run.egressManifest.modelName);
40
+ }
41
+ if (run.totalTokens) {
42
+ parts.push(`${run.totalTokens.toLocaleString()} tokens`);
43
+ }
44
+ if (run.totalCostInUSDCents) {
45
+ parts.push(`$${(run.totalCostInUSDCents / 100).toFixed(4)}`);
46
+ }
47
+ const toolCallCount: number = run.toolCallCount || 0;
48
+ parts.push(`${toolCallCount} ${toolCallCount === 1 ? "query" : "queries"}`);
49
+
50
+ return parts.join(" · ");
51
+ }
52
+
53
+ function messageToMarkdown(
54
+ message: AIConversationMessage,
55
+ isLastCompletedAssistant: boolean,
56
+ latestRun: AIRun | undefined,
57
+ ): Array<string> {
58
+ const lines: Array<string> = [];
59
+ const timestamp: string = message.createdAt
60
+ ? OneUptimeDate.getDateAsLocalFormattedString(message.createdAt)
61
+ : "";
62
+
63
+ if (message.role === AIChatMessageRole.User) {
64
+ lines.push("## You", "");
65
+ /*
66
+ * The user's own words, shown literally on screen. No neutralization: this
67
+ * is the operator's own input, not model output derived from telemetry.
68
+ */
69
+ if (message.contentInMarkdown) {
70
+ lines.push(message.contentInMarkdown.trim(), "");
71
+ }
72
+ if (timestamp) {
73
+ lines.push(`_${timestamp}_`, "");
74
+ }
75
+ return lines;
76
+ }
77
+
78
+ lines.push("## AI", "");
79
+
80
+ if (message.status === AIChatMessageStatus.Error) {
81
+ lines.push(
82
+ `> **Error:** ${
83
+ message.errorMessage || "Something went wrong generating this response."
84
+ }`,
85
+ "",
86
+ );
87
+ return lines;
88
+ }
89
+
90
+ if (message.contentInMarkdown) {
91
+ lines.push(neutralizeAssistantMarkdown(message.contentInMarkdown), "");
92
+ }
93
+
94
+ const widgets: Array<AIChatWidget> = message.widgets || [];
95
+ if (widgets.length > 0) {
96
+ lines.push(widgetsToMarkdown(widgets), "");
97
+ }
98
+
99
+ const toolActions: Array<AIChatToolAction> = message.toolActions || [];
100
+ if (toolActions.length > 0) {
101
+ lines.push(toolActionsToMarkdown(toolActions), "");
102
+ }
103
+
104
+ const citations: Array<AIChatCitation> = message.citations || [];
105
+ if (citations.length > 0) {
106
+ lines.push(citationsToMarkdown(citations), "");
107
+ }
108
+
109
+ const footerParts: Array<string> = [];
110
+ if (timestamp) {
111
+ footerParts.push(timestamp);
112
+ }
113
+ if (isLastCompletedAssistant && latestRun) {
114
+ footerParts.push(runFooter(latestRun));
115
+ }
116
+ if (footerParts.length > 0) {
117
+ lines.push(`_${footerParts.join(" · ")}_`, "");
118
+ }
119
+
120
+ return lines;
121
+ }
122
+
123
+ export default function convertConversationToMarkdown(
124
+ options: ConversationMarkdownOptions,
125
+ ): string {
126
+ const lines: Array<string> = [
127
+ `# ${options.title || "AI conversation"}`,
128
+ "",
129
+ `_Exported from OneUptime on ${OneUptimeDate.getDateAsLocalFormattedString(
130
+ options.exportedAt,
131
+ )}_`,
132
+ "",
133
+ "---",
134
+ "",
135
+ ];
136
+
137
+ /*
138
+ * The run footer belongs to the newest completed answer, matching where the
139
+ * chat shows it.
140
+ */
141
+ const lastCompletedAssistantId: string | undefined = [...options.messages]
142
+ .reverse()
143
+ .find((message: AIConversationMessage) => {
144
+ return (
145
+ message.role === AIChatMessageRole.Assistant &&
146
+ message.status === AIChatMessageStatus.Completed
147
+ );
148
+ })
149
+ ?.id?.toString();
150
+
151
+ for (const message of options.messages) {
152
+ /*
153
+ * An answer still being generated has nothing to export yet; the chat
154
+ * represents it with a live activity feed rather than content.
155
+ */
156
+ if (
157
+ message.role === AIChatMessageRole.Assistant &&
158
+ (message.status === AIChatMessageStatus.InProgress ||
159
+ message.status === AIChatMessageStatus.Pending) &&
160
+ !message.contentInMarkdown &&
161
+ (message.widgets || []).length === 0 &&
162
+ (message.toolActions || []).length === 0
163
+ ) {
164
+ continue;
165
+ }
166
+
167
+ lines.push(
168
+ ...messageToMarkdown(
169
+ message,
170
+ message.id?.toString() === lastCompletedAssistantId,
171
+ options.latestRun,
172
+ ),
173
+ );
174
+ }
175
+
176
+ return `${lines.join("\n").trim()}\n`;
177
+ }
@@ -0,0 +1,327 @@
1
+ import remarkGfm from "remark-gfm";
2
+ import remarkParse from "remark-parse";
3
+ import { unified } from "unified";
4
+
5
+ /*
6
+ * Flattens markdown into a small list of drawable blocks.
7
+ *
8
+ * The PDF exporter cannot use the chat's React markdown renderer, but it should
9
+ * not hand-roll a markdown parser either — that is how exports end up rendering
10
+ * a code fence as bold text. So the same parser the chat uses (remark + GFM)
11
+ * produces the tree, and this module lowers it into a shape a drawing routine
12
+ * can consume without knowing anything about mdast.
13
+ *
14
+ * Only the constructs an assistant answer actually produces are modelled.
15
+ * Anything unrecognized degrades to its text content rather than disappearing.
16
+ */
17
+
18
+ export enum MarkdownBlockType {
19
+ Heading = "Heading",
20
+ Paragraph = "Paragraph",
21
+ ListItem = "ListItem",
22
+ Code = "Code",
23
+ Quote = "Quote",
24
+ Table = "Table",
25
+ Rule = "Rule",
26
+ }
27
+
28
+ export interface MarkdownInline {
29
+ text: string;
30
+ isBold?: boolean | undefined;
31
+ isItalic?: boolean | undefined;
32
+ isCode?: boolean | undefined;
33
+ isStrikethrough?: boolean | undefined;
34
+ }
35
+
36
+ export interface MarkdownBlock {
37
+ type: MarkdownBlockType;
38
+ // Heading level 1-6.
39
+ level?: number | undefined;
40
+ inlines?: Array<MarkdownInline> | undefined;
41
+ // Code blocks.
42
+ code?: string | undefined;
43
+ language?: string | undefined;
44
+ // List items.
45
+ indent?: number | undefined;
46
+ marker?: string | undefined;
47
+ // Tables.
48
+ headers?: Array<string> | undefined;
49
+ rows?: Array<Array<string>> | undefined;
50
+ alignments?: Array<"left" | "right" | "center"> | undefined;
51
+ }
52
+
53
+ interface MarkdownNode {
54
+ type: string;
55
+ value?: string | undefined;
56
+ lang?: string | undefined;
57
+ depth?: number | undefined;
58
+ ordered?: boolean | undefined;
59
+ start?: number | null | undefined;
60
+ align?: Array<string | null> | undefined;
61
+ children?: Array<MarkdownNode> | undefined;
62
+ }
63
+
64
+ interface InlineStyle {
65
+ isBold: boolean;
66
+ isItalic: boolean;
67
+ isCode: boolean;
68
+ isStrikethrough: boolean;
69
+ }
70
+
71
+ const BASE_STYLE: InlineStyle = {
72
+ isBold: false,
73
+ isItalic: false,
74
+ isCode: false,
75
+ isStrikethrough: false,
76
+ };
77
+
78
+ function collectInlines(
79
+ nodes: Array<MarkdownNode>,
80
+ style: InlineStyle,
81
+ into: Array<MarkdownInline>,
82
+ ): void {
83
+ for (const node of nodes) {
84
+ switch (node.type) {
85
+ case "text":
86
+ if (node.value) {
87
+ into.push({
88
+ text: node.value,
89
+ isBold: style.isBold,
90
+ isItalic: style.isItalic,
91
+ isStrikethrough: style.isStrikethrough,
92
+ });
93
+ }
94
+ break;
95
+
96
+ case "inlineCode":
97
+ into.push({
98
+ text: node.value || "",
99
+ isCode: true,
100
+ isBold: style.isBold,
101
+ isItalic: style.isItalic,
102
+ });
103
+ break;
104
+
105
+ case "strong":
106
+ collectInlines(node.children || [], { ...style, isBold: true }, into);
107
+ break;
108
+
109
+ case "emphasis":
110
+ collectInlines(node.children || [], { ...style, isItalic: true }, into);
111
+ break;
112
+
113
+ case "delete":
114
+ collectInlines(
115
+ node.children || [],
116
+ { ...style, isStrikethrough: true },
117
+ into,
118
+ );
119
+ break;
120
+
121
+ case "break":
122
+ into.push({ text: "\n" });
123
+ break;
124
+
125
+ /*
126
+ * Links and images should already have been rewritten into text by the
127
+ * neutralizer before this runs. If one survives, keep its text and drop
128
+ * the destination rather than carrying a live URL forward.
129
+ */
130
+ case "link":
131
+ case "image":
132
+ case "linkReference":
133
+ case "imageReference":
134
+ collectInlines(node.children || [], style, into);
135
+ break;
136
+
137
+ default:
138
+ if (node.children) {
139
+ collectInlines(node.children, style, into);
140
+ } else if (node.value) {
141
+ into.push({ text: node.value, isBold: style.isBold });
142
+ }
143
+ break;
144
+ }
145
+ }
146
+ }
147
+
148
+ function inlinesOf(nodes: Array<MarkdownNode>): Array<MarkdownInline> {
149
+ const inlines: Array<MarkdownInline> = [];
150
+ collectInlines(nodes, BASE_STYLE, inlines);
151
+ return inlines;
152
+ }
153
+
154
+ function plainTextOf(nodes: Array<MarkdownNode>): string {
155
+ return inlinesOf(nodes)
156
+ .map((inline: MarkdownInline) => {
157
+ return inline.text;
158
+ })
159
+ .join("");
160
+ }
161
+
162
+ function tableToBlock(node: MarkdownNode): MarkdownBlock {
163
+ const rows: Array<Array<string>> = (node.children || []).map(
164
+ (row: MarkdownNode) => {
165
+ return (row.children || []).map((cell: MarkdownNode) => {
166
+ return plainTextOf(cell.children || []);
167
+ });
168
+ },
169
+ );
170
+
171
+ const headers: Array<string> =
172
+ rows.length > 0 ? (rows[0] as Array<string>) : [];
173
+
174
+ const alignments: Array<"left" | "right" | "center"> = (node.align || []).map(
175
+ (align: string | null) => {
176
+ if (align === "right") {
177
+ return "right";
178
+ }
179
+ if (align === "center") {
180
+ return "center";
181
+ }
182
+ return "left";
183
+ },
184
+ );
185
+
186
+ return {
187
+ type: MarkdownBlockType.Table,
188
+ headers: headers,
189
+ rows: rows.slice(1),
190
+ alignments: alignments,
191
+ };
192
+ }
193
+
194
+ function flattenList(
195
+ node: MarkdownNode,
196
+ indent: number,
197
+ into: Array<MarkdownBlock>,
198
+ ): void {
199
+ const isOrdered: boolean = node.ordered === true;
200
+ let counter: number = typeof node.start === "number" ? node.start : 1;
201
+
202
+ for (const item of node.children || []) {
203
+ const inlines: Array<MarkdownInline> = [];
204
+ const nested: Array<MarkdownBlock> = [];
205
+
206
+ for (const child of item.children || []) {
207
+ if (child.type === "list") {
208
+ flattenList(child, indent + 1, nested);
209
+ } else if (child.type === "paragraph") {
210
+ // Multiple paragraphs in one item are joined by a soft break.
211
+ if (inlines.length > 0) {
212
+ inlines.push({ text: "\n" });
213
+ }
214
+ collectInlines(child.children || [], BASE_STYLE, inlines);
215
+ } else {
216
+ toBlocks(child, nested, indent + 1);
217
+ }
218
+ }
219
+
220
+ into.push({
221
+ type: MarkdownBlockType.ListItem,
222
+ inlines: inlines,
223
+ indent: indent,
224
+ marker: isOrdered ? `${counter}.` : "-",
225
+ });
226
+ into.push(...nested);
227
+
228
+ counter++;
229
+ }
230
+ }
231
+
232
+ function toBlocks(
233
+ node: MarkdownNode,
234
+ into: Array<MarkdownBlock>,
235
+ indent: number = 0,
236
+ ): void {
237
+ switch (node.type) {
238
+ case "heading":
239
+ into.push({
240
+ type: MarkdownBlockType.Heading,
241
+ level: node.depth || 1,
242
+ inlines: inlinesOf(node.children || []),
243
+ });
244
+ break;
245
+
246
+ case "paragraph":
247
+ into.push({
248
+ type: MarkdownBlockType.Paragraph,
249
+ inlines: inlinesOf(node.children || []),
250
+ indent: indent,
251
+ });
252
+ break;
253
+
254
+ case "list":
255
+ flattenList(node, indent, into);
256
+ break;
257
+
258
+ case "code":
259
+ into.push({
260
+ type: MarkdownBlockType.Code,
261
+ code: node.value || "",
262
+ language: node.lang || "",
263
+ });
264
+ break;
265
+
266
+ case "blockquote":
267
+ for (const child of node.children || []) {
268
+ const quoted: Array<MarkdownBlock> = [];
269
+ toBlocks(child, quoted, indent);
270
+ for (const block of quoted) {
271
+ into.push({ ...block, type: MarkdownBlockType.Quote });
272
+ }
273
+ }
274
+ break;
275
+
276
+ case "table":
277
+ into.push(tableToBlock(node));
278
+ break;
279
+
280
+ case "thematicBreak":
281
+ into.push({ type: MarkdownBlockType.Rule });
282
+ break;
283
+
284
+ case "html":
285
+ // Raw HTML is inert text here; show it rather than silently dropping it.
286
+ if (node.value) {
287
+ into.push({
288
+ type: MarkdownBlockType.Paragraph,
289
+ inlines: [{ text: node.value }],
290
+ indent: indent,
291
+ });
292
+ }
293
+ break;
294
+
295
+ default:
296
+ if (node.children) {
297
+ for (const child of node.children) {
298
+ toBlocks(child, into, indent);
299
+ }
300
+ }
301
+ break;
302
+ }
303
+ }
304
+
305
+ export default function parseMarkdownBlocks(
306
+ text: string,
307
+ ): Array<MarkdownBlock> {
308
+ if (!text) {
309
+ return [];
310
+ }
311
+
312
+ try {
313
+ const tree: MarkdownNode = unified()
314
+ .use(remarkParse)
315
+ .use(remarkGfm)
316
+ .parse(text) as unknown as MarkdownNode;
317
+
318
+ const blocks: Array<MarkdownBlock> = [];
319
+ for (const child of tree.children || []) {
320
+ toBlocks(child, blocks);
321
+ }
322
+ return blocks;
323
+ } catch {
324
+ // A parse failure should still show the answer, just without formatting.
325
+ return [{ type: MarkdownBlockType.Paragraph, inlines: [{ text: text }] }];
326
+ }
327
+ }
@@ -0,0 +1,215 @@
1
+ import { gfmToMarkdown } from "mdast-util-gfm";
2
+ import { toMarkdown } from "mdast-util-to-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkParse from "remark-parse";
5
+ import { unified } from "unified";
6
+
7
+ /*
8
+ * Neutralizes the exfiltration channels in assistant markdown before it is
9
+ * written to an exported file.
10
+ *
11
+ * The chat UI already does this on screen: SafeChatMarkdown renders through
12
+ * MarkdownViewer in safeMode, which drops every href and never emits an <img>.
13
+ * The reason is that an assistant answer is derived from attacker-influenceable
14
+ * telemetry (a log body or exception message can carry markdown), so a
15
+ * prompt-injected response must not be able to render a tracking pixel or hand
16
+ * the reader a clickable exfil URL. An export that turned those back into live
17
+ * links would reopen that hole in a file that then gets shared around, so the
18
+ * exports hold the same line.
19
+ *
20
+ * Unlike the on-screen path we keep the URL visible — a reader auditing a
21
+ * transcript should be able to see where a link pointed — but only ever as
22
+ * inert text. In markdown that means wrapping it in a code span: a bare URL in
23
+ * a .md file would otherwise be turned back into a live link by GFM's autolink
24
+ * literals, which is precisely what we are trying to prevent.
25
+ *
26
+ * This works on the PARSED tree rather than by regex over the raw string, for
27
+ * the same reason MarkdownViewer does: no CommonMark edge case (reference
28
+ * links, autolinks, raw HTML, nesting) can slip a link past a parser, whereas
29
+ * regexes miss those forms routinely.
30
+ */
31
+
32
+ /*
33
+ * A structural view of the mdast nodes we touch. Declared locally rather than
34
+ * imported from @types/mdast: the version hoisted into Common/node_modules is
35
+ * v3 (pulled in by slackify-markdown's old unified 9 tree) while the mdast-util
36
+ * packages here expect v4, so importing those types would fight the resolver
37
+ * for no benefit.
38
+ */
39
+ interface MarkdownNode {
40
+ type: string;
41
+ value?: string | undefined;
42
+ url?: string | undefined;
43
+ alt?: string | null | undefined;
44
+ identifier?: string | undefined;
45
+ children?: Array<MarkdownNode> | undefined;
46
+ }
47
+
48
+ type MarkdownRoot = Parameters<typeof toMarkdown>[0];
49
+
50
+ // Node types that may only appear inside a block, never directly under the root.
51
+ const PHRASING_TYPES: Set<string> = new Set(["text", "inlineCode"]);
52
+
53
+ function textNode(value: string): MarkdownNode {
54
+ return { type: "text", value: value };
55
+ }
56
+
57
+ function codeNode(value: string): MarkdownNode {
58
+ return { type: "inlineCode", value: value };
59
+ }
60
+
61
+ // The visible text of a subtree, used to decide whether a label adds anything.
62
+ function textOf(nodes: Array<MarkdownNode>): string {
63
+ return nodes
64
+ .map((node: MarkdownNode) => {
65
+ if (node.type === "text" || node.type === "inlineCode") {
66
+ return node.value || "";
67
+ }
68
+ return node.children ? textOf(node.children) : "";
69
+ })
70
+ .join("");
71
+ }
72
+
73
+ /*
74
+ * "label (`url`)" — or just "`url`" when the label is only a repeat of the URL,
75
+ * which is what an autolink or a GFM bare URL parses into.
76
+ */
77
+ function inertLink(
78
+ label: Array<MarkdownNode>,
79
+ url: string,
80
+ ): Array<MarkdownNode> {
81
+ const labelText: string = textOf(label);
82
+ const hasDistinctLabel: boolean = Boolean(labelText) && labelText !== url;
83
+
84
+ if (!url) {
85
+ return hasDistinctLabel ? label : [textNode(labelText)];
86
+ }
87
+
88
+ if (!hasDistinctLabel) {
89
+ return [codeNode(url)];
90
+ }
91
+
92
+ return [...label, textNode(" ("), codeNode(url), textNode(")")];
93
+ }
94
+
95
+ function neutralizeNode(
96
+ node: MarkdownNode,
97
+ definitions: Map<string, string>,
98
+ isRootChild: boolean,
99
+ ): Array<MarkdownNode> {
100
+ // Depth first, so a link nested inside emphasis is rewritten before its parent.
101
+ if (node.children) {
102
+ const rewritten: Array<MarkdownNode> = [];
103
+ for (const child of node.children) {
104
+ rewritten.push(...neutralizeNode(child, definitions, false));
105
+ }
106
+ node.children = rewritten;
107
+ }
108
+
109
+ const identifier: string = (node.identifier || "").toLowerCase();
110
+
111
+ switch (node.type) {
112
+ /*
113
+ * Drop link definitions outright. Their referents have already been
114
+ * inlined below, and leaving a definition behind would let "[x][ref]"
115
+ * resolve to a live link again.
116
+ */
117
+ case "definition":
118
+ return [];
119
+
120
+ /*
121
+ * Raw HTML is inert on screen (react-markdown does not render it without
122
+ * rehype-raw), but a .md file is rendered by viewers that do, where an
123
+ * <img src> or <a href> would be live. Demote it to text — toMarkdown
124
+ * escapes the angle brackets on the way out.
125
+ */
126
+ case "html": {
127
+ const asText: MarkdownNode = textNode(node.value || "");
128
+ return isRootChild
129
+ ? [{ type: "paragraph", children: [asText] }]
130
+ : [asText];
131
+ }
132
+
133
+ case "link":
134
+ return inertLink(node.children || [], node.url || "");
135
+
136
+ case "image":
137
+ return inertLink(
138
+ [textNode(`[image: ${node.alt || ""}]`)],
139
+ node.url || "",
140
+ );
141
+
142
+ case "linkReference":
143
+ return inertLink(node.children || [], definitions.get(identifier) || "");
144
+
145
+ case "imageReference":
146
+ return inertLink(
147
+ [textNode(`[image: ${node.alt || ""}]`)],
148
+ definitions.get(identifier) || "",
149
+ );
150
+
151
+ default:
152
+ return [node];
153
+ }
154
+ }
155
+
156
+ function collectDefinitions(
157
+ node: MarkdownNode,
158
+ into: Map<string, string>,
159
+ ): void {
160
+ if (node.type === "definition" && node.identifier) {
161
+ into.set(node.identifier.toLowerCase(), node.url || "");
162
+ }
163
+ for (const child of node.children || []) {
164
+ collectDefinitions(child, into);
165
+ }
166
+ }
167
+
168
+ /*
169
+ * Rewrites every link, image and raw HTML node in `text` into inert text, and
170
+ * returns the result as markdown. Safe to call on any string; a parse failure
171
+ * degrades to a fenced code block rather than emitting unneutralized markdown.
172
+ */
173
+ export default function neutralizeAssistantMarkdown(text: string): string {
174
+ if (!text) {
175
+ return "";
176
+ }
177
+
178
+ try {
179
+ const tree: MarkdownNode = unified()
180
+ .use(remarkParse)
181
+ .use(remarkGfm)
182
+ .parse(text) as unknown as MarkdownNode;
183
+
184
+ const definitions: Map<string, string> = new Map();
185
+ collectDefinitions(tree, definitions);
186
+
187
+ const children: Array<MarkdownNode> = [];
188
+ for (const child of tree.children || []) {
189
+ children.push(...neutralizeNode(child, definitions, true));
190
+ }
191
+
192
+ /*
193
+ * A root child demoted to phrasing (a stray inline node left behind once
194
+ * its link wrapper was removed) is not valid at root level; wrap it back
195
+ * into a paragraph so the serializer stays on its typed path.
196
+ */
197
+ tree.children = children.map((child: MarkdownNode) => {
198
+ return PHRASING_TYPES.has(child.type)
199
+ ? { type: "paragraph", children: [child] }
200
+ : child;
201
+ });
202
+
203
+ return toMarkdown(tree as unknown as MarkdownRoot, {
204
+ extensions: [gfmToMarkdown()],
205
+ bullet: "-",
206
+ }).trim();
207
+ } catch {
208
+ /*
209
+ * Never fall back to returning the raw string: that would ship exactly the
210
+ * live links this function exists to remove. A fence renders the content
211
+ * verbatim and inert.
212
+ */
213
+ return ["```", text.replace(/```/g, "'''"), "```"].join("\n");
214
+ }
215
+ }