codemax-cli 0.0.0-stage → 0.1.3

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 (56) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +27 -0
  3. package/README.md +163 -2
  4. package/TRADEMARKS.md +27 -0
  5. package/bin/codemax +2 -0
  6. package/bin/codemax.cjs +78 -0
  7. package/package.json +72 -4
  8. package/src/adapters/claude.ts +43 -0
  9. package/src/adapters/codex.ts +101 -0
  10. package/src/adapters/detect.ts +16 -0
  11. package/src/adapters/index.ts +21 -0
  12. package/src/adapters/simple.ts +89 -0
  13. package/src/adapters/types.ts +87 -0
  14. package/src/chat/app.ts +1934 -0
  15. package/src/chat/logo.ts +37 -0
  16. package/src/chat/markdown.ts +77 -0
  17. package/src/chat/palette.ts +179 -0
  18. package/src/chat/permission.ts +111 -0
  19. package/src/chat/theme.ts +158 -0
  20. package/src/chat/transcript.ts +342 -0
  21. package/src/cli.ts +254 -0
  22. package/src/config.ts +114 -0
  23. package/src/core/awake.ts +345 -0
  24. package/src/core/brief.ts +314 -0
  25. package/src/core/budget.ts +42 -0
  26. package/src/core/checkpoint.ts +139 -0
  27. package/src/core/doctor.ts +67 -0
  28. package/src/core/files.ts +62 -0
  29. package/src/core/git.ts +36 -0
  30. package/src/core/hotkey.ts +75 -0
  31. package/src/core/journal.ts +215 -0
  32. package/src/core/ledger.ts +153 -0
  33. package/src/core/menu.ts +134 -0
  34. package/src/core/orchestrator.ts +352 -0
  35. package/src/core/paths.ts +30 -0
  36. package/src/core/prices-live.ts +123 -0
  37. package/src/core/pricing.ts +162 -0
  38. package/src/core/pty.ts +99 -0
  39. package/src/core/quota.ts +55 -0
  40. package/src/core/review.ts +67 -0
  41. package/src/core/spawn.ts +20 -0
  42. package/src/core/terminal.ts +51 -0
  43. package/src/core/update.ts +90 -0
  44. package/src/core/usage.ts +116 -0
  45. package/src/engines/acp.ts +419 -0
  46. package/src/engines/codex.ts +330 -0
  47. package/src/engines/env.ts +17 -0
  48. package/src/engines/errors.ts +47 -0
  49. package/src/engines/index.ts +154 -0
  50. package/src/engines/types.ts +131 -0
  51. package/src/transcripts/claude-usage.ts +63 -0
  52. package/src/transcripts/claude.ts +69 -0
  53. package/src/transcripts/codex.ts +74 -0
  54. package/src/tui/app.ts +377 -0
  55. package/src/tui/menu.ts +126 -0
  56. package/src/tui/pane.ts +79 -0
