openclaw-codex-app-server 0.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.
- package/AGENTS.md +22 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/index.ts +69 -0
- package/openclaw.plugin.json +105 -0
- package/package.json +28 -0
- package/src/client.test.ts +332 -0
- package/src/client.ts +2914 -0
- package/src/config.ts +103 -0
- package/src/controller.test.ts +1177 -0
- package/src/controller.ts +3232 -0
- package/src/format.test.ts +502 -0
- package/src/format.ts +869 -0
- package/src/openclaw-plugin-sdk.d.ts +237 -0
- package/src/pending-input.test.ts +298 -0
- package/src/pending-input.ts +785 -0
- package/src/state.test.ts +228 -0
- package/src/state.ts +354 -0
- package/src/thread-picker.test.ts +47 -0
- package/src/thread-picker.ts +98 -0
- package/src/thread-selection.test.ts +89 -0
- package/src/thread-selection.ts +106 -0
- package/src/types.ts +372 -0
- package/tsconfig.json +24 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import type {
|
|
3
|
+
AccountSummary,
|
|
4
|
+
ContextUsageSnapshot,
|
|
5
|
+
ExperimentalFeatureSummary,
|
|
6
|
+
McpServerSummary,
|
|
7
|
+
ModelSummary,
|
|
8
|
+
RateLimitSummary,
|
|
9
|
+
ReviewResult,
|
|
10
|
+
SkillSummary,
|
|
11
|
+
StoredBinding,
|
|
12
|
+
ThreadReplay,
|
|
13
|
+
ThreadState,
|
|
14
|
+
ThreadSummary,
|
|
15
|
+
TurnResult,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
import { getProjectName } from "./thread-picker.js";
|
|
18
|
+
|
|
19
|
+
function formatDateAge(value?: number): string | undefined {
|
|
20
|
+
if (!value) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const deltaMs = Math.max(0, Date.now() - value);
|
|
24
|
+
const minutes = Math.round(deltaMs / 60_000);
|
|
25
|
+
if (minutes < 1) {
|
|
26
|
+
return "just now";
|
|
27
|
+
}
|
|
28
|
+
if (minutes < 60) {
|
|
29
|
+
return `${minutes}m ago`;
|
|
30
|
+
}
|
|
31
|
+
const hours = Math.round(minutes / 60);
|
|
32
|
+
if (hours < 48) {
|
|
33
|
+
return `${hours}h ago`;
|
|
34
|
+
}
|
|
35
|
+
const days = Math.round(hours / 24);
|
|
36
|
+
return `${days}d ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function truncateMiddle(value: string, maxLength: number): string {
|
|
40
|
+
if (value.length <= maxLength) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
if (maxLength <= 3) {
|
|
44
|
+
return value.slice(0, maxLength);
|
|
45
|
+
}
|
|
46
|
+
const keep = maxLength - 3;
|
|
47
|
+
const left = Math.ceil(keep / 2);
|
|
48
|
+
const right = Math.floor(keep / 2);
|
|
49
|
+
return `${value.slice(0, left)}...${value.slice(value.length - right)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatThreadButtonTitle(thread: ThreadSummary): string {
|
|
53
|
+
return thread.title?.trim() || thread.threadId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatCompactAge(value?: number): string | undefined {
|
|
57
|
+
if (!value) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const deltaMs = Math.max(0, Date.now() - value);
|
|
61
|
+
const minutes = Math.round(deltaMs / 60_000);
|
|
62
|
+
if (minutes < 1) {
|
|
63
|
+
return "0m";
|
|
64
|
+
}
|
|
65
|
+
if (minutes < 60) {
|
|
66
|
+
return `${minutes}m`;
|
|
67
|
+
}
|
|
68
|
+
const hours = Math.round(minutes / 60);
|
|
69
|
+
if (hours < 48) {
|
|
70
|
+
return `${hours}h`;
|
|
71
|
+
}
|
|
72
|
+
const days = Math.round(hours / 24);
|
|
73
|
+
return `${days}d`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isLikelyWorktreePath(value?: string): boolean {
|
|
77
|
+
const trimmed = value?.trim();
|
|
78
|
+
return Boolean(trimmed && /[/\\]worktrees[/\\][^/\\]+[/\\][^/\\]+/.test(trimmed));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatBinding(binding: StoredBinding | null): string {
|
|
82
|
+
if (!binding) {
|
|
83
|
+
return "No Codex binding for this conversation.";
|
|
84
|
+
}
|
|
85
|
+
return [
|
|
86
|
+
"Codex is bound to this conversation.",
|
|
87
|
+
`Thread: ${binding.threadId}`,
|
|
88
|
+
`Workspace: ${binding.workspaceDir}`,
|
|
89
|
+
binding.threadTitle ? `Title: ${binding.threadTitle}` : "",
|
|
90
|
+
"Plain text in this bound conversation routes to Codex.",
|
|
91
|
+
]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatThreadPicker(threads: ThreadSummary[]): string {
|
|
97
|
+
if (threads.length === 0) {
|
|
98
|
+
return "No matching Codex threads found.";
|
|
99
|
+
}
|
|
100
|
+
return [
|
|
101
|
+
"Choose a Codex thread:",
|
|
102
|
+
...threads.slice(0, 10).map((thread, index) => {
|
|
103
|
+
const age = formatDateAge(thread.updatedAt ?? thread.createdAt);
|
|
104
|
+
const parts = [
|
|
105
|
+
`${index + 1}. ${thread.title || thread.threadId}`,
|
|
106
|
+
age ? `updated ${age}` : "",
|
|
107
|
+
thread.projectKey ? `cwd ${thread.projectKey}` : "",
|
|
108
|
+
].filter(Boolean);
|
|
109
|
+
return parts.join(" - ");
|
|
110
|
+
}),
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatThreadButtonLabel(params: {
|
|
115
|
+
thread: ThreadSummary;
|
|
116
|
+
includeProjectSuffix: boolean;
|
|
117
|
+
isWorktree?: boolean;
|
|
118
|
+
hasChanges?: boolean;
|
|
119
|
+
maxLength?: number;
|
|
120
|
+
}): string {
|
|
121
|
+
const title = formatThreadButtonTitle(params.thread);
|
|
122
|
+
const projectBadge = params.includeProjectSuffix ? getProjectName(params.thread.projectKey) : undefined;
|
|
123
|
+
const projectSuffix = projectBadge ? ` (${projectBadge})` : "";
|
|
124
|
+
const ageSuffix = [
|
|
125
|
+
formatCompactAge(params.thread.updatedAt) ? `U:${formatCompactAge(params.thread.updatedAt)}` : undefined,
|
|
126
|
+
formatCompactAge(params.thread.createdAt) ? `C:${formatCompactAge(params.thread.createdAt)}` : undefined,
|
|
127
|
+
]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join(" ");
|
|
130
|
+
const iconPrefix = [
|
|
131
|
+
params.isWorktree ? "🌿" : undefined,
|
|
132
|
+
params.hasChanges ? "✏️" : undefined,
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(" ");
|
|
136
|
+
const maxLength = params.maxLength ?? 72;
|
|
137
|
+
const reservedLength =
|
|
138
|
+
(iconPrefix ? `${iconPrefix} `.length : 0) +
|
|
139
|
+
projectSuffix.length +
|
|
140
|
+
(ageSuffix ? ` ${ageSuffix}`.length : 0);
|
|
141
|
+
const titleBudget = Math.max(12, maxLength - reservedLength);
|
|
142
|
+
const clippedTitle = truncateMiddle(title, titleBudget);
|
|
143
|
+
return [iconPrefix, `${clippedTitle}${projectSuffix}`, ageSuffix].filter(Boolean).join(" ");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function formatThreadPickerIntro(params: {
|
|
147
|
+
page: number;
|
|
148
|
+
totalPages: number;
|
|
149
|
+
totalItems: number;
|
|
150
|
+
includeAll: boolean;
|
|
151
|
+
syncTopic?: boolean;
|
|
152
|
+
projectName?: string;
|
|
153
|
+
workspaceDir?: string;
|
|
154
|
+
}): string {
|
|
155
|
+
const pageLabel = `Page ${params.page + 1}/${params.totalPages}`;
|
|
156
|
+
const scopeLabel = params.projectName
|
|
157
|
+
? `Showing recent Codex sessions for ${params.projectName}.`
|
|
158
|
+
: params.includeAll
|
|
159
|
+
? "Showing recent Codex sessions across all projects."
|
|
160
|
+
: params.workspaceDir
|
|
161
|
+
? `Showing recent Codex sessions for ${getProjectName(params.workspaceDir) ?? "this project"}.`
|
|
162
|
+
: "Showing recent Codex sessions.";
|
|
163
|
+
return [
|
|
164
|
+
`${scopeLabel} ${pageLabel}.`,
|
|
165
|
+
"Legend: 🌿 worktree, ✏️ uncommitted changes, U updated, C created.",
|
|
166
|
+
params.syncTopic
|
|
167
|
+
? "Choosing a session will also try to sync the current channel/topic name."
|
|
168
|
+
: "",
|
|
169
|
+
`Tap a session to resume it. Use Projects to browse by project or \`--cwd /path/to/project\` to narrow to one workspace.`,
|
|
170
|
+
params.totalItems === 0 ? "No matching Codex threads found." : "",
|
|
171
|
+
]
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function formatProjectPickerIntro(params: {
|
|
177
|
+
page: number;
|
|
178
|
+
totalPages: number;
|
|
179
|
+
totalItems: number;
|
|
180
|
+
workspaceDir?: string;
|
|
181
|
+
}): string {
|
|
182
|
+
const scopeLabel = params.workspaceDir
|
|
183
|
+
? `Showing projects for ${getProjectName(params.workspaceDir) ?? "this workspace"}.`
|
|
184
|
+
: "Choose a project to filter recent Codex sessions.";
|
|
185
|
+
return [
|
|
186
|
+
`${scopeLabel} Page ${params.page + 1}/${params.totalPages}.`,
|
|
187
|
+
"Tap a project to show only that project's sessions. Use `--cwd /path/to/project` to target one exact workspace.",
|
|
188
|
+
params.totalItems === 0 ? "No Codex projects found." : "",
|
|
189
|
+
]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function formatThreadState(state: ThreadState, binding: StoredBinding | null): string {
|
|
195
|
+
return [
|
|
196
|
+
binding ? "Bound conversation status:" : "Codex thread status:",
|
|
197
|
+
`Thread: ${state.threadId}`,
|
|
198
|
+
state.threadName ? `Name: ${state.threadName}` : "",
|
|
199
|
+
state.model ? `Model: ${state.model}` : "",
|
|
200
|
+
state.serviceTier ? `Service tier: ${state.serviceTier}` : "Service tier: default",
|
|
201
|
+
state.cwd ? `Workspace: ${state.cwd}` : binding ? `Workspace: ${binding.workspaceDir}` : "",
|
|
202
|
+
state.approvalPolicy ? `Permissions: ${state.approvalPolicy}` : "",
|
|
203
|
+
state.sandbox ? `Sandbox: ${state.sandbox}` : "",
|
|
204
|
+
"Plain text in this bound conversation routes to Codex.",
|
|
205
|
+
]
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function shortenHomePath(value?: string): string | undefined {
|
|
211
|
+
const trimmed = value?.trim();
|
|
212
|
+
if (!trimmed) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const home = os.homedir().trim();
|
|
216
|
+
if (!home) {
|
|
217
|
+
return trimmed;
|
|
218
|
+
}
|
|
219
|
+
if (trimmed === home) {
|
|
220
|
+
return "~";
|
|
221
|
+
}
|
|
222
|
+
if (trimmed.startsWith(`${home}/`)) {
|
|
223
|
+
return `~/${trimmed.slice(home.length + 1)}`;
|
|
224
|
+
}
|
|
225
|
+
return trimmed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatTokenCount(value?: number): string {
|
|
229
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
230
|
+
return "0";
|
|
231
|
+
}
|
|
232
|
+
const safe = Math.max(0, value);
|
|
233
|
+
if (safe >= 1_000_000) {
|
|
234
|
+
return `${(safe / 1_000_000).toFixed(1)}m`;
|
|
235
|
+
}
|
|
236
|
+
if (safe >= 1_000) {
|
|
237
|
+
const precision = safe >= 10_000 ? 0 : 1;
|
|
238
|
+
const formattedThousands = (safe / 1_000).toFixed(precision);
|
|
239
|
+
if (Number(formattedThousands) >= 1_000) {
|
|
240
|
+
return `${(safe / 1_000_000).toFixed(1)}m`;
|
|
241
|
+
}
|
|
242
|
+
return `${formattedThousands}k`;
|
|
243
|
+
}
|
|
244
|
+
return String(Math.round(safe));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function formatCodexPermissions(params: {
|
|
248
|
+
approvalPolicy?: string;
|
|
249
|
+
sandbox?: string;
|
|
250
|
+
}): string | undefined {
|
|
251
|
+
const approval = params.approvalPolicy?.trim();
|
|
252
|
+
const sandbox = params.sandbox?.trim();
|
|
253
|
+
if (!approval && !sandbox) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
if (approval === "on-request" && sandbox === "workspace-write") {
|
|
257
|
+
return "Default";
|
|
258
|
+
}
|
|
259
|
+
if (approval === "never" && sandbox === "danger-full-access") {
|
|
260
|
+
return "Full Access";
|
|
261
|
+
}
|
|
262
|
+
if (approval && sandbox) {
|
|
263
|
+
return `Custom (${sandbox}, ${approval})`;
|
|
264
|
+
}
|
|
265
|
+
return approval ?? sandbox;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function formatCodexAccountText(account: AccountSummary | null | undefined): string {
|
|
269
|
+
if (!account) {
|
|
270
|
+
return "unknown";
|
|
271
|
+
}
|
|
272
|
+
if (account.type === "chatgpt" && account.email?.trim()) {
|
|
273
|
+
return account.planType?.trim()
|
|
274
|
+
? `${account.email.trim()} (${account.planType.trim()})`
|
|
275
|
+
: account.email.trim();
|
|
276
|
+
}
|
|
277
|
+
if (account.type === "apiKey") {
|
|
278
|
+
return "API key";
|
|
279
|
+
}
|
|
280
|
+
if (account.requiresOpenaiAuth === false) {
|
|
281
|
+
return "not required";
|
|
282
|
+
}
|
|
283
|
+
if (account.requiresOpenaiAuth === true) {
|
|
284
|
+
return "not signed in";
|
|
285
|
+
}
|
|
286
|
+
return "unknown";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function formatCodexModelText(threadState: ThreadState | undefined): string {
|
|
290
|
+
const model = threadState?.model?.trim();
|
|
291
|
+
const provider = threadState?.modelProvider?.trim();
|
|
292
|
+
const reasoning = threadState?.reasoningEffort?.trim();
|
|
293
|
+
const parts = [
|
|
294
|
+
provider && model && !model.startsWith(`${provider}/`) ? `${provider}/${model}` : model,
|
|
295
|
+
].filter(Boolean) as string[];
|
|
296
|
+
if (reasoning) {
|
|
297
|
+
parts.push(`reasoning ${reasoning}`);
|
|
298
|
+
}
|
|
299
|
+
return parts.join(" · ") || "unknown";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function formatCodexFastModeValue(value: string | undefined): string {
|
|
303
|
+
const normalized = value?.trim().toLowerCase();
|
|
304
|
+
if (!normalized) {
|
|
305
|
+
return "off";
|
|
306
|
+
}
|
|
307
|
+
if (normalized === "default" || normalized === "auto") {
|
|
308
|
+
return "off";
|
|
309
|
+
}
|
|
310
|
+
if (normalized === "fast" || normalized === "priority") {
|
|
311
|
+
return "on";
|
|
312
|
+
}
|
|
313
|
+
return normalized;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function advanceCodexResetAtToNextWindow(params: {
|
|
317
|
+
resetAt: number | undefined;
|
|
318
|
+
windowSeconds?: number;
|
|
319
|
+
nowMs: number;
|
|
320
|
+
}): number | undefined {
|
|
321
|
+
const resetAt = params.resetAt;
|
|
322
|
+
if (!resetAt || !Number.isFinite(resetAt)) {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
if (
|
|
326
|
+
!params.windowSeconds ||
|
|
327
|
+
!Number.isFinite(params.windowSeconds) ||
|
|
328
|
+
params.windowSeconds <= 0
|
|
329
|
+
) {
|
|
330
|
+
return resetAt;
|
|
331
|
+
}
|
|
332
|
+
const windowMs = Math.round(params.windowSeconds * 1_000);
|
|
333
|
+
if (windowMs <= 0 || resetAt >= params.nowMs) {
|
|
334
|
+
return resetAt;
|
|
335
|
+
}
|
|
336
|
+
const missedWindows = Math.floor((params.nowMs - resetAt) / windowMs) + 1;
|
|
337
|
+
return resetAt + missedWindows * windowMs;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function getCodexStatusTimeZoneLabel(): string | undefined {
|
|
341
|
+
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone?.trim();
|
|
342
|
+
return timeZone || undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function formatCodexRateLimitReset(params: {
|
|
346
|
+
resetAt: number | undefined;
|
|
347
|
+
windowSeconds?: number;
|
|
348
|
+
nowMs?: number;
|
|
349
|
+
}): string | undefined {
|
|
350
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
351
|
+
const normalizedResetAt = advanceCodexResetAtToNextWindow({
|
|
352
|
+
resetAt: params.resetAt,
|
|
353
|
+
windowSeconds: params.windowSeconds,
|
|
354
|
+
nowMs,
|
|
355
|
+
});
|
|
356
|
+
if (!normalizedResetAt || !Number.isFinite(normalizedResetAt)) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
const now = new Date(nowMs);
|
|
360
|
+
const date = new Date(normalizedResetAt);
|
|
361
|
+
if (Number.isNaN(date.getTime())) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const sameDay = now.toDateString() === date.toDateString();
|
|
365
|
+
if (sameDay) {
|
|
366
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
367
|
+
hour: "numeric",
|
|
368
|
+
minute: "2-digit",
|
|
369
|
+
}).format(date);
|
|
370
|
+
}
|
|
371
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
372
|
+
month: "short",
|
|
373
|
+
day: "numeric",
|
|
374
|
+
}).format(date);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function formatCodexRateLimitLine(
|
|
378
|
+
limit: RateLimitSummary,
|
|
379
|
+
nowMs = Date.now(),
|
|
380
|
+
): string {
|
|
381
|
+
const prefix = `${limit.name}: `;
|
|
382
|
+
const resetText = formatCodexRateLimitReset({
|
|
383
|
+
resetAt: limit.resetAt,
|
|
384
|
+
windowSeconds: limit.windowSeconds,
|
|
385
|
+
nowMs,
|
|
386
|
+
});
|
|
387
|
+
if (typeof limit.usedPercent === "number") {
|
|
388
|
+
const remaining = Math.max(0, Math.round(100 - limit.usedPercent));
|
|
389
|
+
return `${prefix}${remaining}% left${resetText ? ` (resets ${resetText})` : ""}`;
|
|
390
|
+
}
|
|
391
|
+
if (typeof limit.remaining === "number" && typeof limit.limit === "number") {
|
|
392
|
+
return `${prefix}${limit.remaining}/${limit.limit} remaining${resetText ? ` (resets ${resetText})` : ""}`;
|
|
393
|
+
}
|
|
394
|
+
return `${prefix}unavailable`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function splitCodexRateLimitName(name: string): {
|
|
398
|
+
prefix: string;
|
|
399
|
+
label: string;
|
|
400
|
+
labelOrder: number;
|
|
401
|
+
} {
|
|
402
|
+
const trimmed = name.trim();
|
|
403
|
+
const lower = trimmed.toLowerCase();
|
|
404
|
+
if (lower.endsWith("5h limit")) {
|
|
405
|
+
const prefix = trimmed.slice(0, Math.max(0, trimmed.length - "5h limit".length)).trim();
|
|
406
|
+
return { prefix, label: "5h limit", labelOrder: 0 };
|
|
407
|
+
}
|
|
408
|
+
if (lower.endsWith("weekly limit")) {
|
|
409
|
+
const prefix = trimmed.slice(0, Math.max(0, trimmed.length - "weekly limit".length)).trim();
|
|
410
|
+
return { prefix, label: "Weekly limit", labelOrder: 1 };
|
|
411
|
+
}
|
|
412
|
+
return { prefix: "", label: trimmed, labelOrder: 99 };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function normalizeCodexModelKey(value: string | undefined): string {
|
|
416
|
+
const trimmed = value?.trim().toLowerCase() ?? "";
|
|
417
|
+
const withoutProvider = trimmed.includes("/") ? (trimmed.split("/").at(-1) ?? trimmed) : trimmed;
|
|
418
|
+
return withoutProvider.replace(/[^a-z0-9]+/g, "");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function selectVisibleCodexRateLimits(params: {
|
|
422
|
+
rateLimits: RateLimitSummary[];
|
|
423
|
+
currentModel?: string;
|
|
424
|
+
}): RateLimitSummary[] {
|
|
425
|
+
const currentModelKey = normalizeCodexModelKey(params.currentModel);
|
|
426
|
+
return [...params.rateLimits]
|
|
427
|
+
.filter((limit) => {
|
|
428
|
+
const { prefix } = splitCodexRateLimitName(limit.name);
|
|
429
|
+
if (!prefix) {
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
if (!currentModelKey) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
return normalizeCodexModelKey(prefix) === currentModelKey;
|
|
436
|
+
})
|
|
437
|
+
.toSorted((left, right) => {
|
|
438
|
+
const leftName = splitCodexRateLimitName(left.name);
|
|
439
|
+
const rightName = splitCodexRateLimitName(right.name);
|
|
440
|
+
const leftPrefixBlank = leftName.prefix ? 1 : 0;
|
|
441
|
+
const rightPrefixBlank = rightName.prefix ? 1 : 0;
|
|
442
|
+
if (leftPrefixBlank !== rightPrefixBlank) {
|
|
443
|
+
return leftPrefixBlank - rightPrefixBlank;
|
|
444
|
+
}
|
|
445
|
+
const prefixCompare = leftName.prefix.localeCompare(rightName.prefix);
|
|
446
|
+
if (prefixCompare !== 0) {
|
|
447
|
+
return prefixCompare;
|
|
448
|
+
}
|
|
449
|
+
if (leftName.labelOrder !== rightName.labelOrder) {
|
|
450
|
+
return leftName.labelOrder - rightName.labelOrder;
|
|
451
|
+
}
|
|
452
|
+
return left.name.localeCompare(right.name);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function formatCodexContextUsageSnapshot(
|
|
457
|
+
usage?: ContextUsageSnapshot,
|
|
458
|
+
): string | undefined {
|
|
459
|
+
if (!usage) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
const totalTokens = usage.totalTokens;
|
|
463
|
+
const contextWindow = usage.contextWindow;
|
|
464
|
+
if (typeof totalTokens !== "number") {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
const totalLabel = formatTokenCount(totalTokens);
|
|
468
|
+
const contextLabel = typeof contextWindow === "number" ? formatTokenCount(contextWindow) : "?";
|
|
469
|
+
const percentFull =
|
|
470
|
+
typeof totalTokens === "number" && typeof contextWindow === "number" && contextWindow > 0
|
|
471
|
+
? Math.max(0, Math.min(100, Math.round((totalTokens / contextWindow) * 100)))
|
|
472
|
+
: undefined;
|
|
473
|
+
const extras: string[] = [];
|
|
474
|
+
if (typeof percentFull === "number") {
|
|
475
|
+
extras.push(`${percentFull}% full`);
|
|
476
|
+
}
|
|
477
|
+
return `${totalLabel} / ${contextLabel} tokens used${
|
|
478
|
+
extras.length > 0 ? ` (${extras.join(", ")})` : ""
|
|
479
|
+
}`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function formatCodexStatusText(params: {
|
|
483
|
+
threadState?: ThreadState;
|
|
484
|
+
account?: AccountSummary | null;
|
|
485
|
+
rateLimits: RateLimitSummary[];
|
|
486
|
+
projectFolder?: string;
|
|
487
|
+
worktreeFolder?: string;
|
|
488
|
+
bindingActive?: boolean;
|
|
489
|
+
contextUsage?: ContextUsageSnapshot;
|
|
490
|
+
planMode?: boolean;
|
|
491
|
+
}): string {
|
|
492
|
+
const lines = ["OpenAI Codex"];
|
|
493
|
+
lines.push(`Binding: ${params.bindingActive ? "active" : "none"}`);
|
|
494
|
+
if (params.threadState?.threadName?.trim()) {
|
|
495
|
+
lines.push(`Thread: ${params.threadState.threadName.trim()}`);
|
|
496
|
+
}
|
|
497
|
+
if (params.threadState) {
|
|
498
|
+
lines.push(`Model: ${formatCodexModelText(params.threadState)}`);
|
|
499
|
+
}
|
|
500
|
+
lines.push(`Project folder: ${shortenHomePath(params.projectFolder) ?? "unknown"}`);
|
|
501
|
+
lines.push(`Worktree folder: ${shortenHomePath(params.worktreeFolder) ?? "unknown"}`);
|
|
502
|
+
if (params.threadState || params.bindingActive) {
|
|
503
|
+
lines.push(`Fast mode: ${formatCodexFastModeValue(params.threadState?.serviceTier)}`);
|
|
504
|
+
}
|
|
505
|
+
if (params.bindingActive && params.planMode !== undefined) {
|
|
506
|
+
lines.push(`Plan mode: ${params.planMode ? "on" : "off"}`);
|
|
507
|
+
}
|
|
508
|
+
const contextUsageText = formatCodexContextUsageSnapshot(params.contextUsage);
|
|
509
|
+
if (contextUsageText) {
|
|
510
|
+
lines.push(`Context usage: ${contextUsageText}`);
|
|
511
|
+
} else if (params.bindingActive) {
|
|
512
|
+
lines.push("Context usage: unavailable until Codex emits a token-usage update");
|
|
513
|
+
}
|
|
514
|
+
const permissions = formatCodexPermissions({
|
|
515
|
+
approvalPolicy: params.threadState?.approvalPolicy,
|
|
516
|
+
sandbox: params.threadState?.sandbox,
|
|
517
|
+
});
|
|
518
|
+
if (permissions) {
|
|
519
|
+
lines.push(`Permissions: ${permissions}`);
|
|
520
|
+
}
|
|
521
|
+
lines.push(`Account: ${formatCodexAccountText(params.account)}`);
|
|
522
|
+
const sessionId = params.threadState?.threadId?.trim();
|
|
523
|
+
if (sessionId) {
|
|
524
|
+
lines.push(`Session: ${sessionId}`);
|
|
525
|
+
}
|
|
526
|
+
const visibleRateLimits = selectVisibleCodexRateLimits({
|
|
527
|
+
rateLimits: params.rateLimits,
|
|
528
|
+
currentModel: params.threadState?.model,
|
|
529
|
+
});
|
|
530
|
+
if (visibleRateLimits.length > 0) {
|
|
531
|
+
const timeZoneLabel = getCodexStatusTimeZoneLabel();
|
|
532
|
+
lines.push("");
|
|
533
|
+
if (timeZoneLabel) {
|
|
534
|
+
lines.push(`Rate limits timezone: ${timeZoneLabel}`);
|
|
535
|
+
}
|
|
536
|
+
for (const limit of visibleRateLimits) {
|
|
537
|
+
lines.push(formatCodexRateLimitLine(limit));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return lines.join("\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function formatBoundThreadSummary(params: {
|
|
544
|
+
binding: StoredBinding;
|
|
545
|
+
state?: ThreadState;
|
|
546
|
+
}): string {
|
|
547
|
+
const workspacePath = params.state?.cwd?.trim() || params.binding.workspaceDir;
|
|
548
|
+
const projectName =
|
|
549
|
+
getProjectName(workspacePath) ||
|
|
550
|
+
getProjectName(params.binding.workspaceDir) ||
|
|
551
|
+
"Unknown";
|
|
552
|
+
const threadName =
|
|
553
|
+
params.state?.threadName?.trim() ||
|
|
554
|
+
params.binding.threadTitle?.trim();
|
|
555
|
+
const parts = [
|
|
556
|
+
"Codex thread bound.",
|
|
557
|
+
`Project: ${projectName}`,
|
|
558
|
+
threadName ? `Thread Name: ${threadName}` : "",
|
|
559
|
+
`Thread ID: ${params.binding.threadId}`,
|
|
560
|
+
isLikelyWorktreePath(workspacePath) ? `Worktree Path: ${workspacePath}` : "",
|
|
561
|
+
!isLikelyWorktreePath(workspacePath) && workspacePath ? `Project Path: ${workspacePath}` : "",
|
|
562
|
+
].filter(Boolean);
|
|
563
|
+
return parts.join("\n");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function formatAccountSummary(account: AccountSummary, limits: RateLimitSummary[]): string {
|
|
567
|
+
const lines = ["Codex account:"];
|
|
568
|
+
if (account.email) {
|
|
569
|
+
lines.push(`Email: ${account.email}`);
|
|
570
|
+
}
|
|
571
|
+
if (account.planType) {
|
|
572
|
+
lines.push(`Plan: ${account.planType}`);
|
|
573
|
+
}
|
|
574
|
+
if (account.type) {
|
|
575
|
+
lines.push(`Auth: ${account.type}`);
|
|
576
|
+
}
|
|
577
|
+
if (account.requiresOpenaiAuth) {
|
|
578
|
+
lines.push("OpenAI auth required.");
|
|
579
|
+
}
|
|
580
|
+
if (limits.length > 0) {
|
|
581
|
+
lines.push("", "Rate limits:");
|
|
582
|
+
for (const limit of limits.slice(0, 6)) {
|
|
583
|
+
const parts = [
|
|
584
|
+
limit.name,
|
|
585
|
+
typeof limit.usedPercent === "number" ? `${limit.usedPercent}% used` : "",
|
|
586
|
+
typeof limit.remaining === "number" ? `${limit.remaining}% remaining` : "",
|
|
587
|
+
].filter(Boolean);
|
|
588
|
+
lines.push(`- ${parts.join(" - ")}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return lines.join("\n");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function formatModels(models: ModelSummary[], state?: ThreadState): string {
|
|
595
|
+
if (models.length === 0) {
|
|
596
|
+
return state?.model ? `Current model: ${state.model}` : "No Codex models reported.";
|
|
597
|
+
}
|
|
598
|
+
const currentModel = models.find((model) => model.current)?.id || state?.model;
|
|
599
|
+
const lines = [];
|
|
600
|
+
if (currentModel) {
|
|
601
|
+
lines.push(`Current model: ${currentModel}`);
|
|
602
|
+
}
|
|
603
|
+
lines.push(
|
|
604
|
+
"Available models:",
|
|
605
|
+
...models.slice(0, 20).map((model) => {
|
|
606
|
+
const current = model.current || model.id === state?.model ? " (current)" : "";
|
|
607
|
+
return `- ${model.id}${current}`;
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
return lines.join("\n");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function formatSkills(params: {
|
|
614
|
+
workspaceDir: string;
|
|
615
|
+
skills: SkillSummary[];
|
|
616
|
+
filter?: string;
|
|
617
|
+
}): string {
|
|
618
|
+
const filter = params.filter?.trim().toLowerCase();
|
|
619
|
+
const skills = filter
|
|
620
|
+
? params.skills.filter((skill) => {
|
|
621
|
+
const haystack = [skill.name, skill.description, skill.cwd].filter(Boolean).join("\n");
|
|
622
|
+
return haystack.toLowerCase().includes(filter);
|
|
623
|
+
})
|
|
624
|
+
: params.skills;
|
|
625
|
+
const lines = [`Codex skills for ${params.workspaceDir}:`];
|
|
626
|
+
if (skills.length === 0) {
|
|
627
|
+
lines.push(filter ? `No Codex skills matched "${params.filter?.trim()}".` : "No Codex skills found.");
|
|
628
|
+
return lines.join("\n");
|
|
629
|
+
}
|
|
630
|
+
for (const skill of skills.slice(0, 20)) {
|
|
631
|
+
const suffix = skill.description?.trim() ? ` - ${skill.description.trim()}` : "";
|
|
632
|
+
const state =
|
|
633
|
+
skill.enabled === false ? " (disabled)" : skill.enabled === true ? "" : " (status unknown)";
|
|
634
|
+
lines.push(`- ${skill.name}${state}${suffix}`);
|
|
635
|
+
}
|
|
636
|
+
if (skills.length > 20) {
|
|
637
|
+
lines.push(`- …and ${skills.length - 20} more`);
|
|
638
|
+
}
|
|
639
|
+
return lines.join("\n");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function formatExperimentalFeatures(features: ExperimentalFeatureSummary[]): string {
|
|
643
|
+
if (features.length === 0) {
|
|
644
|
+
return "No Codex experimental features reported.";
|
|
645
|
+
}
|
|
646
|
+
return [
|
|
647
|
+
"Codex experimental features:",
|
|
648
|
+
...features.slice(0, 30).map((feature) =>
|
|
649
|
+
`- ${feature.displayName || feature.name}${feature.enabled ? " (enabled)" : ""}`,
|
|
650
|
+
),
|
|
651
|
+
].join("\n");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function formatMcpServers(params: {
|
|
655
|
+
servers: McpServerSummary[];
|
|
656
|
+
filter?: string;
|
|
657
|
+
}): string {
|
|
658
|
+
const filter = params.filter?.trim().toLowerCase();
|
|
659
|
+
const servers = filter
|
|
660
|
+
? params.servers.filter((server) => {
|
|
661
|
+
const haystack = [server.name, server.authStatus].filter(Boolean).join("\n");
|
|
662
|
+
return haystack.toLowerCase().includes(filter);
|
|
663
|
+
})
|
|
664
|
+
: params.servers;
|
|
665
|
+
const lines = ["Codex MCP servers:"];
|
|
666
|
+
if (servers.length === 0) {
|
|
667
|
+
lines.push(filter ? `No MCP servers matched "${params.filter?.trim()}".` : "No MCP servers reported.");
|
|
668
|
+
return lines.join("\n");
|
|
669
|
+
}
|
|
670
|
+
for (const server of servers.slice(0, 20)) {
|
|
671
|
+
const details = [
|
|
672
|
+
server.authStatus ? `auth=${server.authStatus}` : undefined,
|
|
673
|
+
`tools=${server.toolCount}`,
|
|
674
|
+
`resources=${server.resourceCount}`,
|
|
675
|
+
`templates=${server.resourceTemplateCount}`,
|
|
676
|
+
].filter(Boolean);
|
|
677
|
+
lines.push(`- ${server.name} · ${details.join(" · ")}`);
|
|
678
|
+
}
|
|
679
|
+
if (servers.length > 20) {
|
|
680
|
+
lines.push(`- …and ${servers.length - 20} more`);
|
|
681
|
+
}
|
|
682
|
+
return lines.join("\n");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function formatThreadReplay(replay: ThreadReplay): string {
|
|
686
|
+
return [
|
|
687
|
+
replay.lastUserMessage ? `Last user:\n${replay.lastUserMessage}` : "",
|
|
688
|
+
replay.lastAssistantMessage ? `Last assistant:\n${replay.lastAssistantMessage}` : "",
|
|
689
|
+
]
|
|
690
|
+
.filter(Boolean)
|
|
691
|
+
.join("\n\n");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function formatTurnCompletion(result: TurnResult): string {
|
|
695
|
+
if (result.planArtifact?.markdown) {
|
|
696
|
+
return result.planArtifact.markdown;
|
|
697
|
+
}
|
|
698
|
+
if (result.text?.trim()) {
|
|
699
|
+
return result.text.trim();
|
|
700
|
+
}
|
|
701
|
+
if (result.aborted) {
|
|
702
|
+
return "Codex turn stopped.";
|
|
703
|
+
}
|
|
704
|
+
return "Codex completed without a text reply.";
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function formatReviewCompletion(result: ReviewResult): string {
|
|
708
|
+
return result.reviewText.trim() || (result.aborted ? "Codex review stopped." : "Codex review completed.");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export type ParsedReviewFinding = {
|
|
712
|
+
priorityLabel?: string;
|
|
713
|
+
title: string;
|
|
714
|
+
location?: string;
|
|
715
|
+
body?: string;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
export function parseCodexReviewOutput(text: string): {
|
|
719
|
+
summary?: string;
|
|
720
|
+
findings: ParsedReviewFinding[];
|
|
721
|
+
} {
|
|
722
|
+
const lines = text.trim().split(/\r?\n/);
|
|
723
|
+
const findings: ParsedReviewFinding[] = [];
|
|
724
|
+
const summaryLines: string[] = [];
|
|
725
|
+
const findingRe =
|
|
726
|
+
/^-?\s*(?:\[(?<priority>P\d)\]\s*)?(?<title>.+?)(?:\s+Location:\s*(?<location>.+))?$/i;
|
|
727
|
+
let current: ParsedReviewFinding | null = null;
|
|
728
|
+
let inFindings = false;
|
|
729
|
+
for (const line of lines) {
|
|
730
|
+
const trimmed = line.trimEnd();
|
|
731
|
+
if (!trimmed) {
|
|
732
|
+
if (!inFindings && summaryLines.at(-1) !== "") {
|
|
733
|
+
summaryLines.push("");
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const match = trimmed.match(findingRe);
|
|
738
|
+
const looksLikeFinding =
|
|
739
|
+
(trimmed.startsWith("[P") || trimmed.startsWith("- [P")) &&
|
|
740
|
+
Boolean(match?.groups?.title?.trim());
|
|
741
|
+
if (looksLikeFinding) {
|
|
742
|
+
inFindings = true;
|
|
743
|
+
if (current) {
|
|
744
|
+
findings.push(current);
|
|
745
|
+
}
|
|
746
|
+
current = {
|
|
747
|
+
priorityLabel: match?.groups?.priority?.toUpperCase(),
|
|
748
|
+
title: match?.groups?.title?.trim() ?? trimmed,
|
|
749
|
+
location: match?.groups?.location?.trim() || undefined,
|
|
750
|
+
};
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (!inFindings) {
|
|
754
|
+
summaryLines.push(trimmed);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (!current) {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
current.body = current.body ? `${current.body}\n${trimmed}` : trimmed;
|
|
761
|
+
}
|
|
762
|
+
if (current) {
|
|
763
|
+
findings.push(current);
|
|
764
|
+
}
|
|
765
|
+
const summary = summaryLines.join("\n").trim() || undefined;
|
|
766
|
+
return { summary, findings };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export function formatCodexReviewFindingMessage(params: {
|
|
770
|
+
finding: ParsedReviewFinding;
|
|
771
|
+
index: number;
|
|
772
|
+
}): string {
|
|
773
|
+
const heading = params.finding.priorityLabel ?? `Finding ${params.index + 1}`;
|
|
774
|
+
const lines = [heading, params.finding.title];
|
|
775
|
+
if (params.finding.location) {
|
|
776
|
+
lines.push(`Location: ${params.finding.location}`);
|
|
777
|
+
}
|
|
778
|
+
if (params.finding.body?.trim()) {
|
|
779
|
+
lines.push("", params.finding.body.trim());
|
|
780
|
+
}
|
|
781
|
+
return lines.join("\n");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function formatCodexPlanSteps(
|
|
785
|
+
steps: TurnResult["planArtifact"] extends infer T ? (T extends { steps: infer S } ? S : never) : never,
|
|
786
|
+
): string | undefined {
|
|
787
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
const lines = ["Plan steps:"];
|
|
791
|
+
for (const step of steps) {
|
|
792
|
+
const marker =
|
|
793
|
+
step.status === "completed" ? "[x]" : step.status === "inProgress" ? "[>]" : "[ ]";
|
|
794
|
+
lines.push(`- ${marker} ${step.step}`);
|
|
795
|
+
}
|
|
796
|
+
return lines.join("\n");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export function formatCodexPlanInlineText(plan: NonNullable<TurnResult["planArtifact"]>): string {
|
|
800
|
+
const lines: string[] = ["Plan"];
|
|
801
|
+
if (plan.explanation?.trim()) {
|
|
802
|
+
lines.push("", plan.explanation.trim());
|
|
803
|
+
}
|
|
804
|
+
const stepsText = formatCodexPlanSteps(plan.steps);
|
|
805
|
+
if (stepsText) {
|
|
806
|
+
lines.push("", stepsText);
|
|
807
|
+
}
|
|
808
|
+
if (plan.markdown.trim()) {
|
|
809
|
+
lines.push("", plan.markdown.trim());
|
|
810
|
+
}
|
|
811
|
+
return lines.join("\n").trim();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function buildCodexPlanMarkdownPreview(
|
|
815
|
+
markdown: string,
|
|
816
|
+
maxChars = 1400,
|
|
817
|
+
): string | undefined {
|
|
818
|
+
const trimmed = markdown.trim();
|
|
819
|
+
if (!trimmed) {
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
if (trimmed.length <= maxChars) {
|
|
823
|
+
return trimmed;
|
|
824
|
+
}
|
|
825
|
+
return `${trimmed.slice(0, maxChars).trimEnd()}\n\n[Preview truncated. Open the attachment for the full plan.]`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function formatCodexPlanAttachmentSummary(
|
|
829
|
+
plan: NonNullable<TurnResult["planArtifact"]>,
|
|
830
|
+
): string {
|
|
831
|
+
const lines = ["Plan ready."];
|
|
832
|
+
if (plan.explanation?.trim()) {
|
|
833
|
+
lines.push("", plan.explanation.trim());
|
|
834
|
+
}
|
|
835
|
+
const stepsText = formatCodexPlanSteps(plan.steps);
|
|
836
|
+
if (stepsText) {
|
|
837
|
+
lines.push("", stepsText);
|
|
838
|
+
}
|
|
839
|
+
const summaryPreview = buildCodexPlanMarkdownPreview(plan.markdown, 1400);
|
|
840
|
+
if (summaryPreview) {
|
|
841
|
+
lines.push("", "Plan preview:", "", summaryPreview);
|
|
842
|
+
}
|
|
843
|
+
return lines.join("\n").trim();
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function formatCodexPlanAttachmentFallback(
|
|
847
|
+
plan: NonNullable<TurnResult["planArtifact"]>,
|
|
848
|
+
): string {
|
|
849
|
+
const lines = [
|
|
850
|
+
"I couldn't attach the full Markdown plan here, so here's a condensed inline summary instead.",
|
|
851
|
+
];
|
|
852
|
+
if (plan.explanation?.trim()) {
|
|
853
|
+
lines.push("", plan.explanation.trim());
|
|
854
|
+
}
|
|
855
|
+
const stepsText = formatCodexPlanSteps(plan.steps);
|
|
856
|
+
if (stepsText) {
|
|
857
|
+
lines.push("", stepsText);
|
|
858
|
+
}
|
|
859
|
+
const markdownPreview = plan.markdown.trim();
|
|
860
|
+
if (markdownPreview) {
|
|
861
|
+
const maxPreviewChars = 1800;
|
|
862
|
+
const preview =
|
|
863
|
+
markdownPreview.length > maxPreviewChars
|
|
864
|
+
? `${markdownPreview.slice(0, maxPreviewChars).trimEnd()}\n\n[Truncated]`
|
|
865
|
+
: markdownPreview;
|
|
866
|
+
lines.push("", preview);
|
|
867
|
+
}
|
|
868
|
+
return lines.join("\n").trim();
|
|
869
|
+
}
|