feishu-codex-console 1.0.0-beta.6

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 (113) hide show
  1. package/.env.example +101 -0
  2. package/.feishu-codex-policy.example.json +11 -0
  3. package/.feishu-codex-runbooks.example.json +36 -0
  4. package/CHANGELOG.md +129 -0
  5. package/CODE_OF_CONDUCT.md +7 -0
  6. package/CONTRIBUTING.md +52 -0
  7. package/LICENSE +21 -0
  8. package/README.en.md +81 -0
  9. package/README.md +398 -0
  10. package/ROADMAP.md +40 -0
  11. package/SECURITY.md +53 -0
  12. package/dist/account-quota-card.js +233 -0
  13. package/dist/account-quota.js +125 -0
  14. package/dist/app-server-client.js +281 -0
  15. package/dist/card-session.js +166 -0
  16. package/dist/codex-events.js +1 -0
  17. package/dist/codex-runner.js +875 -0
  18. package/dist/config.js +198 -0
  19. package/dist/confirmation-card.js +135 -0
  20. package/dist/control-card.js +345 -0
  21. package/dist/conversation-turn-session.js +209 -0
  22. package/dist/data-maintenance.js +71 -0
  23. package/dist/device-card.js +460 -0
  24. package/dist/device-health.js +94 -0
  25. package/dist/diagnostics.js +253 -0
  26. package/dist/doctor.js +250 -0
  27. package/dist/fallback-card-session.js +37 -0
  28. package/dist/health-file.js +75 -0
  29. package/dist/index.js +4330 -0
  30. package/dist/lark-cli.js +558 -0
  31. package/dist/lark-retry.js +34 -0
  32. package/dist/maintenance.js +140 -0
  33. package/dist/model-capabilities.js +31 -0
  34. package/dist/onboarding-card.js +312 -0
  35. package/dist/permission-lease.js +22 -0
  36. package/dist/policy.js +506 -0
  37. package/dist/progress.js +267 -0
  38. package/dist/project-card.js +303 -0
  39. package/dist/project-overview-card.js +182 -0
  40. package/dist/project-overview.js +278 -0
  41. package/dist/project-policy.js +160 -0
  42. package/dist/project-registry.js +259 -0
  43. package/dist/project-status.js +45 -0
  44. package/dist/project-workspace.js +55 -0
  45. package/dist/quota-card.js +94 -0
  46. package/dist/recovery-policy.js +26 -0
  47. package/dist/redaction.js +67 -0
  48. package/dist/remote-ready.js +112 -0
  49. package/dist/response-card.js +139 -0
  50. package/dist/result-card.js +166 -0
  51. package/dist/review-card.js +452 -0
  52. package/dist/runbook-card.js +272 -0
  53. package/dist/runbooks.js +191 -0
  54. package/dist/runtime-card.js +337 -0
  55. package/dist/session-card.js +128 -0
  56. package/dist/session-naming.js +14 -0
  57. package/dist/smoke.js +28 -0
  58. package/dist/state-backup.js +302 -0
  59. package/dist/state-store.js +874 -0
  60. package/dist/task-card.js +640 -0
  61. package/dist/task-center-card.js +176 -0
  62. package/dist/task-failure.js +43 -0
  63. package/dist/task-intent.js +76 -0
  64. package/dist/task-queue.js +187 -0
  65. package/dist/task-reconciliation.js +80 -0
  66. package/dist/task-review.js +497 -0
  67. package/dist/team-card.js +275 -0
  68. package/dist/team-directory.js +54 -0
  69. package/dist/team-policy.js +93 -0
  70. package/dist/types.js +1 -0
  71. package/dist/version.js +9 -0
  72. package/dist/workspace-session.js +64 -0
  73. package/docs/ARCHITECTURE.md +54 -0
  74. package/docs/COMPATIBILITY.md +55 -0
  75. package/docs/CONFIGURATION.md +88 -0
  76. package/docs/DEMO.md +45 -0
  77. package/docs/GOOD_FIRST_ISSUES.md +23 -0
  78. package/docs/INSTALLATION.md +207 -0
  79. package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
  80. package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
  81. package/docs/RELEASE_CHECKLIST.md +65 -0
  82. package/docs/TEAM_DEPLOYMENT.md +35 -0
  83. package/docs/TROUBLESHOOTING.md +130 -0
  84. package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
  85. package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
  86. package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
  87. package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
  88. package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
  89. package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
  90. package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
  91. package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
  92. package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
  93. package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
  94. package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
  95. package/package.json +76 -0
  96. package/scripts/capability-probe.mjs +113 -0
  97. package/scripts/cli.mjs +919 -0
  98. package/scripts/config-file.mjs +137 -0
  99. package/scripts/discovery-lib.mjs +78 -0
  100. package/scripts/install-card.mjs +37 -0
  101. package/scripts/install-detection.mjs +126 -0
  102. package/scripts/install-state.mjs +107 -0
  103. package/scripts/launchd.mjs +161 -0
  104. package/scripts/migrate-legacy.mjs +97 -0
  105. package/scripts/package-smoke.mjs +163 -0
  106. package/scripts/release-dist-tag.mjs +7 -0
  107. package/scripts/runbook-template.mjs +36 -0
  108. package/scripts/service-health.mjs +110 -0
  109. package/scripts/service.mjs +24 -0
  110. package/scripts/setup-lib.mjs +118 -0
  111. package/scripts/systemd.mjs +96 -0
  112. package/scripts/upgrade-lib.mjs +99 -0
  113. package/scripts/verify-release.mjs +37 -0
