pi-context-guard-jev 0.1.0 → 0.2.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/src/index.ts CHANGED
@@ -6,18 +6,40 @@
6
6
  * entries then replace each output with the kept chunks, verbatim. The raw output stays in the
7
7
  * session; the `recall` tool returns it. The footer shows how much context is being saved.
8
8
  *
9
- * Also pins an Anthropic cache breakpoint at the previous user question, so the distilled
10
- * history still hits the prompt cache (docs/CACHE.md).
9
+ * During long runs, mid-run checkpoints judge older outputs at turn boundaries (turn_end), in
10
+ * batches, with a checkpoint-specific Jev request (docs/DESIGN.md → Mid-run checkpoints).
11
+ *
12
+ * Also places Anthropic cache breakpoints (the previous user question, and after edits a read point
13
+ * before the first edited output), so the distilled history still hits the prompt cache (docs/CACHE.md).
11
14
  */
12
15
  import { join } from "node:path";
13
16
  import { Type } from "@earendil-works/pi-ai";
14
17
  import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
15
- import { pinQuestionBreakpoint } from "./cache-pin.ts";
18
+ import { markedToolResults, normalizeToolUseId, pinQuestionBreakpoint, placeGuardBreakpoints } from "./cache-pin.ts";
19
+ import {
20
+ type CheckpointCandidate,
21
+ collectCheckpoint,
22
+ anthropicRewriteChars,
23
+ BREAK_EVEN_FACTOR,
24
+ CACHE_TTL_MS,
25
+ cacheAnchors,
26
+ exchangeMemo,
27
+ charsFrom,
28
+ contextChars,
29
+ memoFromBranch,
30
+ paysOff,
31
+ pendingEdits,
32
+ refreshOnReadThrough,
33
+ runBaseline,
34
+ type WrittenEntry,
35
+ } from "./checkpoint.ts";
36
+ import { buildCheckpointRequest } from "./decide.ts";
16
37
  import { type GuardConfig, loadConfig, parseModelRef, saveConfigPatch } from "./config.ts";
17
- import { type DistillOutcome, distillRun } from "./distill.ts";
18
- import { originalOutput, recallText } from "./recall.ts";
38
+ import { omittedIdsFor, originalOutput, recallText } from "./recall.ts";
19
39
  import { formatChars, MARKER } from "./render.ts";
