@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.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 (51) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/cli.js +2991 -3011
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/edit/index.d.ts +1 -0
  8. package/dist/types/edit/renderer.d.ts +4 -0
  9. package/dist/types/edit/snapshot-details.d.ts +33 -0
  10. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  11. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  12. package/dist/types/session/agent-session.d.ts +4 -0
  13. package/dist/types/session/session-manager.d.ts +3 -4
  14. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  15. package/dist/types/web/search/types.d.ts +1 -1
  16. package/package.json +12 -12
  17. package/src/cli/args.ts +32 -1
  18. package/src/cli/bench-cli.ts +89 -21
  19. package/src/cli/web-search-cli.ts +6 -1
  20. package/src/commands/bench.ts +10 -1
  21. package/src/config/mcp-schema.json +1 -1
  22. package/src/config/model-discovery.ts +66 -8
  23. package/src/config/model-registry.ts +13 -6
  24. package/src/edit/hashline/execute.ts +15 -9
  25. package/src/edit/index.ts +19 -6
  26. package/src/edit/modes/patch.ts +3 -2
  27. package/src/edit/modes/replace.ts +3 -2
  28. package/src/edit/renderer.ts +4 -0
  29. package/src/edit/snapshot-details.ts +77 -0
  30. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  31. package/src/internal-urls/docs-index.generated.txt +1 -1
  32. package/src/mcp/oauth-flow.ts +10 -8
  33. package/src/mcp/transports/stdio.ts +9 -17
  34. package/src/modes/controllers/event-controller.ts +17 -8
  35. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  36. package/src/prompts/bench.md +4 -10
  37. package/src/prompts/tools/irc.md +19 -29
  38. package/src/prompts/tools/job.md +8 -14
  39. package/src/prompts/tools/lsp.md +19 -30
  40. package/src/prompts/tools/task.md +42 -62
  41. package/src/sdk.ts +1 -0
  42. package/src/session/agent-session.ts +10 -0
  43. package/src/session/session-listing.ts +9 -8
  44. package/src/session/session-loader.ts +98 -3
  45. package/src/session/session-manager.ts +34 -4
  46. package/src/subprocess/worker-client.ts +12 -4
  47. package/src/tools/path-utils.ts +4 -2
  48. package/src/web/search/index.ts +14 -8
  49. package/src/web/search/providers/duckduckgo.ts +136 -78
  50. package/src/web/search/providers/gemini.ts +268 -185
  51. package/src/web/search/types.ts +1 -1