@@ -0,0 +1,452 @@
1
+ import { effectiveTestRuns, testEvidenceState, } from "./progress.js";
2
+ export function renderReviewCard(snapshot) {
3
+ const tests = effectiveTestRuns(snapshot.commandRuns);
4
+ const testState = summarizeTests(tests);
5
+ const body = snapshot.fileDiff
6
+ ? renderDiffBody(snapshot)
7
+ : renderOverviewBody(snapshot, tests, testState);
8
+ return {
9
+ schema: "2.0",
10
+ config: {
11
+ update_multi: true,
12
+ width_mode: "default",
13
+ enable_forward: false,
14
+ summary: { content: `任务验证 · ${safePlain(snapshot.projectLabel)}` },
15
+ },
16
+ header: {
17
+ title: { tag: "plain_text", content: "Codex 任务验证" },
18
+ subtitle: {
19
+ tag: "plain_text",
20
+ content: truncatePlain(snapshot.prompt.replace(/\s+/g, " "), 72),
21
+ },
22
+ template: testState.state === "failed"
23
+ ? "red"
24
+ : snapshot.review.attribution === "mixed" || snapshot.review.attribution === "unknown"
25
+ ? "orange"
26
+ : "green",
27
+ icon: { tag: "standard_icon", token: "tasklist_colorful" },
28
+ text_tag_list: [
29
+ {
30
+ tag: "text_tag",
31
+ text: { tag: "plain_text", content: reviewStatusLabel(snapshot.review) },
32
+ color: snapshot.review.availability === "ready" ? "green" : "neutral",
33
+ },
34
+ {
35
+ tag: "text_tag",
36
+ text: { tag: "plain_text", content: testState.label },
37
+ color: testState.state === "passed" ? "green" : testState.state === "failed" ? "red" : "neutral",
38
+ },
39
+ {
40
+ tag: "text_tag",
41
+ text: { tag: "plain_text", content: `#${snapshot.taskId.slice(0, 12)}` },
42
+ color: "neutral",
43
+ },
44
+ ],
45
+ },
46
+ body: {
47
+ direction: "vertical",
48
+ padding: "12px 12px 18px 12px",
49
+ vertical_spacing: "12px",
50
+ elements: body,
51
+ },
52
+ };
53
+ }
54
+ function renderOverviewBody(snapshot, tests, testState) {
55
+ const review = snapshot.review;
56
+ const start = snapshot.page * snapshot.pageSize;
57
+ const pageFiles = review.files.slice(start, start + snapshot.pageSize);
58
+ const pageCount = Math.max(1, Math.ceil(review.files.length / snapshot.pageSize));
59
+ if (review.availability === "no_changes" && tests.length === 0) {
60
+ return [
61
+ ...(snapshot.feedback ? [feedbackPanel(snapshot.feedback)] : []),
62
+ {
63
+ tag: "column_set",
64
+ element_id: "review_empty",
65
+ flex_mode: "none",
66
+ columns: [
67
+ {
68
+ tag: "column",
69
+ width: "weighted",
70
+ weight: 1,
71
+ background_style: "grey-50",
72
+ padding: "12px 12px 12px 12px",
73
+ elements: [
74
+ {
75
+ tag: "markdown",
76
+ content: "**这次任务没有可验证的代码结果**\n<font color='grey'>未发现文件变更,也没有运行测试;结果属于分析或说明,无需进入代码审阅。</font>",
77
+ },
78
+ ],
79
+ },
80
+ ],
81
+ },
82
+ overviewActions(snapshot.taskId, 0, false, false, snapshot.embedded ?? false),
83
+ ];
84
+ }
85
+ const elements = [
86
+ metadataRow(review, testState.label),
87
+ reviewConfidencePanel(review),
88
+ ];
89
+ if (snapshot.feedback)
90
+ elements.push(feedbackPanel(snapshot.feedback));
91
+ elements.push(testPanel(tests));
92
+ if (review.availability === "ready" && pageFiles.length > 0) {
93
+ elements.push({
94
+ tag: "markdown",
95
+ element_id: "files_heading",
96
+ content: `**文件变更** <font color='grey'>第 ${snapshot.page + 1} / ${pageCount} 页</font>`,
97
+ });
98
+ pageFiles.forEach((file, index) => {
99
+ elements.push(fileRow(snapshot.taskId, file, start + index, index));
100
+ });
101
+ }
102
+ else {
103
+ elements.push({
104
+ tag: "column_set",
105
+ element_id: "review_empty",
106
+ flex_mode: "none",
107
+ columns: [
108
+ {
109
+ tag: "column",
110
+ width: "weighted",
111
+ weight: 1,
112
+ background_style: "grey-50",
113
+ padding: "12px 12px 12px 12px",
114
+ elements: [
115
+ {
116
+ tag: "markdown",
117
+ content: review.availability === "no_changes"
118
+ ? "**未发现代码变更**\n<font color='grey'>任务可能只完成了分析,或修改已经提交且基线不可用。</font>"
119
+ : `**暂时无法生成 Diff**\n<font color='grey'>${safe(review.error ?? "请回到本机确认工作区状态。")}</font>`,
120
+ },
121
+ ],
122
+ },
123
+ ],
124
+ });
125
+ }
126
+ elements.push(overviewActions(snapshot.taskId, snapshot.page, snapshot.page > 0, start + pageFiles.length < review.files.length, snapshot.embedded ?? false));
127
+ elements.push({
128
+ tag: "markdown",
129
+ element_id: "review_footer",
130
+ text_size: "notation",
131
+ text_align: "center",
132
+ content: "<font color='grey'>审阅是只读操作;直接发送补充要求可继续修改。提交、推送、部署和 PR 仍需单独确认。</font>",
133
+ });
134
+ return elements;
135
+ }
136
+ function renderDiffBody(snapshot) {
137
+ const diff = snapshot.fileDiff;
138
+ const pageSize = snapshot.diffPageSize ?? 7_000;
139
+ const pageCount = Math.max(1, Math.ceil(diff.content.length / pageSize));
140
+ const diffPage = Math.min(Math.max(0, snapshot.diffPage ?? 0), pageCount - 1);
141
+ const visibleContent = diff.content.slice(diffPage * pageSize, (diffPage + 1) * pageSize);
142
+ const selected = snapshot.selectedFileIndex === undefined
143
+ ? undefined
144
+ : snapshot.review.files[snapshot.selectedFileIndex];
145
+ const warnings = [
146
+ ...(selected?.attribution === "mixed"
147
+ ? ["这个文件在任务开始前已有修改,下面是合并后的工作区 Diff。"]
148
+ : []),
149
+ ...(diff.stale ? ["文件在任务完成后又发生了变化,下面显示的是当前工作区内容。"] : []),
150
+ ...(diff.sensitive ? ["敏感路径已隐藏正文。"] : []),
151
+ ...(diff.truncated ? ["Diff 超过安全预览总量,最后一页会标记截断。"] : []),
152
+ ];
153
+ return [
154
+ ...(snapshot.feedback ? [feedbackPanel(snapshot.feedback)] : []),
155
+ {
156
+ tag: "markdown",
157
+ element_id: "diff_heading",
158
+ content: `**${safe(diff.path)}** <font color='grey'>Diff 第 ${diffPage + 1} / ${pageCount} 页</font>\n<font color='grey'>${warnings.length > 0 ? warnings.map(safe).join(" ") : "任务完成时的只读差异快照"}</font>`,
159
+ },
160
+ {
161
+ tag: "column_set",
162
+ element_id: "diff_panel",
163
+ flex_mode: "none",
164
+ columns: [
165
+ {
166
+ tag: "column",
167
+ width: "weighted",
168
+ weight: 1,
169
+ background_style: diff.error || diff.sensitive ? "orange-50" : "grey-50",
170
+ padding: "12px 12px 12px 12px",
171
+ elements: [
172
+ {
173
+ tag: "markdown",
174
+ element_id: "diff_content",
175
+ content: `\`\`\`diff\n${safeCode(visibleContent)}\n\`\`\``,
176
+ },
177
+ ],
178
+ },
179
+ ],
180
+ },
181
+ diffActions(snapshot.taskId, snapshot.page, snapshot.selectedFileIndex ?? 0, diffPage, pageCount, snapshot.embedded ?? false),
182
+ ];
183
+ }
184
+ function metadataRow(review, testLabel) {
185
+ return {
186
+ tag: "column_set",
187
+ element_id: "review_metadata",
188
+ flex_mode: "none",
189
+ horizontal_spacing: "8px",
190
+ columns: [
191
+ metric("文件", String(review.totalFiles), "left"),
192
+ metric("新增 / 删除", `+${review.totalAdditions} / -${review.totalDeletions}`, "center"),
193
+ metric("测试", testLabel, "right"),
194
+ ],
195
+ };
196
+ }
197
+ function metric(label, value, align) {
198
+ return {
199
+ tag: "column",
200
+ width: "weighted",
201
+ weight: 1,
202
+ background_style: "grey-50",
203
+ padding: "8px 4px 8px 4px",
204
+ elements: [
205
+ {
206
+ tag: "markdown",
207
+ text_align: align,
208
+ text_size: "notation",
209
+ content: `<font color='grey'>${label}</font>\n**${safe(value)}**`,
210
+ },
211
+ ],
212
+ };
213
+ }
214
+ function reviewConfidencePanel(review) {
215
+ const mixed = review.attribution !== "task";
216
+ const detail = review.attribution === "task"
217
+ ? "任务开始时已建立 Git 基线;列表排除了未被本任务触碰的既有修改。"
218
+ : review.attribution === "mixed"
219
+ ? "部分文件在任务开始前已经修改,Diff 可能同时包含你的原有内容;请逐文件确认。"
220
+ : "任务基线不可用,文件列表主要来自 Codex 事件,不能保证完整归因。";
221
+ const excluded = review.preexistingFilesExcluded > 0
222
+ ? ` 已排除 ${review.preexistingFilesExcluded} 个未变化的既有脏文件。`
223
+ : "";
224
+ return {
225
+ tag: "column_set",
226
+ element_id: "confidence_panel",
227
+ flex_mode: "none",
228
+ columns: [
229
+ {
230
+ tag: "column",
231
+ width: "weighted",
232
+ weight: 1,
233
+ background_style: mixed ? "orange-50" : "green-50",
234
+ padding: "10px 12px 10px 12px",
235
+ elements: [
236
+ {
237
+ tag: "markdown",
238
+ content: `**${mixed ? "需要人工确认归因" : "任务基线清晰"}**\n${safe(detail + excluded)}`,
239
+ },
240
+ ],
241
+ },
242
+ ],
243
+ };
244
+ }
245
+ function testPanel(tests) {
246
+ const visible = tests.slice(-5);
247
+ const lines = visible.length === 0
248
+ ? ["<font color='grey'>未检测到测试命令。任务完成不等于测试通过。</font>"]
249
+ : visible.map((run) => {
250
+ const status = run.status === "passed" ? "通过" : run.status === "failed" ? "失败" : "运行中";
251
+ const color = run.status === "passed" ? "green" : run.status === "failed" ? "red" : "blue";
252
+ const output = run.status === "failed" && run.outputSummary
253
+ ? `\n<font color='grey'>${safe(truncatePlain(run.outputSummary, 500))}</font>`
254
+ : "";
255
+ return `<font color='${color}'>${status}</font> ${safe(truncatePlain(run.command, 140))}${run.exitCode === undefined ? "" : ` · 退出码 ${run.exitCode}`}${output}`;
256
+ });
257
+ return {
258
+ tag: "column_set",
259
+ element_id: "test_panel",
260
+ flex_mode: "none",
261
+ columns: [
262
+ {
263
+ tag: "column",
264
+ width: "weighted",
265
+ weight: 1,
266
+ background_style: "grey-50",
267
+ padding: "10px 12px 10px 12px",
268
+ vertical_spacing: "4px",
269
+ elements: [
270
+ { tag: "markdown", content: ["**测试证据**", ...lines].join("\n\n") },
271
+ ],
272
+ },
273
+ ],
274
+ };
275
+ }
276
+ function fileRow(taskId, file, absoluteIndex, rowIndex) {
277
+ const stats = file.binary
278
+ ? "二进制"
279
+ : file.additions === null || file.deletions === null
280
+ ? "行数未知"
281
+ : `+${file.additions} / -${file.deletions}`;
282
+ const kind = file.kind === "add" ? "新增" : file.kind === "delete" ? "删除" : "修改";
283
+ const flags = [
284
+ file.attribution === "mixed" ? "含既有修改" : "任务变更",
285
+ file.sensitive ? "内容受保护" : "",
286
+ ].filter(Boolean).join(" · ");
287
+ return {
288
+ tag: "column_set",
289
+ element_id: `review_file_${rowIndex}`,
290
+ flex_mode: "none",
291
+ columns: [
292
+ {
293
+ tag: "column",
294
+ width: "weighted",
295
+ weight: 1,
296
+ background_style: file.attribution === "mixed" ? "orange-50" : "grey-50",
297
+ padding: "9px 10px 9px 10px",
298
+ elements: [
299
+ {
300
+ tag: "markdown",
301
+ content: `**${safe(file.path)}**\n<font color='grey'>${kind} · ${stats} · ${flags}</font>`,
302
+ },
303
+ ],
304
+ },
305
+ {
306
+ tag: "column",
307
+ width: "auto",
308
+ vertical_align: "center",
309
+ elements: [
310
+ actionButton(`file_open_${rowIndex}`, file.sensitive ? "说明" : "查看 Diff", "review_file", taskId, { file_index: absoluteIndex }),
311
+ ],
312
+ },
313
+ ],
314
+ };
315
+ }
316
+ function overviewActions(taskId, page, hasPrevious, hasNext, embedded) {
317
+ const actions = [];
318
+ if (hasPrevious) {
319
+ actions.push(actionButton("review_prev", "上一页", "review_page", taskId, { page: page - 1 }));
320
+ }
321
+ if (hasNext) {
322
+ actions.push(actionButton("review_next", "下一页", "review_page", taskId, { page: page + 1 }));
323
+ }
324
+ actions.push(actionButton("review_refresh", "刷新快照", "review_refresh", taskId));
325
+ if (embedded) {
326
+ actions.push(actionButton("review_close", "返回结果", "review_close", taskId));
327
+ }
328
+ return actionGrid("review_actions", actions);
329
+ }
330
+ function diffActions(taskId, page, fileIndex, diffPage, pageCount, embedded) {
331
+ const actions = [
332
+ actionButton("review_back", "返回列表", "review_back", taskId, { page }),
333
+ ];
334
+ if (diffPage > 0) {
335
+ actions.push(actionButton("diff_prev", "上一页", "review_diff_page", taskId, {
336
+ page,
337
+ file_index: fileIndex,
338
+ diff_page: diffPage - 1,
339
+ }));
340
+ }
341
+ if (diffPage + 1 < pageCount) {
342
+ actions.push(actionButton("diff_next", "下一页", "review_diff_page", taskId, {
343
+ page,
344
+ file_index: fileIndex,
345
+ diff_page: diffPage + 1,
346
+ }));
347
+ }
348
+ if (pageCount === 1) {
349
+ actions.push(actionButton("diff_refresh", "刷新 Diff", "review_refresh", taskId, {
350
+ page,
351
+ file_index: fileIndex,
352
+ diff_page: diffPage,
353
+ }));
354
+ }
355
+ if (embedded) {
356
+ actions.push(actionButton("review_close", "返回结果", "review_close", taskId));
357
+ }
358
+ return actionGrid("diff_actions", actions);
359
+ }
360
+ function actionButton(elementId, label, action, taskId, extra = {}) {
361
+ return {
362
+ tag: "button",
363
+ element_id: elementId,
364
+ text: { tag: "plain_text", content: label },
365
+ type: action === "review_file" ? "primary_filled" : "default",
366
+ width: "fill",
367
+ behaviors: [
368
+ {
369
+ type: "callback",
370
+ value: {
371
+ bridge: "feishu-codex-v6",
372
+ action,
373
+ task_id: taskId,
374
+ ...extra,
375
+ },
376
+ },
377
+ ],
378
+ };
379
+ }
380
+ function actionGrid(id, actions) {
381
+ return {
382
+ tag: "column_set",
383
+ element_id: id,
384
+ flex_mode: "none",
385
+ horizontal_spacing: "8px",
386
+ columns: actions.map((action) => ({
387
+ tag: "column",
388
+ width: "weighted",
389
+ weight: 1,
390
+ elements: [action],
391
+ })),
392
+ };
393
+ }
394
+ function feedbackPanel(feedback) {
395
+ return {
396
+ tag: "column_set",
397
+ element_id: "review_feedback",
398
+ flex_mode: "none",
399
+ columns: [
400
+ {
401
+ tag: "column",
402
+ width: "weighted",
403
+ weight: 1,
404
+ background_style: "blue-50",
405
+ padding: "9px 11px 9px 11px",
406
+ elements: [{ tag: "markdown", content: `<font color='blue'>${safe(feedback)}</font>` }],
407
+ },
408
+ ],
409
+ };
410
+ }
411
+ function summarizeTests(tests) {
412
+ const state = testEvidenceState(tests);
413
+ if (state === "missing")
414
+ return { state, label: "未运行测试" };
415
+ if (state === "failed") {
416
+ return { state, label: `${tests.filter((run) => run.status === "failed").length} 次测试失败` };
417
+ }
418
+ if (state === "running") {
419
+ return { state, label: `${tests.filter((run) => run.status === "running").length} 次测试运行中` };
420
+ }
421
+ return { state, label: `${tests.length} 次测试通过` };
422
+ }
423
+ function reviewStatusLabel(review) {
424
+ if (review.availability === "no_changes")
425
+ return "无代码变更";
426
+ if (review.availability === "unavailable")
427
+ return "审阅不可用";
428
+ return `${review.totalFiles} 个文件`;
429
+ }
430
+ function safe(value) {
431
+ return value
432
+ .replaceAll("&", "&")
433
+ .replaceAll("<", "<")
434
+ .replaceAll(">", ">")
435
+ .replaceAll("*", "*")
436
+ .replaceAll("`", "`")
437
+ .replaceAll("_", "_");
438
+ }
439
+ function safeCode(value) {
440
+ return value
441
+ .replaceAll("<at", "<at")
442
+ .replaceAll("</at>", "</at>")
443
+ .replaceAll("```", "```");
444
+ }
445
+ function safePlain(value) {
446
+ return value.replace(/[\r\n]/g, " ").slice(0, 80);
447
+ }
448
+ function truncatePlain(value, max) {
449
+ if (value.length <= max)
450
+ return value;
451
+ return `${value.slice(0, Math.max(0, max - 1))}…`;
452
+ }