@oh-my-pi/pi-ai 18.4.1 → 18.4.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.2] - 2026-09-28
6
+
7
+ ### Fixed
8
+
9
+ - Fixed successful Cursor agent turns being treated as context overflows, which ran overflow compaction and showed "Compaction freed too little context to make progress" while `/context` read well under the window; overflow detection now uses the reported context size instead of input totals summed across a turn's model calls ([#13608](https://github.com/can1357/oh-my-pi/pull/13608) by [@H4vC](https://github.com/H4vC))
10
+ - Fixed Anthropic requests with thinking enabled failing on models whose output ceiling cannot fit the minimum thinking budget; thinking is now disabled for those requests instead ([#13359](https://github.com/can1357/oh-my-pi/pull/13359) by [@jchanghong023](https://github.com/jchanghong023))
11
+ - Fixed Cursor native Grep/Glob results showing no matches or raw output, Write failing to create files, StrReplace missing edits beyond the read limit, and Read/Shell/Delete results misreporting content or metadata ([#13600](https://github.com/can1357/oh-my-pi/issues/13600)).
12
+
5
13
  ## [18.4.1] - 2026-09-28
6
14
 
7
15
  ### Fixed
@@ -126,9 +126,16 @@ export declare function classifyMessage(message: {
126
126
  export declare function attach<E extends object>(error: E, id: number): E;
127
127
  /** Overflow-classification evidence, including errors received before token usage is available. */
128
128
  export interface ContextOverflowMessage extends Pick<AssistantMessage, "errorId" | "stopReason" | "errorMessage"> {
129
- readonly usage?: Pick<Usage, "input" | "cacheRead" | "cacheWrite">;
129
+ readonly usage?: Pick<Usage, "input" | "cacheRead" | "cacheWrite" | "contextTokens">;
130
130
  }
131
- /** Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235). */
131
+ /**
132
+ * Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235).
133
+ *
134
+ * Prefers `contextTokens` when the provider reports it: providers that run
135
+ * several model calls per turn (Cursor's server-side tool loop) report
136
+ * `input`/`cacheRead` summed across those calls, which can exceed the window
137
+ * many times over while the conversation itself stays small.
138
+ */
132
139
  export declare function isUsageBackedContextOverflow(message: ContextOverflowMessage, contextWindow?: number): boolean;
133
140
  /** Classify overflow from error flags, available token usage, or provider error text. */
134
141
  export declare function isContextOverflow(message: ContextOverflowMessage, contextWindow?: number): boolean;
@@ -17,7 +17,7 @@ import type { ToolResultMessage } from "../../types.js";
17
17
  * virtual registry. Re-exported here because this is where the frame builders
18
18
  * and their translation are consumed together.
19
19
  */
20
- export { cursorEditOwnedReadPath, cursorRawReadPath, omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, piLimit, piLsPath, piReadDisplayPath, piReadPath, piReadPathHasRange, piTimeout, shellTimeoutSeconds, } from "../cursor-pi-args.js";
20
+ export { cursorExecReadPath, cursorRawReadPath, omitUndefinedArgs, piEscapeRegexLiteral, piGrepSkip, piJoinPath, piLimit, piLsPath, piReadDisplayPath, piReadPath, piReadPathHasRange, piTimeout, shellTimeoutSeconds, } from "../cursor-pi-args.js";
21
21
  /** Flatten a tool result's content into the single `output` string the Pi frames carry. */
22
22
  export declare function piOutputText(toolResult: ToolResultMessage): string;
23
23
  /**
@@ -22,18 +22,14 @@
22
22
  * A `pi_read` range composed onto the path as `read`'s inline `:raw:N+K`
23
23
  * selector.
24
24
  *
25
- * `read` exposes no range kwargs, so an uncomposed range reads the whole file.
26
- * `offset` is a 1-indexed start clamped like the reference's
27
- * `Math.max(0, offset - 1)` over 0-indexed lines; `limit` is a line count.
28
- * `null` marks a present `limit: 0` — zero lines, which no selector expresses
29
- * and which must not degrade into a whole-file read.
30
- *
31
- * The range is `raw` because a plain `:N+K` deliberately pads with one leading
32
- * and three trailing context lines: helpful for a human reading a snippet,
33
- * wrong for a caller that asked for exactly `limit` lines from `offset`. The
34
- * wire result is an opaque `output` string, so the hashline and line-number
35
- * gutter that `raw` also drops carry nothing the frame's contract needs.
36
- * A range-free read keeps the ordinary form — whole-file reads want them.
25
+ * `read` exposes no range kwargs; `offset` and `limit` are composed onto the
26
+ * path. A negative offset needs the source line count and is resolved by the
27
+ * coding-agent bridge before calling this helper. `limit: 0` has no selector
28
+ * representation and returns `null`.
29
+ *
30
+ * Range selectors are raw because plain ranges add context lines. Cursor
31
+ * numbers the returned text itself, so it cannot use read's hashline gutter.
32
+ * Use [`cursorExecReadPath`] for a range-free read, which also needs `:raw`.
37
33
  */
38
34
  export declare function piReadPath(readPath: string, offset?: number, limit?: number): string | null;
39
35
  /**
@@ -55,14 +51,13 @@ export declare function piReadPathHasRange(readPath: string): boolean;
55
51
  */
56
52
  export declare function cursorRawReadPath(readPath: string): string;
57
53
  /**
58
- * Path the edit-owned materialization read should execute.
54
+ * Raw selector for a Cursor exec read, including edit-owned materialization.
59
55
  *
60
- * Range is composed first (`piReadPath` already uses `:raw` for a range),
61
- * then a whole-file path is forced onto `:raw`. The caller must drop
62
- * `offset`/`limit` after this so the bridge's `piReadPath` cannot append a
63
- * second `:raw` onto the already-composed selector.
56
+ * Compose a requested window before forcing `:raw` on whole-file reads. The
57
+ * caller drops `offset`/`limit` after composing so the handler cannot append
58
+ * another selector.
64
59
  */
65
- export declare function cursorEditOwnedReadPath(readPath: string, offset?: number, limit?: number): string | null;
60
+ export declare function cursorExecReadPath(readPath: string, offset?: number, limit?: number): string | null;
66
61
  /**
67
62
  * The same range as {@link piReadPath}, rendered for a transcript block rather
68
63
  * than for execution.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.4.1",
3
+ "version": "18.4.2",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.4.1",
159
- "@oh-my-pi/pi-catalog": "18.4.1",
160
- "@oh-my-pi/pi-natives": "18.4.1",
161
- "@oh-my-pi/pi-utils": "18.4.1",
162
- "@oh-my-pi/pi-wire": "18.4.1"
158
+ "@oh-my-pi/omptype": "18.4.2",
159
+ "@oh-my-pi/pi-catalog": "18.4.2",
160
+ "@oh-my-pi/pi-natives": "18.4.2",
161
+ "@oh-my-pi/pi-utils": "18.4.2",
162
+ "@oh-my-pi/pi-wire": "18.4.2"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -875,14 +875,21 @@ export function attach<E extends object>(error: E, id: number): E {
875
875
 
876
876
  /** Overflow-classification evidence, including errors received before token usage is available. */
877
877
  export interface ContextOverflowMessage extends Pick<AssistantMessage, "errorId" | "stopReason" | "errorMessage"> {
878
- readonly usage?: Pick<Usage, "input" | "cacheRead" | "cacheWrite">;
878
+ readonly usage?: Pick<Usage, "input" | "cacheRead" | "cacheWrite" | "contextTokens">;
879
879
  }
880
880
 
881
- /** Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235). */
881
+ /**
882
+ * Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235).
883
+ *
884
+ * Prefers `contextTokens` when the provider reports it: providers that run
885
+ * several model calls per turn (Cursor's server-side tool loop) report
886
+ * `input`/`cacheRead` summed across those calls, which can exceed the window
887
+ * many times over while the conversation itself stays small.
888
+ */
882
889
  export function isUsageBackedContextOverflow(message: ContextOverflowMessage, contextWindow?: number): boolean {
883
890
  const usage = message.usage;
884
891
  if (!contextWindow || !usage) return false;
885
- const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
892
+ const inputTokens = usage.contextTokens ?? usage.input + usage.cacheRead + usage.cacheWrite;
886
893
  return inputTokens > contextWindow;
887
894
  }
888
895
 
@@ -74,7 +74,7 @@ import type { ToolResultMessage } from "../../types";
74
74
  * and their translation are consumed together.
75
75
  */
76
76
  export {
77
- cursorEditOwnedReadPath,
77
+ cursorExecReadPath,
78
78
  cursorRawReadPath,
79
79
  omitUndefinedArgs,
80
80
  piEscapeRegexLiteral,
@@ -25,18 +25,14 @@ import * as path from "node:path";
25
25
  * A `pi_read` range composed onto the path as `read`'s inline `:raw:N+K`
26
26
  * selector.
27
27
  *
28
- * `read` exposes no range kwargs, so an uncomposed range reads the whole file.
29
- * `offset` is a 1-indexed start clamped like the reference's
30
- * `Math.max(0, offset - 1)` over 0-indexed lines; `limit` is a line count.
31
- * `null` marks a present `limit: 0` — zero lines, which no selector expresses
32
- * and which must not degrade into a whole-file read.
33
- *
34
- * The range is `raw` because a plain `:N+K` deliberately pads with one leading
35
- * and three trailing context lines: helpful for a human reading a snippet,
36
- * wrong for a caller that asked for exactly `limit` lines from `offset`. The
37
- * wire result is an opaque `output` string, so the hashline and line-number
38
- * gutter that `raw` also drops carry nothing the frame's contract needs.
39
- * A range-free read keeps the ordinary form — whole-file reads want them.
28
+ * `read` exposes no range kwargs; `offset` and `limit` are composed onto the
29
+ * path. A negative offset needs the source line count and is resolved by the
30
+ * coding-agent bridge before calling this helper. `limit: 0` has no selector
31
+ * representation and returns `null`.
32
+ *
33
+ * Range selectors are raw because plain ranges add context lines. Cursor
34
+ * numbers the returned text itself, so it cannot use read's hashline gutter.
35
+ * Use [`cursorExecReadPath`] for a range-free read, which also needs `:raw`.
40
36
  */
41
37
  export function piReadPath(readPath: string, offset?: number, limit?: number): string | null {
42
38
  if (limit !== undefined && Math.floor(limit) <= 0) return null;
@@ -100,14 +96,13 @@ export function cursorRawReadPath(readPath: string): string {
100
96
  }
101
97
 
102
98
  /**
103
- * Path the edit-owned materialization read should execute.
99
+ * Raw selector for a Cursor exec read, including edit-owned materialization.
104
100
  *
105
- * Range is composed first (`piReadPath` already uses `:raw` for a range),
106
- * then a whole-file path is forced onto `:raw`. The caller must drop
107
- * `offset`/`limit` after this so the bridge's `piReadPath` cannot append a
108
- * second `:raw` onto the already-composed selector.
101
+ * Compose a requested window before forcing `:raw` on whole-file reads. The
102
+ * caller drops `offset`/`limit` after composing so the handler cannot append
103
+ * another selector.
109
104
  */
110
- export function cursorEditOwnedReadPath(readPath: string, offset?: number, limit?: number): string | null {
105
+ export function cursorExecReadPath(readPath: string, offset?: number, limit?: number): string | null {
111
106
  const ranged = piReadPath(readPath, offset, limit);
112
107
  if (ranged === null) return null;
113
108
  return cursorRawReadPath(ranged);
@@ -59,7 +59,6 @@ import {
59
59
  GrepContentResultSchema,
60
60
  GrepCountResultSchema,
61
61
  GrepErrorSchema,
62
- type GrepFileCount,
63
62
  GrepFileCountSchema,
64
63
  GrepFileMatchSchema,
65
64
  GrepFilesResultSchema,
@@ -99,6 +98,7 @@ import {
99
98
  McpToolResultSchema,
100
99
  ModelDetailsSchema,
101
100
  ReadErrorSchema,
101
+ ReadFileNotFoundSchema,
102
102
  ReadMcpResourceErrorSchema,
103
103
  type ReadMcpResourceExecResult,
104
104
  ReadMcpResourceExecResultSchema,
@@ -233,7 +233,8 @@ import {
233
233
  buildPiWriteError,
234
234
  buildPiWriteRejected,
235
235
  buildPiWriteResult,
236
- cursorEditOwnedReadPath,
236
+ cursorExecReadPath,
237
+ cursorRawReadPath,
237
238
  omitUndefinedArgs,
238
239
  piEscapeRegexLiteral,
239
240
  piGrepSkip,
@@ -1654,39 +1655,93 @@ async function handleExecServerMessage(
1654
1655
  const args = execMsg.message.value;
1655
1656
  if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1656
1657
  const editOwned = isEditOwnedToolCallId(state, output, args.toolCallId);
1657
- // Native StrReplace materializes by reading then writing the same
1658
- // toolCallId. The server treats the read result as file bytes, so a
1659
- // hashline-formatted native read would be written back as markup.
1660
- // Force `:raw` and skip the extra transcript block — the edit card
1661
- // already owns this id.
1662
- const composed = editOwned ? cursorEditOwnedReadPath(args.path, args.offset, args.limit) : args.path;
1663
- const handlerArgs = editOwned
1664
- ? composed === null
1658
+ // Cursor numbers raw content itself; StrReplace also writes it back.
1659
+ // Neither frame may receive hashline gutters or summarized bodies.
1660
+ // Edit-owned reads omit the extra transcript block owned by the edit card.
1661
+ // The handler needs the original negative offset to resolve it against
1662
+ // the file length; positive windows can be composed before dispatch.
1663
+ const negativeOffset = args.offset !== undefined && args.offset < 0;
1664
+ const composed = negativeOffset
1665
+ ? cursorRawReadPath(args.path)
1666
+ : cursorExecReadPath(args.path, args.offset, args.limit);
1667
+ const handlerArgs =
1668
+ composed === null
1665
1669
  ? args
1666
- : { ...args, path: composed, offset: undefined, limit: undefined }
1667
- : args;
1670
+ : {
1671
+ ...args,
1672
+ path: composed,
1673
+ offset: negativeOffset ? args.offset : undefined,
1674
+ limit: negativeOffset ? args.limit : undefined,
1675
+ };
1668
1676
  if (!editOwned) {
1669
- // The same composed selector the bridge executes: showing a bare path
1670
- // for a ranged read makes the returned slice look like the whole
1671
- // file in every rebuilt transcript.
1672
- synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", {
1673
- path: piReadDisplayPath(args.path, args.offset, args.limit),
1674
- });
1677
+ synthesizeCursorExecToolCall(
1678
+ output,
1679
+ stream,
1680
+ state,
1681
+ args.toolCallId,
1682
+ "read",
1683
+ negativeOffset
1684
+ ? { path: composed, offset: args.offset, limit: args.limit }
1685
+ : { path: piReadDisplayPath(args.path, args.offset, args.limit) },
1686
+ );
1675
1687
  }
1676
- const { execResult } = await resolveExecHandler(
1688
+ const { execResult: readResult, toolResult } = await resolveExecHandler(
1677
1689
  handlerArgs,
1678
1690
  execHandlers?.read?.bind(execHandlers),
1679
1691
  editOwned ? undefined : onToolResult,
1680
- toolResult =>
1692
+ result =>
1681
1693
  buildReadResultFromToolResult(
1682
1694
  args.path,
1683
- toolResult,
1695
+ result,
1684
1696
  args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path),
1685
1697
  ),
1686
1698
  reason => buildReadRejectedResult(args.path, reason),
1687
1699
  error => buildReadErrorResult(args.path, error),
1688
1700
  editOwned ? null : { toolCallId: args.toolCallId, toolName: "read" },
1689
1701
  );
1702
+ let execResult = readResult;
1703
+ // StrReplace writes the returned bytes back. A truncated raw read would
1704
+ // make every replacement below the read limit invisible to the server.
1705
+ if (
1706
+ editOwned &&
1707
+ args.offset === undefined &&
1708
+ args.limit === undefined &&
1709
+ !piReadPathHasRange(args.path) &&
1710
+ toolResult &&
1711
+ !toolResult.isError &&
1712
+ toolResultWasTruncated(toolResult)
1713
+ ) {
1714
+ const details = toolResult.details;
1715
+ const source = details && typeof details === "object" && "meta" in details && details.meta;
1716
+ const path = source && typeof source === "object" && "source" in source && source.source;
1717
+ if (
1718
+ path &&
1719
+ typeof path === "object" &&
1720
+ "type" in path &&
1721
+ path.type === "path" &&
1722
+ "value" in path &&
1723
+ typeof path.value === "string"
1724
+ ) {
1725
+ try {
1726
+ const content = await readEditMaterialization(path.value);
1727
+ execResult =
1728
+ content === null
1729
+ ? buildReadErrorResult(
1730
+ args.path,
1731
+ `File exceeds ${EDIT_MATERIALIZATION_MAX_BYTES} bytes; StrReplace cannot load it whole. Use a line-range read and a targeted edit instead.`,
1732
+ )
1733
+ : buildReadResultFromToolResult(args.path, {
1734
+ ...toolResult,
1735
+ content: [{ type: "text", text: content }],
1736
+ details: { fileSize: Buffer.byteLength(content, "utf8") },
1737
+ });
1738
+ } catch (error) {
1739
+ execResult = buildReadErrorResult(args.path, error instanceof Error ? error.message : String(error));
1740
+ }
1741
+ } else {
1742
+ execResult = buildReadErrorResult(args.path, "Unable to read the complete file for StrReplace");
1743
+ }
1744
+ }
1690
1745
  sendExecClientMessage(h2Request, execMsg, "readResult", execResult);
1691
1746
  return;
1692
1747
  }
@@ -2842,9 +2897,50 @@ function readFileSizeFromDetails(toolResult: ToolResultMessage): number | undefi
2842
2897
  return typeof fileSize === "number" && Number.isSafeInteger(fileSize) && fileSize >= 0 ? fileSize : undefined;
2843
2898
  }
2844
2899
 
2900
+ /**
2901
+ * Largest file a native StrReplace materializes whole; matches the local
2902
+ * `read` tool's whole-file snapshot cap (`SNAPSHOT_MAX_BYTES`).
2903
+ */
2904
+ const EDIT_MATERIALIZATION_MAX_BYTES = 4 * 1024 * 1024;
2905
+
2906
+ /**
2907
+ * Read a file for native StrReplace materialization, or `null` when it exceeds
2908
+ * {@link EDIT_MATERIALIZATION_MAX_BYTES}. The buffer is sized from the handle's
2909
+ * stat and reading stops one byte past it, so a file growing during the read
2910
+ * cannot exhaust memory.
2911
+ *
2912
+ * Throws when the file grew during the read while still under the cap.
2913
+ */
2914
+ async function readEditMaterialization(filePath: string): Promise<string | null> {
2915
+ const handle = await fs.open(filePath, "r");
2916
+ try {
2917
+ const { size } = await handle.stat();
2918
+ if (size > EDIT_MATERIALIZATION_MAX_BYTES) return null;
2919
+ const buffer = Buffer.allocUnsafe(size + 1);
2920
+ let length = 0;
2921
+ while (length < buffer.length) {
2922
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
2923
+ if (bytesRead === 0) break;
2924
+ length += bytesRead;
2925
+ }
2926
+ if (length > size) {
2927
+ if (size === EDIT_MATERIALIZATION_MAX_BYTES) return null;
2928
+ throw new Error(`File changed while reading: ${filePath}`);
2929
+ }
2930
+ return buffer.toString("utf8", 0, length);
2931
+ } finally {
2932
+ await handle.close();
2933
+ }
2934
+ }
2935
+
2845
2936
  function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessage, rangeApplied = false) {
2846
2937
  const text = toolResultToText(toolResult);
2847
2938
  if (toolResult.isError) {
2939
+ if (/^Path '.*' not found$/.test(text)) {
2940
+ return create(ReadResultSchema, {
2941
+ result: { case: "fileNotFound", value: create(ReadFileNotFoundSchema, { path }) },
2942
+ });
2943
+ }
2848
2944
  return buildReadErrorResult(path, text || "Read failed");
2849
2945
  }
2850
2946
  // Counting the payload is only the file's length when the payload is the
@@ -2942,7 +3038,7 @@ function buildDeleteResultFromToolResult(path: string, toolResult: ToolResultMes
2942
3038
  value: create(DeleteSuccessSchema, {
2943
3039
  path,
2944
3040
  deletedFile: path,
2945
- fileSize: BigInt(0),
3041
+ fileSize: BigInt(readFileSizeFromDetails(toolResult) ?? 0),
2946
3042
  prevContent: "",
2947
3043
  }),
2948
3044
  },
@@ -2973,7 +3069,14 @@ function buildShellResultFromToolResult(
2973
3069
  ) {
2974
3070
  const output = toolResultToText(toolResult);
2975
3071
  if (toolResult.isError) {
2976
- return buildShellFailureResult(args.command, args.workingDirectory, output || "Shell failed");
3072
+ const details = toolResult.details;
3073
+ const code = details && typeof details === "object" && "exitCode" in details ? details.exitCode : undefined;
3074
+ return buildShellFailureResult(
3075
+ args.command,
3076
+ args.workingDirectory,
3077
+ output || "Shell failed",
3078
+ typeof code === "number" && Number.isInteger(code) ? code : 1,
3079
+ );
2977
3080
  }
2978
3081
  return create(ShellResultSchema, {
2979
3082
  result: {
@@ -2991,14 +3094,14 @@ function buildShellResultFromToolResult(
2991
3094
  });
2992
3095
  }
2993
3096
 
2994
- function buildShellFailureResult(command: string, workingDirectory: string, error: string) {
3097
+ function buildShellFailureResult(command: string, workingDirectory: string, error: string, exitCode = 1) {
2995
3098
  return create(ShellResultSchema, {
2996
3099
  result: {
2997
3100
  case: "failure",
2998
3101
  value: create(ShellFailureSchema, {
2999
3102
  command,
3000
3103
  workingDirectory,
3001
- exitCode: 1,
3104
+ exitCode,
3002
3105
  signal: "",
3003
3106
  stdout: "",
3004
3107
  stderr: error,
@@ -3101,16 +3204,16 @@ function buildGrepResultFromToolResult(
3101
3204
 
3102
3205
  const outputMode = args.outputMode || "content";
3103
3206
  const clientTruncated = toolResultDetailBoolean(toolResult, "truncated");
3104
- const lines = text
3105
- .split("\n")
3106
- .map(line => line.trimEnd())
3107
- .filter(line => line.length > 0 && !line.startsWith("[") && !line.toLowerCase().startsWith("no matches"));
3207
+ const details = toolResult.details;
3208
+ const files =
3209
+ details && typeof details === "object" && "files" in details && Array.isArray(details.files)
3210
+ ? details.files.filter((file): file is string => typeof file === "string")
3211
+ : [];
3108
3212
 
3109
3213
  const workspaceKey = args.path || ".";
3110
3214
  let unionResult: GrepUnionResult;
3111
3215
 
3112
3216
  if (outputMode === "files_with_matches") {
3113
- const files = lines;
3114
3217
  unionResult = create(GrepUnionResultSchema, {
3115
3218
  result: {
3116
3219
  case: "files",
@@ -3128,20 +3231,21 @@ function buildGrepResultFromToolResult(
3128
3231
  },
3129
3232
  });
3130
3233
  } else if (outputMode === "count") {
3131
- const counts = lines
3132
- .map(line => {
3133
- const separatorIndex = line.lastIndexOf(":");
3134
- if (separatorIndex === -1) {
3135
- return null;
3136
- }
3137
- const file = line.slice(0, separatorIndex);
3138
- const count = Number.parseInt(line.slice(separatorIndex + 1), 10);
3139
- if (!file || Number.isNaN(count)) {
3140
- return null;
3141
- }
3142
- return create(GrepFileCountSchema, { file, count });
3143
- })
3144
- .filter((entry): entry is GrepFileCount => entry !== null);
3234
+ const fileMatches =
3235
+ details && typeof details === "object" && "fileMatches" in details && Array.isArray(details.fileMatches)
3236
+ ? details.fileMatches
3237
+ : [];
3238
+ const counts = fileMatches
3239
+ .filter(
3240
+ (entry): entry is { path: string; count: number } =>
3241
+ entry !== null &&
3242
+ typeof entry === "object" &&
3243
+ "path" in entry &&
3244
+ typeof entry.path === "string" &&
3245
+ "count" in entry &&
3246
+ typeof entry.count === "number",
3247
+ )
3248
+ .map(({ path, count }) => create(GrepFileCountSchema, { file: path, count }));
3145
3249
  const totalMatches = counts.reduce((sum, entry) => sum + entry.count, 0);
3146
3250
  unionResult = create(GrepUnionResultSchema, {
3147
3251
  result: {
@@ -3159,22 +3263,40 @@ function buildGrepResultFromToolResult(
3159
3263
  } else {
3160
3264
  const matchMap = new Map<string, Array<{ line: number; content: string; isContextLine: boolean }>>();
3161
3265
  let totalMatchedLines = 0;
3162
-
3163
- for (const line of lines) {
3164
- const matchLine = line.match(/^(.+?):(\d+):\s?(.*)$/);
3165
- const contextLine = line.match(/^(.+?)-(\d+)-\s?(.*)$/);
3166
- const match = matchLine ?? contextLine;
3167
- if (!match) {
3266
+ const directories = new Map<number, string>();
3267
+ let currentFile = files.length === 1 ? files[0] : undefined;
3268
+
3269
+ for (const line of text.split("\n")) {
3270
+ const header = /^(#+) (.+)$/.exec(line);
3271
+ if (header) {
3272
+ const depth = header[1]!.length;
3273
+ const name = header[2]!;
3274
+ for (const level of directories.keys()) {
3275
+ if (level >= depth) directories.delete(level);
3276
+ }
3277
+ const parent = directories.get(depth - 1);
3278
+ const candidate = parent ? `${parent}/${name}` : name;
3279
+ if (name.endsWith("/")) {
3280
+ directories.set(depth, candidate.slice(0, -1));
3281
+ currentFile = undefined;
3282
+ } else {
3283
+ currentFile = files.includes(candidate) ? candidate : candidate.replace(/#[0-9a-f]{4,}$/i, "");
3284
+ }
3168
3285
  continue;
3169
3286
  }
3170
- const [, file, lineNumber, content] = match;
3171
- const isContextLine = Boolean(contextLine);
3172
- const list = matchMap.get(file) ?? [];
3173
- list.push({ line: Number(lineNumber), content, isContextLine });
3174
- matchMap.set(file, list);
3175
- if (!isContextLine) {
3176
- totalMatchedLines += 1;
3287
+ const singleHeader = /^\[(.+)#[0-9a-f]{4,}\]$/i.exec(line);
3288
+ if (singleHeader) {
3289
+ currentFile = files[0] ?? singleHeader[1]!;
3290
+ continue;
3177
3291
  }
3292
+ const match = /^([* ])(\d+)[:|](.*)$/.exec(line);
3293
+ if (!match || !currentFile) continue;
3294
+ const [, marker, lineNumber, content] = match;
3295
+ const isContextLine = marker === " ";
3296
+ const list = matchMap.get(currentFile) ?? [];
3297
+ list.push({ line: Number(lineNumber), content, isContextLine });
3298
+ matchMap.set(currentFile, list);
3299
+ if (!isContextLine) totalMatchedLines++;
3178
3300
  }
3179
3301
 
3180
3302
  const matches = Array.from(matchMap.entries()).map(([file, matches]) =>
package/src/stream.ts CHANGED
@@ -1932,29 +1932,39 @@ function mapOptionsForApi<TApi extends Api>(
1932
1932
  }
1933
1933
 
1934
1934
  if (ANTHROPIC_USE_INTERLEAVED_THINKING) {
1935
- return castApi<"anthropic-messages">({
1936
- ...base,
1937
- maxTokens: maxTokensWithThinking,
1938
- requestModelId: resolveWireModelId(model, reasoning),
1939
- thinkingEnabled: true,
1940
- thinkingBudgetTokens: thinkingBudget,
1941
- effort,
1942
- toolChoice: mapAnthropicToolChoice(options?.toolChoice),
1943
- thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
1944
- serviceTier: options?.serviceTier,
1945
- });
1935
+ if (
1936
+ model.maxTokens !== null &&
1937
+ model.maxTokens !== undefined &&
1938
+ model.maxTokens < thinkingBudget + OUTPUT_FALLBACK_BUFFER
1939
+ ) {
1940
+ thinkingBudget = model.maxTokens - OUTPUT_FALLBACK_BUFFER;
1941
+ }
1942
+ if (thinkingBudget >= ANTHROPIC_THINKING.minimal) {
1943
+ return castApi<"anthropic-messages">({
1944
+ ...base,
1945
+ maxTokens: maxTokensWithThinking,
1946
+ requestModelId: resolveWireModelId(model, reasoning),
1947
+ thinkingEnabled: true,
1948
+ thinkingBudgetTokens: thinkingBudget,
1949
+ effort,
1950
+ toolChoice: mapAnthropicToolChoice(options?.toolChoice),
1951
+ thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
1952
+ serviceTier: options?.serviceTier,
1953
+ });
1954
+ }
1946
1955
  }
1947
1956
 
1948
1957
  // Caller's maxTokens is desired output, so add thinking budget on top. With no caller/model cap, use a finite total fallback.
1949
1958
  const maxTokens = maxTokensWithThinkingBudget(base.maxTokens, model.maxTokens, thinkingBudget);
1950
1959
 
1951
- // If not enough room for thinking + output, reduce thinking budget
1952
- if (maxTokens <= thinkingBudget) {
1953
- thinkingBudget = maxTokens - MIN_OUTPUT_TOKENS;
1960
+ // Keep the provider's output buffer after thinking, reducing the
1961
+ // budget before its wire-level clamp could fall below the API minimum.
1962
+ if (maxTokens < thinkingBudget + OUTPUT_FALLBACK_BUFFER) {
1963
+ thinkingBudget = maxTokens - OUTPUT_FALLBACK_BUFFER;
1954
1964
  }
1955
1965
 
1956
1966
  // If thinking budget is too low, disable thinking
1957
- if (thinkingBudget <= 0) {
1967
+ if (thinkingBudget < ANTHROPIC_THINKING.minimal) {
1958
1968
  return castApi<"anthropic-messages">({
1959
1969
  ...base,
1960
1970
  requestModelId: resolveWireModelId(model, undefined),