@@ -122,6 +122,12 @@ type OllamaDiscoveredModelMetadata = {
122
122
  type LlamaCppDiscoveredServerMetadata = {
123
123
  contextWindow?: number;
124
124
  input?: ("text" | "image")[];
125
+ maxTokens?: "contextWindow";
126
+ };
127
+
128
+ type LlamaCppDiscoveredModelRuntimeMetadata = {
129
+ contextWindow: number;
130
+ maxTokens: number;
125
131
  };
126
132
 
127
133
  type LlamaCppModelListEntry = {
@@ -143,6 +149,44 @@ function toPositiveNumberOrUndefined(value: unknown): number | undefined {
143
149
  return undefined;
144
150
  }
145
151
 
152
+ function isLlamaCppUnlimitedSentinel(value: unknown): boolean {
153
+ if (typeof value === "number") {
154
+ return value === -1;
155
+ }
156
+ if (typeof value === "string" && value.trim()) {
157
+ return Number(value) === -1;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ /**
163
+ * llama.cpp `/props.default_generation_settings.params.{max_tokens,n_predict}`
164
+ * are per-request defaults the server applies when a client omits the field —
165
+ * clients can still raise them per call. Positive values therefore are NOT
166
+ * hard model caps; only the `-1` unlimited sentinel reliably tells us the
167
+ * server bounds generation by the runtime context window. Anything else
168
+ * leaves the discovery default in place.
169
+ */
170
+ function extractLlamaCppMaxTokens(payload: Record<string, unknown>): "contextWindow" | undefined {
171
+ const generationSettings = payload.default_generation_settings;
172
+ const params = isRecord(generationSettings) ? generationSettings.params : undefined;
173
+ const candidates = [
174
+ isRecord(params) ? params.max_tokens : undefined,
175
+ isRecord(params) ? params.n_predict : undefined,
176
+ isRecord(generationSettings) ? generationSettings.max_tokens : undefined,
177
+ isRecord(generationSettings) ? generationSettings.n_predict : undefined,
178
+ payload.max_tokens,
179
+ payload.n_predict,
180
+ ];
181
+ return candidates.some(isLlamaCppUnlimitedSentinel) ? "contextWindow" : undefined;
182
+ }
183
+
184
+ function resolveLlamaCppMaxTokens(contextWindow: number, maxTokens: "contextWindow" | undefined): number {
185
+ return maxTokens === "contextWindow"
186
+ ? contextWindow
187
+ : Math.min(contextWindow, maxTokens ?? DISCOVERY_DEFAULT_MAX_TOKENS);
188
+ }
189
+
146
190
  function extractOllamaRuntimeContextWindow(payload: Record<string, unknown>): number | undefined {
147
191
  const parameters = payload.parameters;
148
192
  if (typeof parameters !== "string") {
@@ -353,6 +397,7 @@ async function discoverLlamaCppServerMetadata(
353
397
  }
354
398
  return {
355
399
  contextWindow: extractLlamaCppContextWindow(payload),
400
+ maxTokens: extractLlamaCppMaxTokens(payload),
356
401
  input: extractLlamaCppInputCapabilities(payload),
357
402
  };
358
403
  } catch {
@@ -410,7 +455,7 @@ export async function discoverLlamaCppModels(
410
455
  imageInputDecoder: "stb",
411
456
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
412
457
  contextWindow,
413
- maxTokens: Math.min(contextWindow, DISCOVERY_DEFAULT_MAX_TOKENS),
458
+ maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
414
459
  headers,
415
460
  compat: {
416
461
  supportsStore: false,
@@ -423,24 +468,37 @@ export async function discoverLlamaCppModels(
423
468
  return discovered;
424
469
  }
425
470
 
426
- export async function discoverLlamaCppModelContextWindow(
471
+ export async function discoverLlamaCppModelRuntimeMetadata(
427
472
  model: Pick<Model<Api>, "provider" | "id" | "baseUrl" | "headers">,
428
473
  ctx: DiscoveryContext,
429
- ): Promise<number | undefined> {
474
+ ): Promise<LlamaCppDiscoveredModelRuntimeMetadata | undefined> {
430
475
  const baseUrl = normalizeLlamaCppBaseUrl(model.baseUrl);
431
476
  const modelsUrl = `${baseUrl}/models`;
432
477
  const baseHeaders: Record<string, string> = { ...(model.headers ?? {}) };
433
478
  const attempt = async (headers: Record<string, string>) => {
434
- const response = await ctx.fetch(modelsUrl, {
435
- headers,
436
- signal: AbortSignal.timeout(250),
437
- });
479
+ const [response, serverMetadata] = await Promise.all([
480
+ ctx.fetch(modelsUrl, {
481
+ headers,
482
+ signal: AbortSignal.timeout(250),
483
+ }),
484
+ discoverLlamaCppServerMetadata(ctx, baseUrl, headers),
485
+ ]);
438
486
  if (!response.ok) {
439
487
  return undefined;
440
488
  }
441
489
  const entries = parseLlamaCppModelList(await response.json());
442
490
  const entry = entries.find(entry => entry.id === model.id);
443
- return entry?.runtimeContextWindow ?? entry?.trainingContextWindow;
491
+ if (!entry) {
492
+ return undefined;
493
+ }
494
+ const contextWindow = entry.runtimeContextWindow ?? serverMetadata?.contextWindow ?? entry.trainingContextWindow;
495
+ if (contextWindow === undefined) {
496
+ return undefined;
497
+ }
498
+ return {
499
+ contextWindow,
500
+ maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
501
+ };
444
502
  };
445
503
  try {
446
504
  const apiKey = await ctx.getBearerApiKeyResolver(model.provider);
@@ -75,7 +75,7 @@ import {
75
75
  DISCOVERY_DEFAULT_MAX_TOKENS,
76
76
  type DiscoveryContext,
77
77
  type DiscoveryProviderConfig,
78
- discoverLlamaCppModelContextWindow,
78
+ discoverLlamaCppModelRuntimeMetadata,
79
79
  discoverModelsByProviderType,
80
80
  getImplicitOllamaBaseUrl,
81
81
  getOllamaContextLengthOverride,
@@ -873,10 +873,11 @@ export class ModelRegistry {
873
873
  if (!isLlamaCppDiscovery) {
874
874
  return model;
875
875
  }
876
- const contextWindow = await discoverLlamaCppModelContextWindow(model, this.#nonResolvingDiscoveryContext());
877
- if (contextWindow === undefined) {
876
+ const runtimeMetadata = await discoverLlamaCppModelRuntimeMetadata(model, this.#nonResolvingDiscoveryContext());
877
+ if (runtimeMetadata === undefined) {
878
878
  return this.find(model.provider, model.id) ?? model;
879
879
  }
880
+ const { contextWindow, maxTokens } = runtimeMetadata;
880
881
  const current = this.find(model.provider, model.id) ?? model;
881
882
  const override = this.#resolveLiveModelOverride(current);
882
883
  const customModel = this.#resolveLiveCustomModelOverlay(current);
@@ -888,13 +889,19 @@ export class ModelRegistry {
888
889
  ) {
889
890
  patch.contextWindow = contextWindow;
890
891
  }
891
- const maxTokens = Math.min(contextWindow, DISCOVERY_DEFAULT_MAX_TOKENS);
892
+ const effectiveContextWindow =
893
+ override?.contextWindow ??
894
+ customModel?.contextWindow ??
895
+ patch.contextWindow ??
896
+ current.contextWindow ??
897
+ contextWindow;
898
+ const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
892
899
  if (
893
900
  override?.maxTokens === undefined &&
894
901
  customModel?.maxTokens === undefined &&
895
- current.maxTokens !== maxTokens
902
+ current.maxTokens !== effectiveMaxTokens
896
903
  ) {
897
- patch.maxTokens = maxTokens;
904
+ patch.maxTokens = effectiveMaxTokens;
898
905
  }
899
906
  if (patch.contextWindow === undefined && patch.maxTokens === undefined) {
900
907
  return current;
@@ -27,6 +27,7 @@ import { ToolError } from "../../tools/tool-errors";
27
27
  import { generateDiffString } from "../diff";
28
28
  import { getFileSnapshotStore } from "../file-snapshot-store";
29
29
  import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
30
+ import { pruneOversizedEditSnapshots } from "../snapshot-details";
30
31
  import { nativeBlockResolver } from "./block-resolver";
31
32
  import { HashlineFilesystem } from "./filesystem";
32
33
  import { hashPatchInput, NOOP_HARD_LIMIT, recordNoopEdit, resetNoopEdit } from "./noop-loop-guard";
@@ -114,17 +115,22 @@ function renderSection(
114
115
  if (result.op === "delete") {
115
116
  const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
116
117
  content: [{ type: "text", text: `Deleted ${result.path}` }],
117
- details: {
118
+ details: pruneOversizedEditSnapshots({
118
119
  diff: "",
119
120
  op: "delete",
120
121
  path: result.path,
121
122
  oldText: result.before,
122
123
  meta: outputMeta().get(),
123
- },
124
+ }),
124
125
  };
125
126
  return {
126
127
  toolResult,
127
- perFileResult: { path: result.path, diff: "", op: "delete", oldText: result.before },
128
+ perFileResult: pruneOversizedEditSnapshots({
129
+ path: result.path,
130
+ diff: "",
131
+ op: "delete",
132
+ oldText: result.before,
133
+ }),
128
134
  };
129
135
  }
130
136
 
@@ -161,7 +167,7 @@ function renderSection(
161
167
  text: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`,
162
168
  },
163
169
  ],
164
- details: {
170
+ details: pruneOversizedEditSnapshots({
165
171
  diff: diff.diff,
166
172
  firstChangedLine,
167
173
  diagnostics,
@@ -172,9 +178,9 @@ function renderSection(
172
178
  oldText: result.before,
173
179
  newText: result.after,
174
180
  meta,
175
- },
181
+ }),
176
182
  },
177
- perFileResult: {
183
+ perFileResult: pruneOversizedEditSnapshots({
178
184
  path: result.moveDest ?? result.path,
179
185
  diff: diff.diff,
180
186
  firstChangedLine,
@@ -184,7 +190,7 @@ function renderSection(
184
190
  sourcePath: result.moveDest ? sourcePath : undefined,
185
191
  oldText: result.before,
186
192
  newText: result.after,
187
- },
193
+ }),
188
194
  };
189
195
  }
190
196
 
@@ -263,10 +269,10 @@ export async function executeHashlineSingle(
263
269
  .join("\n\n"),
264
270
  },
265
271
  ],
266
- details: {
272
+ details: pruneOversizedEditSnapshots({
267
273
  diff: rendered.map(r => r.toolResult.details?.diff ?? "").join("\n"),
268
274
  perFileResults: rendered.map(r => r.perFileResult),
269
- },
275
+ }),
270
276
  };
271
277
  }
272
278
 
package/src/edit/index.ts CHANGED
@@ -25,6 +25,7 @@ import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
25
25
  import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
26
26
  import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
27
27
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
28
+ import { pruneOversizedEditSnapshots } from "./snapshot-details";
28
29
  import { EDIT_MODE_STRATEGIES } from "./streaming";
29
30
 
30
31
  export * from "@oh-my-pi/hashline";
@@ -38,6 +39,7 @@ export * from "./modes/patch";
38
39
  export * from "./modes/replace";
39
40
  export * from "./normalize";
40
41
  export * from "./renderer";
42
+ export * from "./snapshot-details";
41
43
  export * from "./streaming";
42
44
 
43
45
  type TInput =
@@ -158,6 +160,7 @@ async function executeApplyPatchPerFile(
158
160
  meta: details?.meta,
159
161
  oldText: details?.oldText,
160
162
  newText: details?.newText,
163
+ snapshotsPruned: details?.snapshotsPruned,
161
164
  });
162
165
  const text = result.content?.find(c => c.type === "text")?.text ?? "";
163
166
  if (text) contentTexts.push(text);
@@ -186,14 +189,14 @@ async function executeApplyPatchPerFile(
186
189
 
187
190
  return {
188
191
  content: [{ type: "text", text: contentTexts.join("\n") }],
189
- details: {
192
+ details: pruneOversizedEditSnapshots({
190
193
  diff: perFileResults
191
194
  .map(r => r.diff)
192
195
  .filter(Boolean)
193
196
  .join("\n"),
194
197
  firstChangedLine: perFileResults.find(r => r.firstChangedLine)?.firstChangedLine,
195
198
  perFileResults,
196
- },
199
+ }),
197
200
  };
198
201
  }
199
202
 
@@ -216,6 +219,11 @@ async function executeSinglePathEntries(
216
219
  let firstOldText: string | undefined;
217
220
  let hasLastNewText = false;
218
221
  let lastNewText: string | undefined;
222
+ // Any pruned child invalidates the aggregate snapshot: combining a kept
223
+ // first-entry oldText with a pruned next entry's newText (or vice-versa)
224
+ // would describe a transition the file never made. Suppress aggregate
225
+ // snapshots and stamp the marker so ACP/downstream can degrade cleanly.
226
+ let snapshotsPruned = false;
219
227
 
220
228
  for (let i = 0; i < runs.length; i++) {
221
229
  const isLast = i === runs.length - 1;
@@ -239,6 +247,7 @@ async function executeSinglePathEntries(
239
247
  lastNewText = details.newText;
240
248
  hasLastNewText = true;
241
249
  }
250
+ if (details?.snapshotsPruned) snapshotsPruned = true;
242
251
  const text = result.content?.find(c => c.type === "text")?.text ?? "";
243
252
  if (text) contentTexts.push(text);
244
253
  } catch (err) {
@@ -276,13 +285,17 @@ async function executeSinglePathEntries(
276
285
 
277
286
  return {
278
287
  content: [{ type: "text", text: contentTexts.join("\n") }],
279
- details: {
288
+ details: pruneOversizedEditSnapshots({
280
289
  diff: diffTexts.join("\n"),
281
290
  firstChangedLine,
282
291
  path: metadataPath ?? path,
283
- ...(hasFirstOldText ? { oldText: firstOldText } : {}),
284
- ...(hasLastNewText ? { newText: lastNewText } : {}),
285
- },
292
+ ...(snapshotsPruned
293
+ ? { snapshotsPruned: true as const }
294
+ : {
295
+ ...(hasFirstOldText ? { oldText: firstOldText } : {}),
296
+ ...(hasLastNewText ? { newText: lastNewText } : {}),
297
+ }),
298
+ }),
286
299
  // Any per-entry failure marks the aggregate result as an error so the
287
300
  // renderer takes the error branch instead of falling through to the
288
301
  // streaming-edit preview (which displays the *proposed* diff and looks
@@ -48,6 +48,7 @@ import {
48
48
  } from "../normalize";
49
49
  import { readEditFileText, serializeEditFileText } from "../read-file";
50
50
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
51
+ import { pruneOversizedEditSnapshots } from "../snapshot-details";
51
52
  import {
52
53
  type ContextLineResult,
53
54
  DEFAULT_FUZZY_THRESHOLD,
@@ -1894,7 +1895,7 @@ export async function executePatchSingle(
1894
1895
 
1895
1896
  return {
1896
1897
  content: [{ type: "text", text: resultText }],
1897
- details: {
1898
+ details: pruneOversizedEditSnapshots({
1898
1899
  diff: diffResult.diff,
1899
1900
  // When the patch moves the file, anchor the diff to the destination
1900
1901
  // path. ACP `ToolCallContent.diff.path` comes from this field, and
@@ -1909,6 +1910,6 @@ export async function executePatchSingle(
1909
1910
  meta,
1910
1911
  oldText,
1911
1912
  newText,
1912
- },
1913
+ }),
1913
1914
  };
1914
1915
  }
@@ -24,6 +24,7 @@ import {
24
24
  } from "../normalize";
25
25
  import { readEditFileText, serializeEditFileText } from "../read-file";
26
26
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
27
+ import { pruneOversizedEditSnapshots } from "../snapshot-details";
27
28
 
28
29
  export interface FuzzyMatch {
29
30
  actualText: string;
@@ -1123,7 +1124,7 @@ export async function executeReplaceSingle(
1123
1124
 
1124
1125
  return {
1125
1126
  content: [{ type: "text", text: resultText }],
1126
- details: {
1127
+ details: pruneOversizedEditSnapshots({
1127
1128
  diff: diffResult.diff,
1128
1129
  path: absolutePath,
1129
1130
  firstChangedLine: diffResult.firstChangedLine,
@@ -1131,6 +1132,6 @@ export async function executeReplaceSingle(
1131
1132
  meta,
1132
1133
  oldText: rawContent,
1133
1134
  newText: finalContent,
1134
- },
1135
+ }),
1135
1136
  };
1136
1137
  }
@@ -70,6 +70,8 @@ export interface EditToolPerFileResult {
70
70
  oldText?: string;
71
71
  /** Source-of-truth content after the edit; `undefined` for delete operations. */
72
72
  newText?: string;
73
+ /** True when {@link pruneOversizedEditSnapshots} dropped `oldText`/`newText` from this entry. Aggregators check this to suppress misleading combined snapshots when at least one entry of a multi-entry single-path edit was pruned. */
74
+ snapshotsPruned?: boolean;
73
75
  /** Pre-move source path; set only when the edit moved/renamed the file. The header renders `sourcePath → path`. */
74
76
  sourcePath?: string;
75
77
  }
@@ -95,6 +97,8 @@ export interface EditToolDetails {
95
97
  oldText?: string;
96
98
  /** Source-of-truth content after the edit; `undefined` for delete operations. */
97
99
  newText?: string;
100
+ /** True when {@link pruneOversizedEditSnapshots} dropped `oldText`/`newText` from this entry. Aggregators check this to suppress misleading combined snapshots when at least one entry of a multi-entry single-path edit was pruned. */
101
+ snapshotsPruned?: boolean;
98
102
  /** Pre-move source path; set only when the edit moved/renamed the file. The header renders `sourcePath → path`. */
99
103
  sourcePath?: string;
100
104
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Bound the size of the `oldText` / `newText` snapshots that edit-tool results
3
+ * carry in `details`. These fields hold the full pre/post file content; for
4
+ * large files they balloon the per-turn JSONL line and the session file
5
+ * (300 KB+ each on the cases reported in #3786) without paying for any LLM
6
+ * context (provider serializers send only `content`, never `details`).
7
+ *
8
+ * Only consumer of the raw snapshots is the ACP event mapper, which builds a
9
+ * `diff` ToolCallContent for ACP clients (Zed). When the snapshots are pruned
10
+ * the mapper returns `undefined` for that file and the text content still
11
+ * flows — diff visualization degrades gracefully for over-threshold edits.
12
+ */
13
+
14
+ import type { EditToolDetails, EditToolPerFileResult } from "./renderer";
15
+
16
+ /**
17
+ * Combined `oldText` + `newText` character budget for a single edit-tool
18
+ * result. Applies both per-entry (one file at a time) and as an aggregate
19
+ * across `perFileResults` (so a many-small-files batch can't accumulate
20
+ * unbounded snapshot bytes — see #3787 review).
21
+ *
22
+ * Picked so typical code-file edits keep ACP diff visualization while
23
+ * pathological cases (large generated files, full-file rewrites, or
24
+ * many-file batches) drop the raw snapshots before they hit the
25
+ * session JSONL.
26
+ */
27
+ export const MAX_EDIT_SNAPSHOT_TEXT_CHARS = 32_768;
28
+
29
+ type WithSnapshot = { oldText?: string; newText?: string; snapshotsPruned?: boolean };
30
+
31
+ function pruneSnapshot<T extends WithSnapshot>(details: T): T {
32
+ if ((details.oldText?.length ?? 0) + (details.newText?.length ?? 0) <= MAX_EDIT_SNAPSHOT_TEXT_CHARS) {
33
+ return details;
34
+ }
35
+ const { oldText: _old, newText: _new, ...rest } = details;
36
+ return { ...rest, snapshotsPruned: true } as T;
37
+ }
38
+
39
+ /**
40
+ * Walk `perFileResults` in order with a shared budget. Each per-entry payload
41
+ * is first capped individually by {@link pruneSnapshot}; if its kept bytes
42
+ * would push the running aggregate past the cap, strip and mark this entry
43
+ * too. Early entries get to keep their diff visualization; later entries in
44
+ * a large batch degrade to text-only.
45
+ */
46
+ function capPerFileSnapshots<T extends WithSnapshot>(entries: T[]): T[] {
47
+ let remaining = MAX_EDIT_SNAPSHOT_TEXT_CHARS;
48
+ return entries.map(entry => {
49
+ const perEntry = pruneSnapshot(entry);
50
+ const kept = (perEntry.oldText?.length ?? 0) + (perEntry.newText?.length ?? 0);
51
+ if (kept === 0) return perEntry;
52
+ if (kept <= remaining) {
53
+ remaining -= kept;
54
+ return perEntry;
55
+ }
56
+ const { oldText: _old, newText: _new, ...rest } = perEntry;
57
+ return { ...rest, snapshotsPruned: true } as T;
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Prune oversized `oldText` / `newText` from an edit-tool details payload,
63
+ * recursing into `perFileResults` when present. Per-file overload comes first
64
+ * so the more specific shape (required `path`) wins overload resolution at
65
+ * the per-file call sites.
66
+ */
67
+ export function pruneOversizedEditSnapshots(details: EditToolPerFileResult): EditToolPerFileResult;
68
+ export function pruneOversizedEditSnapshots(details: EditToolDetails): EditToolDetails;
69
+ export function pruneOversizedEditSnapshots(
70
+ details: EditToolDetails | EditToolPerFileResult,
71
+ ): EditToolDetails | EditToolPerFileResult {
72
+ const pruned = pruneSnapshot(details);
73
+ if ("perFileResults" in pruned && pruned.perFileResults) {
74
+ return { ...pruned, perFileResults: capPerFileSnapshots(pruned.perFileResults) };
75
+ }
76
+ return pruned;
77
+ }
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import { isBuiltin } from "node:module";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
- import { isCompiledBinary } from "@oh-my-pi/pi-utils";
5
+ import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
6
6
  import { BUNDLED_PI_REGISTRY_KEYS } from "./legacy-pi-bundled-keys";
7
7
 
8
8
  const IS_COMPILED_BINARY = isCompiledBinary();
@@ -425,7 +425,7 @@ function toImportSpecifier(resolvedPath: string): string {
425
425
  if (isBundledVirtualSpecifier(resolvedPath)) {
426
426
  return resolvedPath;
427
427
  }
428
- return url.pathToFileURL(resolvedPath).href;
428
+ return url.pathToFileURL(stripWindowsExtendedLengthPathPrefix(resolvedPath)).href;
429
429
  }
430
430
 
431
431
  function rewriteLegacyPiImports(source: string): string {