20
- import { collectRun } from "./run.ts";
40
+ import { collectExchanges, collectSmall, type ExchangeItem, type SmallItem, type WorkContext } from "./items.ts";
41
+ import { deferExchanges, exchangeOmissions, type ProcessOptions, type ProcessOutcome, processItems } from "./process.ts";
42
+ import { collectRun, findRunStart, type RunInfo } from "./run.ts";
21
43
  import {
22
44
  CUSTOM_TYPE,
23
45
  type Colorize,
@@ -40,6 +62,33 @@ export default function contextGuard(pi: ExtensionAPI) {
40
62
  let busy = false;
41
63
  let problem: string | undefined;
42
64
  let warned = new Set<string>();
65
+ // No checkpoint state is kept in memory: the memo and the read point are derived from the entries
66
+ // persisted on the active branch at every boundary and request (checkpoint.ts).
67
+ const branchOf = (ctx: ExtensionContext) => ctx.sessionManager.getBranch() as unknown as Parameters<typeof pendingEdits>[0];
68
+ /**
69
+ * Cache entries this process wrote: the tool-result breakpoints it saw in the Anthropic requests it
70
+ * sent (after its own changes). The only source for read points; cleared on session start or reload,
71
+ * so anything uncertain falls back to the question pin.
72
+ */
73
+ let written: WrittenEntry[] = [];
74
+ const recordWritten = (branch: ReturnType<typeof branchOf>, payload: unknown, model: string, now: number) => {
75
+ const leafId = branch.at(-1)?.id;
76
+ if (!leafId) return;
77
+ const byToolUseId = new Map<string, string>();
78
+ for (const e of branch) {
79
+ const id = e.message?.role === "toolResult" ? e.message.toolCallId : undefined;
80
+ if (id && e.id) byToolUseId.set(normalizeToolUseId(id), e.id);
81
+ }
82
+ const fresh = written.filter((w) => now - w.time <= CACHE_TTL_MS);
83
+ for (const toolUseId of markedToolResults(payload)) {
84
+ const entryId = byToolUseId.get(toolUseId);
85
+ if (entryId) fresh.push({ entryId, model, time: now, leafId });
86
+ }
87
+ written = fresh.slice(-200);
88
+ };
89
+ const modelKey = (ctx: ExtensionContext) => `${ctx.model?.provider}/${ctx.model?.id}`;
90
+ /** The last Anthropic request this process saw, to match its response in message_end. */
91
+ let lastRequest: { model: string; time: number } | undefined;
43
92
 
44
93
  const warnOnce = (ctx: ExtensionContext, key: string, message: string) => {
45
94
  if (warned.has(key)) return;
@@ -97,6 +146,7 @@ export default function contextGuard(pi: ExtensionAPI) {
97
146
  warned = new Set();
98
147
  busy = false;
99
148
  reload(ctx);
149
+ written = [];
100
150
  if (config.enabled) resolveClassifier(ctx);
101
151
  refreshStatus(ctx);
102
152
  });
@@ -104,77 +154,212 @@ export default function contextGuard(pi: ExtensionAPI) {
104
154
  pi.on("session_compact", async (_event, ctx) => refreshStatus(ctx));
105
155
  pi.on("agent_settled", async (_event, ctx) => refreshStatus(ctx));
106
156
 
107
- pi.on("agent_before_settle", async (event, ctx) => {
108
- if (!config.enabled || event.outcome !== "completed") return;
109
- const run = collectRun(event.context.contextEntries as unknown as ProjectedEntryLike[], config);
110
- if (!run || run.candidates.length === 0) return;
111
- if (run.candidates.reduce((sum, c) => sum + c.text.length, 0) < config.minRunChars) return;
112
-
157
+ /** Process every kind of item with the busy status shown; undefined when distillation could not start. */
158
+ const processWithStatus = async (
159
+ ctx: ExtensionContext,
160
+ run: RunInfo,
161
+ small: SmallItem[],
162
+ exchanges: ExchangeItem[],
163
+ work: WorkContext,
164
+ options: ProcessOptions,
165
+ ): Promise<ProcessOutcome | undefined> => {
113
166
  const classify = resolveClassifier(ctx);
114
167
  if (!classify) {
115
168
  refreshStatus(ctx);
116
- return;
169
+ return undefined;
117
170
  }
118
-
119
171
  busy = true;
120
172
  refreshStatus(ctx);
121
- if (ctx.hasUI) ctx.ui.setWorkingMessage("context-guard: distilling tool output with Jev…");
122
- let outcome: DistillOutcome;
173
+ if (ctx.hasUI) ctx.ui.setWorkingMessage("context-guard: distilling old context with Jev…");
123
174
  try {
124
- outcome = await distillRun(run, classify, config, ctx.signal);
175
+ return await processItems(run, small, exchanges, work, classify, options, ctx.signal);
125
176
  } catch (err) {
126
177
  warnOnce(ctx, "distill", `distillation failed: ${err instanceof Error ? err.message : String(err)}`);
127
- return;
178
+ return undefined;
128
179
  } finally {
129
180
  busy = false;
130
181
  if (ctx.hasUI) ctx.ui.setWorkingMessage();
131
182
  refreshStatus(ctx);
132
183
  }
133
- if (outcome.requests === 0) return;
134
-
135
- const record: CustomDraft = {
136
- type: "custom",
137
- customType: CUSTOM_TYPE,
138
- data: {
139
- v: 1,
140
- model: config.model,
141
- savedChars: outcome.savedChars,
142
- requests: outcome.requests,
143
- inputTokens: outcome.inputTokens,
144
- costUsd: outcome.costUsd,
145
- ms: outcome.ms,
146
- timedOut: outcome.timedOut,
147
- results: outcome.records.map(({ entryId, tool, label, outcome, reason, beforeChars, afterChars, jev }) => ({
148
- entryId,
149
- tool,
150
- label,
151
- outcome,
152
- reason,
153
- beforeChars,
154
- afterChars,
155
- jev,
156
- })),
157
- } satisfies RunRecordData,
158
- };
159
- if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some results were left as they are.`);
184
+ };
185
+
186
+ const recordOf = (outcome: ProcessOutcome, phase: "mid-run" | "run-end"): CustomDraft => ({
187
+ type: "custom",
188
+ customType: CUSTOM_TYPE,
189
+ data: {
190
+ v: 1,
191
+ phase,
192
+ model: config.model,
193
+ savedChars: outcome.savedChars,
194
+ requests: outcome.requests,
195
+ inputTokens: outcome.inputTokens,
196
+ costUsd: outcome.costUsd,
197
+ ms: outcome.ms,
198
+ timedOut: outcome.timedOut,
199
+ results: outcome.records.map(({ kind, entryId, tool, label, outcome, reason, beforeChars, afterChars, jev, omitted }) => ({
200
+ kind,
201
+ ...(omitted ? { omitted } : {}),
202
+ entryId,
203
+ tool,
204
+ label,
205
+ outcome,
206
+ reason,
207
+ beforeChars,
208
+ afterChars,
209
+ jev,
210
+ })),
211
+ } satisfies RunRecordData,
212
+ });
213
+
214
+ /** Checkpoint batch size for the current main model (OpenAI Codex loses its whole cache on every edit). */
215
+ const batchCharsFor = (ctx: ExtensionContext): number =>
216
+ ctx.model?.provider === "openai-codex" ? config.midRunBatchCharsOpenAI : config.midRunBatchChars;
217
+
218
+ /** Characters the next request would write to the cache again if `ids` (entry ids) were edited. */
219
+ const rewriteCharsFor = (ctx: ExtensionContext, entries: ProjectedEntryLike[], ids: ReadonlySet<string>): number => {
220
+ // OpenAI Codex: the whole context. Anthropic: everything after the read point it would get, or after
221
+ // the question when every edit comes after it, else everything (with pinning off: everything).
222
+ // Other providers: everything from the earliest edited entry.
223
+ if (ctx.model?.provider === "openai-codex") return contextChars(entries);
224
+ if (ctx.model?.api === "anthropic-messages") {
225
+ return config.pinAnthropicCache ? anthropicRewriteChars(entries, branchOf(ctx), written, modelKey(ctx), Date.now(), ids) : contextChars(entries);
226
+ }
227
+ return charsFrom(entries, ids);
228
+ };
229
+
230
+ /**
231
+ * Run-end gate for exchange omissions (checkpoint.ts → paysOff). Their marginal cache cost is the
232
+ * rewrite with them minus the rewrite the pass's other edits cause anyway (on OpenAI Codex any edit
233
+ * already costs the whole context, so with other edits the omissions are free). When that is above
234
+ * zero, they must save at least `exchangeMinSavingChars` and pay off over as many later requests as
235
+ * the session has had so far.
236
+ */
237
+ const exchangesPayOff = (ctx: ExtensionContext, entries: ProjectedEntryLike[], outcome: ProcessOutcome): boolean => {
238
+ const { savedChars, ids } = exchangeOmissions(outcome);
239
+ if (ids.size === 0) return true;
240
+ const others = new Set(outcome.edits.map((e) => e.targetId).filter((id) => !ids.has(id)));
241
+ const rewriteWith = rewriteCharsFor(ctx, entries, new Set([...others, ...ids]));
242
+ const rewriteWithout = others.size > 0 ? rewriteCharsFor(ctx, entries, others) : 0;
243
+ const marginal = rewriteWith - rewriteWithout;
244
+ if (marginal <= 0) return true;
245
+ if (savedChars < config.exchangeMinSavingChars) return false;
246
+ const requests = entries.filter((e) => e.messages[0]?.role === "assistant").length;
247
+ const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
248
+ return paysOff(savedChars, requests, marginal, factor);
249
+ };
250
+
251
+ pi.on("turn_end", async (event, ctx) => {
252
+ if (!config.enabled || !config.midRun || event.outcome !== "completed") return;
253
+ // The final turn of a run (no tool calls) is left to run-end distillation.
254
+ const message = event.message as { role?: string; content?: unknown };
255
+ if (message.role !== "assistant" || !Array.isArray(message.content) || !message.content.some((b: { type?: string }) => b?.type === "toolCall")) return;
256
+ const entries = event.context.contextEntries as unknown as ProjectedEntryLike[];
257
+ const branch = branchOf(ctx);
258
+ const memo = memoFromBranch(branch, CUSTOM_TYPE);
259
+ const info = collectCheckpoint(entries, config, memo);
260
+ if (!info) return;
261
+ const runStart = findRunStart(entries);
262
+ const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, memo, info.currentTurn - config.midRunMinAgeTurns) : [];
263
+ let exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branch, CUSTOM_TYPE)) : [];
264
+ const outputChars = info.pendingChars + small.reduce((n, item) => n + item.text.length, 0);
265
+ const exchangeChars = exchanges.reduce((n, item) => n + item.chars, 0);
266
+ if (outputChars + exchangeChars < batchCharsFor(ctx)) return;
267
+ // Only checkpoint when the one-time cache rewrite is likely to pay off (checkpoint.ts → paysOff).
268
+ // Old exchanges come early, so they move the rewrite start back; if the whole batch does not pay
269
+ // off, try the tool outputs alone.
270
+ if (config.midRunBreakEven) {
271
+ const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
272
+ const outputIds = new Set([...info.candidates.map((c) => c.entryId), ...small.map((s) => s.entryId)]);
273
+ const allIds = new Set([...outputIds, ...exchanges.map((e) => e.entryId)]);
274
+ const turns = info.currentTurn + 1;
275
+ if (!paysOff(outputChars + exchangeChars, turns, rewriteCharsFor(ctx, entries, allIds), factor)) {
276
+ exchanges = [];
277
+ if (outputChars < batchCharsFor(ctx) || !paysOff(outputChars, turns, rewriteCharsFor(ctx, entries, outputIds), factor)) return;
278
+ }
279
+ }
280
+
281
+ const run: RunInfo = { question: info.question, answer: "", notes: info.notes, candidates: info.candidates, toolResults: 0, history: info.history };
282
+ const work: WorkContext = { question: info.question, history: info.history, latest: info.latest, notes: info.notes };
283
+ const outcome = await processWithStatus(ctx, run, small, exchanges, work, {
284
+ ...config,
285
+ minRunChars: 0,
286
+ chunkKeepThreshold: config.midRunChunkKeepThreshold,
287
+ requestBuilder: (candidate, segment, segmentIndex, segmentCount, totalLines) =>
288
+ buildCheckpointRequest(info, candidate as CheckpointCandidate, segment, segmentIndex, segmentCount, totalLines),
289
+ });
290
+ if (!outcome || outcome.requests === 0) return;
291
+
292
+ if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
160
293
  // The runner REPLACES the draft list with our return value: keep other extensions' drafts.
161
- return { entries: [...event.entries, ...outcome.edits, record] };
294
+ // Never ask for `continue`: the run goes on by itself.
295
+ // The memo only advances once Pi has committed this record to the branch.
296
+ return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "mid-run")] };
297
+ });
298
+
299
+ pi.on("agent_before_settle", async (event, ctx) => {
300
+ if (!config.enabled || event.outcome !== "completed") return;
301
+ const entries = event.context.contextEntries as unknown as ProjectedEntryLike[];
302
+ // Outputs kept at a mid-run checkpoint are judged again, now with the final answer; distilled ones
303
+ // carry the marker and are skipped by the candidate rules.
304
+ const run = collectRun(entries, config);
305
+ if (!run) return;
306
+ const largeChars = run.candidates.reduce((sum, c) => sum + c.text.length, 0);
307
+ if (largeChars < config.minRunChars) run.candidates = [];
308
+ const runStart = findRunStart(entries);
309
+ const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, new Set()) : [];
310
+ const exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branchOf(ctx), CUSTOM_TYPE)) : [];
311
+ if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0) return;
312
+ const work: WorkContext = { question: run.question, history: run.history ?? { exchanges: [] }, answer: run.answer };
313
+
314
+ const outcome = await processWithStatus(ctx, run, small, exchanges, work, config);
315
+ if (!outcome || outcome.requests === 0) return;
316
+ if (config.exchangeBreakEven && !exchangesPayOff(ctx, entries, outcome)) deferExchanges(outcome);
317
+ if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
318
+ // The runner REPLACES the draft list with our return value: keep other extensions' drafts.
319
+ return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "run-end")] };
320
+ });
321
+
322
+ // Refresh logged cache entries that the response shows were read through (checkpoint.ts →
323
+ // refreshOnReadThrough). Without a clear hit beyond the system prompt and question, nothing changes.
324
+ pi.on("message_end", async (event, ctx) => {
325
+ const message = event.message as { role?: string; provider?: string; model?: string; usage?: { cacheRead?: number } };
326
+ const request = lastRequest;
327
+ if (message.role !== "assistant" || !request || written.length === 0 || !ctx.sessionManager) return;
328
+ lastRequest = undefined;
329
+ if (`${message.provider}/${message.model}` !== request.model) return;
330
+ const branch = branchOf(ctx);
331
+ const baseline = runBaseline(branch);
332
+ if (baseline === undefined) return;
333
+ const projection = ctx.sessionManager.buildSessionProjection().entries as unknown as ProjectedEntryLike[];
334
+ refreshOnReadThrough(projection, branch, written, request.model, request.time, message.usage?.cacheRead ?? 0, baseline);
162
335
  });
163
336
 
164
337
  pi.on("before_provider_request", (event, ctx) => {
165
338
  if (!config.enabled || !config.pinAnthropicCache) return;
166
339
  if (ctx.model?.api !== "anthropic-messages") return;
167
- pinQuestionBreakpoint(event.payload); // mutates in place; returning undefined keeps the payload
340
+ // Mutates in place; returning undefined keeps the payload. The previous question is always pinned
341
+ // (the floor). The first request after edits (checkpoint, run end or another extension) also gets
342
+ // a read point, only at an entry this process saw itself write (`written`), and a write anchor
343
+ // after the first edited batch (checkpoint.ts → cacheAnchors).
344
+ const model = modelKey(ctx);
345
+ const branch = ctx.sessionManager ? branchOf(ctx) : [];
346
+ const now = Date.now();
347
+ lastRequest = { model, time: now };
348
+ const anchors = cacheAnchors(branch, written, model, now);
349
+ if (anchors.read || anchors.write) {
350
+ placeGuardBreakpoints(event.payload, { anchors: anchors.read ? [anchors.read] : [], writeAnchors: anchors.write ? [anchors.write] : [], pinQuestion: true });
351
+ } else pinQuestionBreakpoint(event.payload);
352
+ recordWritten(branch, event.payload, model, now);
168
353
  });
169
354
 
170
355
  pi.registerTool({
171
356
  name: "recall",
172
357
  label: "Recall",
173
358
  description:
174
- `Return the original output of a tool result that context-guard distilled (its text starts with "${MARKER}"). ` +
175
- "Pass the entryId from that header. Optionally pass a regex `pattern` to get only matching lines (with line numbers), " +
359
+ `Return the original of something context-guard distilled or omitted (its text starts with "${MARKER}"): a tool output, ` +
360
+ "or a whole earlier exchange (prompt, replies, tool calls and results). Pass the entryId from that note. Optionally pass a regex `pattern` to get only matching lines (with line numbers), " +
176
361
  "and `offset`/`limit` (1-based) to page through a long output; with a `pattern` they count matching lines.",
177
- promptSnippet: `recall: fetch the full original output of a tool result marked "${MARKER}"`,
362
+ promptSnippet: `recall: fetch the full original of a tool output or earlier exchange marked "${MARKER}"`,
178
363
  parameters: Type.Object({
179
364
  entryId: Type.String({ description: "Entry id from the context-guard header" }),
180
365
  pattern: Type.Optional(Type.String({ description: "JavaScript regex; return only matching lines" })),
@@ -182,7 +367,8 @@ export default function contextGuard(pi: ExtensionAPI) {
182
367
  limit: Type.Optional(Type.Number({ description: "Most lines to return (matching lines when pattern is set)" })),
183
368
  }),
184
369
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
185
- const original = originalOutput(ctx.sessionManager.getEntry(params.entryId) as never, params.entryId);
370
+ const branch = ctx.sessionManager.getBranch() as never;
371
+ const original = originalOutput(ctx.sessionManager.getEntry(params.entryId) as never, params.entryId, branch, omittedIdsFor(branch, params.entryId, CUSTOM_TYPE));
186
372
  const text = recallText(original, params);
187
373
  return { content: [{ type: "text", text }], details: undefined };
188
374
  },
@@ -213,15 +399,17 @@ export default function contextGuard(pi: ExtensionAPI) {
213
399
  function describe(config: GuardConfig, stats: GuardStats, problem: string | undefined): string {
214
400
  const lines = [
215
401
  `context-guard is ${config.enabled ? "on" : "off"}${problem && config.enabled ? ` (${problem})` : ""} · model ${config.model}`,
216
- `Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars) across ${stats.distilledResults} distilled tool result(s).`,
217
- `Jev: ${stats.runs} run(s), ${stats.requests} request(s), ${formatCost(stats.costUsd)} on this branch.`,
402
+ `Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars): ${stats.distilledResults} distilled tool output(s), ${stats.omittedExchanges} omitted exchange(s).`,
403
+ `Jev: ${stats.runs - stats.checkpoints} run(s) and ${stats.checkpoints} mid-run checkpoint(s), ${stats.requests} request(s), ${formatCost(stats.costUsd)} on this branch.`,
404
+ `Mid-run checkpoints are ${config.midRun ? `on (outputs ≥ ${config.midRunMinAgeTurns} turns old, batches of ≥ ${formatChars(config.midRunBatchChars)} chars)` : "off"}; old exchanges are ${config.pruneExchanges ? `judged (all but the last ${config.keepRecentExchanges})` : "kept"}.`,
218
405
  ];
219
406
  const last = stats.last;
220
407
  if (last) {
221
- lines.push(`Last run (${(last.ms / 1000).toFixed(1)} s${last.timedOut ? ", timed out" : ""}):`);
408
+ const kinds = ["large", "small", "exchange"].map((kind) => `${last.results.filter((r) => (r.kind ?? "large") === kind).length} ${kind}`).join(", ");
409
+ lines.push(`Last ${last.phase === "mid-run" ? "checkpoint" : "run"} (${(last.ms / 1000).toFixed(1)} s${last.timedOut ? ", timed out" : ""}; ${kinds}):`);
222
410
  for (const result of last.results) {
223
411
  const sizes = result.outcome === "kept" ? formatChars(result.beforeChars) : `${formatChars(result.beforeChars)} → ${formatChars(result.afterChars)}`;
224
- lines.push(` ${result.outcome.padEnd(9)} ${sizes.padEnd(13)} ${result.label}${result.reason ? ` (${result.reason})` : ""}`);
412
+ lines.push(` ${result.outcome.padEnd(9)} ${(result.kind ?? "large").padEnd(9)} ${sizes.padEnd(13)} ${result.label}${result.reason ? ` (${result.reason})` : ""}`);
225
413
  for (const trace of result.jev ?? []) lines.push(` jev: ${trace}`);
226
414
  }
227
415
  }