@@ -0,0 +1,419 @@
1
+ import { ClientSideConnection, ndJsonStream, type Client, type Stream } from "@agentclientprotocol/sdk";
2
+ import type { AgentId } from "../adapters/types.ts";
3
+ import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason } from "./types.ts";
4
+ import { agentEnv } from "./env.ts";
5
+ import { platformArgv } from "../core/spawn.ts";
6
+ import { classifyError } from "./errors.ts";
7
+
8
+ export interface UsageSample {
9
+ model?: string;
10
+ input: number;
11
+ output: number;
12
+ cacheRead: number;
13
+ cacheWrite: number;
14
+ costUsd?: number;
15
+ }
16
+
17
+ export interface AcpEngineOptions {
18
+ agent: AgentId;
19
+ name: string;
20
+ /** Command that starts the ACP agent over stdio. */
21
+ cmd: string[];
22
+ env?: Record<string, string>;
23
+ /** Pre-built stream (tests inject an in-process agent). When set, `cmd` is not spawned. */
24
+ stream?: Stream;
25
+ /** False when the agent is known to auto-approve tools over ACP (surfaced to the user). */
26
+ permissionPrompts?: boolean;
27
+ /**
28
+ * Agents that do not report usage over ACP (Claude Code, Grok) keep it in
29
+ * session files. This reads what was appended since the last call.
30
+ */
31
+ usageTail?: (session: EngineSessionRef) => { read(): UsageSample[] };
32
+ }
33
+
34
+ /**
35
+ * Generic Agent Client Protocol engine. Works for any agent that speaks ACP
36
+ * over stdio: Claude Code (via the Zed adapter), OpenCode, Grok, Gemini CLI.
37
+ */
38
+ export class AcpEngine implements Engine {
39
+ readonly agent: AgentId;
40
+ info: EngineInfo | null = null;
41
+ session: EngineSessionRef | null = null;
42
+ private listeners = new Set<(e: EngineEvent) => void>();
43
+ private conn: ClientSideConnection | null = null;
44
+ private proc: Bun.Subprocess | null = null;
45
+ private pendingPermissions = new Map<string, (optionId: string | null) => void>();
46
+ private permSeq = 0;
47
+ private turnActive = false;
48
+ private tail: { read(): UsageSample[] } | null = null;
49
+ private lastAgentCost = 0;
50
+ /** Agents that expose model/mode as ACP config options (OpenCode) are switched through them. */
51
+ private configIds: { model?: string; mode?: string } = {};
52
+ /** Context fill from the latest usage_update this turn; a token fallback for agents with no usage. */
53
+ private turnContext = 0;
54
+
55
+ constructor(private opts: AcpEngineOptions) {
56
+ this.agent = opts.agent;
57
+ }
58
+
59
+ onEvent(listener: (e: EngineEvent) => void) {
60
+ this.listeners.add(listener);
61
+ return () => void this.listeners.delete(listener);
62
+ }
63
+
64
+ private emit(e: EngineEvent) {
65
+ for (const l of this.listeners) l(e);
66
+ }
67
+
68
+ async start(o: EngineStartOptions): Promise<EngineInfo> {
69
+ const stream = this.opts.stream ?? this.spawn(o.cwd);
70
+ const client: Client = {
71
+ requestPermission: (p) => this.onPermission(p),
72
+ sessionUpdate: ({ update }) => this.onUpdate(update as any),
73
+ readTextFile: async (p) => ({ content: await Bun.file(p.path).text() }),
74
+ writeTextFile: async (p) => {
75
+ await Bun.write(p.path, p.content);
76
+ return {};
77
+ },
78
+ };
79
+ this.conn = new ClientSideConnection(() => client, stream);
80
+ const init = await this.conn.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false } });
81
+
82
+ let sessionId: string;
83
+ let modes: any;
84
+ let models: any;
85
+ let config: any;
86
+ if (o.resume) {
87
+ try {
88
+ const r: any = await this.conn.loadSession({ sessionId: o.resume.id, cwd: o.cwd, mcpServers: [] });
89
+ sessionId = o.resume.id;
90
+ modes = r?.modes;
91
+ models = r?.models;
92
+ config = r?.configOptions;
93
+ } catch (e: any) {
94
+ this.emit({ type: "log", level: "warn", message: `resume failed (${e?.message ?? e}); starting a new session` });
95
+ const s: any = await this.conn.newSession({ cwd: o.cwd, mcpServers: [] });
96
+ sessionId = s.sessionId;
97
+ config = s.configOptions;
98
+ modes = s.modes;
99
+ models = s.models;
100
+ }
101
+ } else {
102
+ const s: any = await this.conn.newSession({ cwd: o.cwd, mcpServers: [] });
103
+ sessionId = s.sessionId;
104
+ modes = s.modes;
105
+ models = s.models;
106
+ config = s.configOptions;
107
+ }
108
+ // ACP config options (OpenCode): a "model" / "mode" select stands in for models/modes.
109
+ const opt = (cat: string) => (Array.isArray(config) ? config.find((c: any) => c?.category === cat || c?.id === cat) : undefined);
110
+ const modelOpt = opt("model"), modeOpt = opt("mode");
111
+ if (modelOpt) this.configIds.model = modelOpt.id;
112
+ if (modeOpt) this.configIds.mode = modeOpt.id;
113
+ if (!models && modelOpt) {
114
+ models = { currentModelId: modelOpt.currentValue, availableModels: (modelOpt.options ?? []).map((x: any) => ({ modelId: x.value, name: x.name ?? x.value })) };
115
+ }
116
+ if (!modes && modeOpt) {
117
+ modes = { currentModeId: modeOpt.currentValue, availableModes: (modeOpt.options ?? []).map((x: any) => ({ id: x.value, name: x.name ?? x.value, description: x.description })) };
118
+ }
119
+ this.session = { agent: this.agent, id: sessionId, cwd: o.cwd, startedAt: Date.now(), lastUsedAt: Date.now() };
120
+ this.tail = this.opts.usageTail?.(this.session) ?? null;
121
+ if (o.resume && this.tail) this.tail.read(); // skip history already accounted for
122
+ this.info = {
123
+ agent: this.agent,
124
+ name: (init as any).agentInfo?.title ?? this.opts.name,
125
+ version: (init as any).agentInfo?.version,
126
+ modes: (modes?.availableModes ?? []).map((m: any) => ({ id: m.id, name: m.name ?? m.id, description: m.description })),
127
+ currentMode: modes?.currentModeId,
128
+ models: (models?.availableModels ?? []).map((m: any) => ({ id: m.modelId ?? m.id, name: m.name ?? m.modelId ?? m.id })),
129
+ currentModel: models?.currentModelId,
130
+ options: extraOptions(config),
131
+ };
132
+ this.emit({ type: "session", ref: this.session });
133
+ if (this.info.currentMode) this.emit({ type: "mode", modeId: this.info.currentMode });
134
+ return this.info;
135
+ }
136
+
137
+ private spawn(cwd: string): Stream {
138
+ const proc = Bun.spawn(platformArgv(this.opts.cmd), { cwd, env: agentEnv(this.opts.env), stdin: "pipe", stdout: "pipe", stderr: "pipe" });
139
+ this.proc = proc;
140
+ void (async () => {
141
+ for await (const chunk of proc.stderr as ReadableStream<Uint8Array>) {
142
+ const s = new TextDecoder().decode(chunk).trim();
143
+ if (s) this.emit({ type: "log", level: "info", message: s.slice(0, 500) }); // agent stderr: noise unless debugging
144
+ }
145
+ })();
146
+ void proc.exited.then((code) => {
147
+ if (this.conn) this.emit({ type: "error", kind: "crashed", message: `${this.opts.name} exited with code ${code}` });
148
+ });
149
+ const output = new WritableStream<Uint8Array>({
150
+ write(chunk) {
151
+ (proc.stdin as any).write(chunk);
152
+ (proc.stdin as any).flush?.();
153
+ },
154
+ });
155
+ return ndJsonStream(output, proc.stdout as ReadableStream<Uint8Array>);
156
+ }
157
+
158
+ private async onPermission(p: any) {
159
+ const id = `perm-${++this.permSeq}`;
160
+ const request: PermissionRequest = {
161
+ id,
162
+ toolCallId: p.toolCall?.toolCallId,
163
+ kind: p.toolCall?.kind,
164
+ title: p.toolCall?.title ?? p.toolCall?.toolCallId ?? "tool",
165
+ detail: formatInput(p.toolCall?.rawInput),
166
+ options: (p.options ?? []).map((o: any) => ({ id: o.optionId, label: o.name ?? o.optionId, kind: o.kind })),
167
+ };
168
+ const choice = await new Promise<string | null>((resolve) => {
169
+ this.pendingPermissions.set(id, resolve);
170
+ this.emit({ type: "permission_request", request });
171
+ });
172
+ this.pendingPermissions.delete(id);
173
+ return choice ? { outcome: { outcome: "selected" as const, optionId: choice } } : { outcome: { outcome: "cancelled" as const } };
174
+ }
175
+
176
+ private onUpdate(u: any) {
177
+ switch (u.sessionUpdate) {
178
+ case "agent_message_chunk":
179
+ if (u.content?.type === "text") this.emit({ type: "text_delta", text: u.content.text, messageId: u.messageId });
180
+ break;
181
+ case "agent_thought_chunk":
182
+ if (u.content?.type === "text") this.emit({ type: "thought_delta", text: u.content.text, messageId: u.messageId });
183
+ break;
184
+ case "tool_call":
185
+ this.emit({ type: "tool_call", id: u.toolCallId, title: u.title ?? u.toolCallId, kind: u.kind, status: mapStatus(u.status), detail: summarize(u), input: u.rawInput, files: paths(u), ...full(u) });
186
+ break;
187
+ case "tool_call_update":
188
+ this.emit({ type: "tool_update", id: u.toolCallId, status: u.status ? mapStatus(u.status) : undefined, title: u.title, detail: summarize(u), input: u.rawInput, files: paths(u), ...full(u) });
189
+ break;
190
+ case "plan":
191
+ this.emit({ type: "plan", entries: (u.entries ?? []).map((e: any) => ({ content: e.content, status: e.status, priority: e.priority })) });
192
+ break;
193
+ case "current_mode_update":
194
+ this.emit({ type: "mode", modeId: u.currentModeId });
195
+ break;
196
+ case "available_commands_update":
197
+ this.emit({ type: "commands", commands: (u.availableCommands ?? []).map((c: any) => ({ name: c.name, description: c.description })) });
198
+ break;
199
+ case "usage_update": {
200
+ // ACP: `used`/`size` describe context fill; `cost` is the session's running total.
201
+ const total = typeof u.cost?.amount === "number" ? u.cost.amount : undefined;
202
+ const delta = total !== undefined ? Math.max(0, total - this.lastAgentCost) : undefined;
203
+ if (total !== undefined) this.lastAgentCost = total;
204
+ // `used` is how full the context window is, not tokens spent: it feeds the context meter.
205
+ // Token counts come from the prompt response (or session files) at turn end.
206
+ if (typeof u.used === "number") this.turnContext = u.used;
207
+ this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName() });
208
+ break;
209
+ }
210
+ default:
211
+ break;
212
+ }
213
+ }
214
+
215
+ async prompt(text: string): Promise<StopReason> {
216
+ if (!this.conn || !this.session) throw new Error("engine not started");
217
+ this.turnActive = true;
218
+ this.turnContext = 0;
219
+ try {
220
+ const res: any = await this.conn.prompt({ sessionId: this.session.id, prompt: [{ type: "text", text }] });
221
+ this.session.lastUsedAt = Date.now();
222
+ this.flushUsage();
223
+ this.emitTurnUsage(res?.usage);
224
+ const reason = mapStop(res.stopReason);
225
+ this.emit({ type: "turn_end", stopReason: reason });
226
+ return reason;
227
+ } catch (e: any) {
228
+ const message = e?.message ?? String(e);
229
+ const data = JSON.stringify(e?.data ?? "");
230
+ const kind = classifyError(`${message} ${data}`);
231
+ this.emit({ type: "error", kind, message: `${message} ${data}`.trim() });
232
+ this.emit({ type: "turn_end", stopReason: "error" });
233
+ return "error";
234
+ } finally {
235
+ this.turnActive = false;
236
+ }
237
+ }
238
+
239
+ answerPermission(requestId: string, optionId: string) {
240
+ this.pendingPermissions.get(requestId)?.(optionId);
241
+ }
242
+
243
+ private currentModelName(): string {
244
+ const id = this.info?.currentModel;
245
+ return id ?? `${this.opts.name.toLowerCase()} default model`;
246
+ }
247
+
248
+ /**
249
+ * Per-turn token counts from the ACP prompt response (OpenCode reports
250
+ * inputTokens / outputTokens / thoughtTokens / cachedReadTokens). Agents
251
+ * with session-file usage (Claude Code, Grok) are counted by the tail
252
+ * instead; with neither, the context fill is the closest input figure.
253
+ */
254
+ private emitTurnUsage(u: any) {
255
+ if (this.tail) return;
256
+ const n = (v: unknown) => (typeof v === "number" && v > 0 ? v : 0);
257
+ if (u && typeof u === "object") {
258
+ const cacheRead = n(u.cachedReadTokens ?? u.cacheReadTokens);
259
+ const input = Math.max(0, n(u.inputTokens) - cacheRead);
260
+ const output = n(u.outputTokens) + n(u.thoughtTokens ?? u.reasoningTokens);
261
+ if (input + output + cacheRead > 0) {
262
+ this.emit({ type: "usage", inputTokens: input, outputTokens: output, cacheReadTokens: cacheRead, cacheWriteTokens: n(u.cachedWriteTokens ?? u.cacheWriteTokens), model: this.currentModelName() });
263
+ return;
264
+ }
265
+ }
266
+ if (this.turnContext > 0) this.emit({ type: "usage", inputTokens: this.turnContext, model: this.currentModelName() });
267
+ }
268
+
269
+ /** Emit usage appended to the agent's session files since the last check. */
270
+ flushUsage() {
271
+ if (!this.tail) return;
272
+ try {
273
+ for (const s of this.tail.read()) this.emit({ type: "usage", inputTokens: s.input, outputTokens: s.output, cacheReadTokens: s.cacheRead, cacheWriteTokens: s.cacheWrite, model: s.model, costUsd: s.costUsd });
274
+ } catch {
275
+ /* file mid-write; next turn picks it up */
276
+ }
277
+ }
278
+
279
+ async cancel() {
280
+ if (!this.conn || !this.session || !this.turnActive) return;
281
+ for (const resolve of this.pendingPermissions.values()) resolve(null);
282
+ await this.conn.cancel({ sessionId: this.session.id }).catch(() => {});
283
+ }
284
+
285
+ async setModel(modelId: string) {
286
+ if (!this.conn || !this.session) return;
287
+ // The current protocol switches models through a config option (Grok, Cursor, OpenCode).
288
+ // Agents that only list models (the Claude adapter) take the older session/set_model call,
289
+ // which this SDK no longer wraps, so it is sent as a raw method.
290
+ if (this.configIds.model) await this.conn.setSessionConfigOption({ sessionId: this.session.id, configId: this.configIds.model, value: modelId } as any);
291
+ else await this.conn.extMethod("session/set_model", { sessionId: this.session.id, modelId });
292
+ if (this.info) this.info.currentModel = modelId;
293
+ }
294
+
295
+ async setOption(optionId: string, value: string) {
296
+ if (!this.conn || !this.session) return;
297
+ await this.conn.setSessionConfigOption({ sessionId: this.session.id, configId: optionId, value } as any);
298
+ const o = this.info?.options?.find((x) => x.id === optionId);
299
+ if (o) o.current = value;
300
+ }
301
+
302
+ async setMode(modeId: string) {
303
+ if (!this.conn || !this.session) return;
304
+ if (this.configIds.mode) await this.conn.setSessionConfigOption({ sessionId: this.session.id, configId: this.configIds.mode, value: modeId } as any);
305
+ else await this.conn.setSessionMode({ sessionId: this.session.id, modeId });
306
+ if (this.info) this.info.currentMode = modeId;
307
+ this.emit({ type: "mode", modeId });
308
+ }
309
+
310
+ async close() {
311
+ this.flushUsage();
312
+ const conn = this.conn;
313
+ this.conn = null;
314
+ for (const resolve of this.pendingPermissions.values()) resolve(null);
315
+ if (conn && this.session) await conn.closeSession?.({ sessionId: this.session.id }).catch(() => {});
316
+ if (this.proc) {
317
+ try {
318
+ this.proc.kill();
319
+ } catch {
320
+ /* already gone */
321
+ }
322
+ await Promise.race([this.proc.exited, Bun.sleep(1500)]);
323
+ if (!this.proc.killed) this.proc.kill("SIGKILL");
324
+ }
325
+ }
326
+ }
327
+
328
+ /** Select-type config options other than model and mode (those have their own commands). */
329
+ function extraOptions(config: unknown): EngineOption[] {
330
+ if (!Array.isArray(config)) return [];
331
+ return config
332
+ .filter((c: any) => c && c.type === "select" && c.category !== "model" && c.category !== "mode" && c.id !== "model" && c.id !== "mode" && Array.isArray(c.options))
333
+ .map((c: any) => ({
334
+ id: String(c.id),
335
+ name: String(c.name ?? c.id),
336
+ category: c.category,
337
+ current: c.currentValue,
338
+ values: c.options.map((o: any) => ({ value: String(o.value), name: String(o.name ?? o.value), description: o.description })),
339
+ }));
340
+ }
341
+
342
+ function mapStatus(s: string | undefined): "pending" | "in_progress" | "completed" | "failed" {
343
+ if (s === "in_progress" || s === "completed" || s === "failed") return s;
344
+ return "pending";
345
+ }
346
+
347
+ function mapStop(s: string | undefined): StopReason {
348
+ switch (s) {
349
+ case "end_turn":
350
+ case "max_tokens":
351
+ case "cancelled":
352
+ case "refusal":
353
+ return s;
354
+ default:
355
+ return "other";
356
+ }
357
+ }
358
+
359
+ function paths(u: any): string[] | undefined {
360
+ const out = new Set<string>();
361
+ for (const l of u.locations ?? []) if (l?.path) out.add(l.path);
362
+ for (const c of u.content ?? []) if (c?.type === "diff" && c.path) out.add(c.path);
363
+ return out.size ? [...out] : undefined;
364
+ }
365
+
366
+ /** The whole result of a tool call: text content, unified-style diffs, raw output. Uncut. */
367
+ function full(u: any): { output?: string } {
368
+ const parts: string[] = [];
369
+ for (const c of u.content ?? []) {
370
+ if (c?.type === "content" && c.content?.type === "text") parts.push(c.content.text);
371
+ else if (c?.type === "diff") parts.push(simpleDiff(c.path, c.oldText, c.newText));
372
+ }
373
+ if (!parts.length && u.rawOutput !== undefined) parts.push(typeof u.rawOutput === "string" ? u.rawOutput : JSON.stringify(u.rawOutput));
374
+ return parts.length ? { output: parts.join("\n") } : {};
375
+ }
376
+
377
+ /** A minimal line diff for ACP diff content (old/new file text): changed region with context. */
378
+ export function simpleDiff(path: string, oldText: string | null | undefined, newText: string): string {
379
+ const a = (oldText ?? "").split("\n");
380
+ const b = (newText ?? "").split("\n");
381
+ if (!oldText) return [`--- /dev/null`, `+++ ${path}`, ...b.map((l) => `+${l}`)].join("\n");
382
+ let s = 0;
383
+ while (s < a.length && s < b.length && a[s] === b[s]) s++;
384
+ let ea = a.length - 1, eb = b.length - 1;
385
+ while (ea >= s && eb >= s && a[ea] === b[eb]) { ea--; eb--; }
386
+ const ctx = 2, from = Math.max(0, s - ctx);
387
+ const lines = [`--- ${path}`, `+++ ${path}`, `@@ line ${from + 1} @@`];
388
+ for (let i = from; i < s; i++) lines.push(` ${a[i]}`);
389
+ for (let i = s; i <= ea; i++) lines.push(`-${a[i]}`);
390
+ for (let i = s; i <= eb; i++) lines.push(`+${b[i]}`);
391
+ for (let i = ea + 1; i <= Math.min(a.length - 1, ea + ctx); i++) lines.push(` ${a[i]}`);
392
+ return lines.join("\n");
393
+ }
394
+
395
+ function summarize(u: any): string | undefined {
396
+ const parts: string[] = [];
397
+ if (u.locations?.length) parts.push(u.locations.map((l: any) => l.path).join(", "));
398
+ if (u.content?.length) {
399
+ for (const c of u.content) {
400
+ if (c.type === "content" && c.content?.type === "text") parts.push(c.content.text.slice(0, 400));
401
+ else if (c.type === "diff") parts.push(`diff ${c.path}`);
402
+ else if (c.type === "terminal") parts.push(`terminal ${c.terminalId}`);
403
+ }
404
+ }
405
+ return parts.length ? parts.join("\n").slice(0, 1200) : undefined;
406
+ }
407
+
408
+ /** Human-readable view of a tool's raw input: `key: value` lines, long values clipped. */
409
+ function formatInput(raw: unknown): string | undefined {
410
+ if (raw === undefined || raw === null) return undefined;
411
+ if (typeof raw !== "object") return String(raw).slice(0, 800);
412
+ const lines: string[] = [];
413
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>).slice(0, 6)) {
414
+ const val = typeof v === "string" ? v : JSON.stringify(v);
415
+ if (val === undefined) continue;
416
+ lines.push(`${k}: ${val.length > 300 ? val.slice(0, 297) + "…" : val}`);
417
+ }
418
+ return lines.join("\n") || undefined;
419
+ }