@oh-my-pi/pi-ai 18.4.1 → 18.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.3] - 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - Added Command Code usage limits (5-hour, weekly, and credit balance) to /usage and the status line ([#13666](https://github.com/can1357/oh-my-pi/pull/13666) by [@riicodespretty](https://github.com/riicodespretty))
10
+
11
+ ### Changed
12
+
13
+ - Reduced per-token CPU and allocations while streaming: the leaked-thinking scanner used for OpenAI-compatible and custom endpoints no longer allocates per character, chat-completions and Bedrock look up a delta's content block in constant time, Google, Gemini CLI, Codex, and chat-completions streams skip raw SSE line capture unless an `onSseEvent` listener is attached, and event streams drain backlogs without `Array#shift` ([#13650](https://github.com/can1357/oh-my-pi/pull/13650) by [@H4vC](https://github.com/H4vC)).
14
+
15
+ ## [18.4.2] - 2026-09-28
16
+
17
+ ### Fixed
18
+
19
+ - 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))
20
+ - 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))
21
+ - 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)).
22
+
5
23
  ## [18.4.1] - 2026-09-28
6
24
 
7
25
  ### Fixed
@@ -2298,28 +2316,4 @@
2298
2316
 
2299
2317
  - Fixed `google-gemini-cli` ignoring `Model.requestModelId` when serializing the request model id
2300
2318
 
2301
- ## [15.11.5] - 2026-06-12
2302
-
2303
- ### Added
2304
-
2305
- - Added `AuthStorage.listUsageHistory` to retrieve historical usage snapshots with optional `provider` and `sinceMs` filtering
2306
- - Added durable usage-history persistence in the sqlite auth store so successful usage reports are recorded as time-series snapshots of limit utilization for later trend inspection
2307
- - Added `AuthStorage.redeemResetCredit` to redeem stored OpenAI Codex saved rate-limit reset credits for a target account by `credentialId`, `accountId`, or `email`
2308
- - Added `listCodexResetCredits` and `consumeCodexResetCredit` exports for OpenAI Codex saved reset-credit listing and redemption
2309
- - Added `resetCredits` with `availableCount` to `UsageReport` so OpenAI Codex usage data now exposes redeemable rate-limit resets
2310
- - Added `openai-codex-reset` exports via package barrel for out-of-band tooling usage
2311
- - Added a one-shot request-debug target that writes the next provider HTTP request JSON to an explicit path.
2312
-
2313
- ### Changed
2314
-
2315
- - Changed `AuthStorage.redeemResetCredit` to invalidate cached usage data after a successful redemption so the next usage report reflects the reset immediately
2316
-
2317
- ### Fixed
2318
-
2319
- - Fixed temporary credential block state so redeemed reset credits immediately make the affected account selectable again after `redeemResetCredit` succeeds
2320
- - Fixed one-shot request-debug path handling so an explicit request log target is consumed after the next request and no longer affects subsequent calls
2321
- - Fixed explicit request-debug path mode to create missing parent directories before writing request logs
2322
- - Fixed explicit request-debug mode to overwrite existing `.res.log` files for the requested path instead of failing when they already exist
2323
- - Fixed OpenAI Responses `previous_response_id` chaining on Zero Data Retention orgs: the in-provider retry classifier missed the ZDR-specific 400 ("Previous response cannot be used for this organization due to Zero Data Retention"), so chained turns kept failing every other request after a brief recovery — the chain was reset but not disabled, so the next successful full-replay turn re-armed it. The ZDR phrasing is now classified categorically: one strike disables chaining for the session (skipping the three-strike circuit breaker) and the in-call retry drops `store: true`/`previous_response_id` and replays the full transcript instead ([#2341](https://github.com/can1357/oh-my-pi/issues/2341)).
2324
-
2325
- Older entries are archived in [packages/ai/CHANGELOG.md@dfbf3cc34eeb](https://github.com/can1357/oh-my-pi/blob/dfbf3cc34eeb5653580f51bfbcae558a9840f697/packages/ai/CHANGELOG.md).
2319
+ Older entries are archived in [packages/ai/CHANGELOG.md@689a3418cb45](https://github.com/can1357/oh-my-pi/blob/689a3418cb45d54a459cde2e1abf3f66f50e47a4/packages/ai/CHANGELOG.md).
@@ -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;
@@ -1,3 +1,3 @@
1
- import type { Model } from "@oh-my-pi/pi-catalog/types";
1
+ import { type Model } from "@oh-my-pi/pi-catalog/types";
2
2
  import type { ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types.js";
3
3
  export declare function generateHostedImage(model: Model, request: ImageGenerationRequest, options: ImageGenerationOptions): Promise<ImageGenerationResult>;
@@ -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.
@@ -0,0 +1,4 @@
1
+ import type { CredentialRankingStrategy, UsageProvider } from "../usage.js";
2
+ export declare const commandCodeUsageProvider: UsageProvider;
3
+ /** Ranks Command Code accounts by the 5-hour and weekly credit windows. */
4
+ export declare const commandCodeRankingStrategy: CredentialRankingStrategy;
@@ -5,6 +5,13 @@ export interface LocalWorkSource {
5
5
  }
6
6
  export declare class EventStream<T, R = T> implements AsyncIterable<T> {
7
7
  #private;
8
+ /**
9
+ * Events pushed while no consumer was waiting. The iterator dequeues by
10
+ * advancing {@link #queueHead} instead of `shift()` — O(remaining) per event,
11
+ * quadratic for a consumer draining a backlog — so while it drains, the
12
+ * slots before the head are consumed (cleared) placeholders. Do not mutate
13
+ * the array while the stream is being iterated.
14
+ */
8
15
  queue: T[];
9
16
  waiting: Array<{
10
17
  resolve: (value: IteratorResult<T>) => void;
@@ -28,8 +28,14 @@ export interface OpenAIStreamRequestInit {
28
28
  fetch?: FetchImpl;
29
29
  /** Optional caller-specific gate composed with shared transport retry exclusions. */
30
30
  shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
31
- /** Raw wire-frame observer (`onSseEvent` debug pipeline). */
31
+ /**
32
+ * Raw wire-frame observer (`onSseEvent` debug pipeline). Leave it unset
33
+ * when no diagnostic listener exists: any observer turns on per-line raw
34
+ * capture for every frame.
35
+ */
32
36
  onSseEvent?: SseEventObserver;
37
+ /** Called when the stream ends on the OpenAI `[DONE]` sentinel; independent of {@link onSseEvent}. */
38
+ onDoneSentinel?: () => void;
33
39
  }
34
40
  export interface OpenAIStreamHandle<TEvent> {
35
41
  /** Decoded `data:` payloads; terminates on `[DONE]` or stream end. */
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.3",
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.3",
159
+ "@oh-my-pi/pi-catalog": "18.4.3",
160
+ "@oh-my-pi/pi-natives": "18.4.3",
161
+ "@oh-my-pi/pi-utils": "18.4.3",
162
+ "@oh-my-pi/pi-wire": "18.4.3"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -36,6 +36,18 @@ const TAGS: readonly Tag[] = [
36
36
  const OPENS = TAGS.map(tag => tag.open);
37
37
  const IMPLIED_OPEN_TAGS = TAGS.filter(tag => tag.impliedOpen);
38
38
  const IMPLIED_OPEN_DELIMITERS = [...OPENS, ...IMPLIED_OPEN_TAGS.map(tag => tag.close)];
39
+ /** A hold needs the buffer tail to be a proper prefix of some delimiter, so it must be shorter than this. */
40
+ const MAX_DELIMITER_LENGTH = Math.max(...IMPLIED_OPEN_DELIMITERS.map(delimiter => delimiter.length));
41
+ const BACKTICK = 0x60;
42
+ const BOUNDARY_LEAD_CODES = [...IMPLIED_OPEN_DELIMITERS.map(delimiter => delimiter.charCodeAt(0)), BACKTICK];
43
+ /**
44
+ * `1` at the char code of every character a {@link scanVisible} boundary can
45
+ * start on: the first character of any delimiter (a tag open, an implied close,
46
+ * or a partial of either) and the backtick. Every other character — including
47
+ * any code past the table — is skipped without comparing.
48
+ */
49
+ const BOUNDARY_LEAD = new Uint8Array(Math.max(...BOUNDARY_LEAD_CODES) + 1);
50
+ for (const code of BOUNDARY_LEAD_CODES) BOUNDARY_LEAD[code] = 1;
39
51
 
40
52
  export interface ThinkingInbandScannerOptions {
41
53
  /**
@@ -240,20 +252,26 @@ type VisibleHit =
240
252
  */
241
253
  function scanVisible(buffer: string, final: boolean, impliedOpen: boolean): VisibleHit {
242
254
  const delimiters = impliedOpen ? IMPLIED_OPEN_DELIMITERS : OPENS;
255
+ // Only the tail can be a proper prefix of a delimiter.
256
+ const holdFrom = final ? buffer.length : buffer.length - MAX_DELIMITER_LENGTH + 1;
243
257
  for (let i = 0; i < buffer.length; i++) {
244
- const tag = TAGS.find(candidate => buffer.startsWith(candidate.open, i));
245
- if (tag) return { kind: "tag", index: i, tag };
258
+ const code = buffer.charCodeAt(i);
259
+ if (code >= BOUNDARY_LEAD.length || BOUNDARY_LEAD[code] === 0) continue;
260
+ for (const tag of TAGS) {
261
+ if (buffer.startsWith(tag.open, i)) return { kind: "tag", index: i, tag };
262
+ }
246
263
  if (impliedOpen) {
247
- const closed = IMPLIED_OPEN_TAGS.find(candidate => buffer.startsWith(candidate.close, i));
248
- if (closed) return { kind: "impliedClose", index: i, tag: closed };
264
+ for (const tag of IMPLIED_OPEN_TAGS) {
265
+ if (buffer.startsWith(tag.close, i)) return { kind: "impliedClose", index: i, tag };
266
+ }
249
267
  }
250
- if (!final) {
268
+ if (i >= holdFrom) {
251
269
  const rest = buffer.slice(i);
252
270
  if (delimiters.some(delimiter => delimiter.length > rest.length && delimiter.startsWith(rest))) {
253
271
  return { kind: "hold", index: i };
254
272
  }
255
273
  }
256
- if (buffer[i] === "`") {
274
+ if (code === BACKTICK) {
257
275
  const ticks = backtickRun(buffer, i);
258
276
  if (!final && i + ticks === buffer.length) return { kind: "hold", index: i };
259
277
  return { kind: "code", index: i, ticks };
@@ -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
 
@@ -1,4 +1,4 @@
1
- import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
1
+ import { type Api, type Model, modelKind } from "@oh-my-pi/pi-catalog/types";
2
2
  import {
3
3
  applyCodexResidencyHeader,
4
4
  CODEX_BASE_URL,
@@ -155,7 +155,10 @@ export async function generateHostedImage(
155
155
  action: content.length > 1 ? "edit" : "generate",
156
156
  output_format: "webp",
157
157
  ...(size ? { size } : {}),
158
- ...(model.api === "openai-responses" ? { model: model.requestModelId ?? model.id } : {}),
158
+ // A chat model generating on its own lets the host pick the image model.
159
+ ...(model.api === "openai-responses" && modelKind(model) === "image"
160
+ ? { model: model.requestModelId ?? model.id }
161
+ : {}),
159
162
  };
160
163
  const body = {
161
164
  model: carrier.requestModelId ?? carrier.id,
@@ -446,6 +446,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
446
446
  };
447
447
 
448
448
  const blocks = output.content as Block[];
449
+ const contentIndexByBlockIndex = new Map<number, number>();
449
450
  let rawRequestDump: RawHttpRequestDump | undefined;
450
451
  const region = resolveBedrockRegion(model.id, options);
451
452
 
@@ -640,7 +641,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
640
641
  if (messageType === "exception") {
641
642
  const exceptionType = message.headers[":exception-type"] || "Exception";
642
643
  const payload = safeParsePayload(message.payload) as { message?: string } | undefined;
643
- const errorMessage = payload?.message || new TextDecoder().decode(message.payload);
644
+ const errorMessage = payload?.message || PAYLOAD_DECODER.decode(message.payload);
644
645
  const text = `${exceptionType}: ${errorMessage}`;
645
646
  throw new AIError.BedrockApiError(text, bedrockStreamExceptionStatus(exceptionType), {
646
647
  code: exceptionType,
@@ -648,7 +649,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
648
649
  }
649
650
  if (messageType === "error") {
650
651
  const code = message.headers[":error-code"] || "UnknownError";
651
- const errorMessage = message.headers[":error-message"] || new TextDecoder().decode(message.payload);
652
+ const errorMessage = message.headers[":error-message"] || PAYLOAD_DECODER.decode(message.payload);
652
653
  throw new AIError.BedrockApiError(`${code}: ${errorMessage}`, bedrockStreamExceptionStatus(code), {
653
654
  code,
654
655
  });
@@ -673,16 +674,35 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
673
674
  }
674
675
  case "contentBlockStart": {
675
676
  if (!firstTokenTime) firstTokenTime = performance.now();
676
- handleContentBlockStart(payload as ContentBlockStartEvent, blocks, output, stream, sentinelInjected);
677
+ handleContentBlockStart(
678
+ payload as ContentBlockStartEvent,
679
+ blocks,
680
+ contentIndexByBlockIndex,
681
+ output,
682
+ stream,
683
+ sentinelInjected,
684
+ );
677
685
  break;
678
686
  }
679
687
  case "contentBlockDelta": {
680
688
  if (!firstTokenTime) firstTokenTime = performance.now();
681
- handleContentBlockDelta(payload as ContentBlockDeltaEvent, blocks, output, stream);
689
+ handleContentBlockDelta(
690
+ payload as ContentBlockDeltaEvent,
691
+ blocks,
692
+ contentIndexByBlockIndex,
693
+ output,
694
+ stream,
695
+ );
682
696
  break;
683
697
  }
684
698
  case "contentBlockStop": {
685
- handleContentBlockStop(payload as ContentBlockStopEvent, blocks, output, stream);
699
+ handleContentBlockStop(
700
+ payload as ContentBlockStopEvent,
701
+ blocks,
702
+ contentIndexByBlockIndex,
703
+ output,
704
+ stream,
705
+ );
686
706
  break;
687
707
  }
688
708
  case "messageStop": {
@@ -782,18 +802,40 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
782
802
  return stream;
783
803
  };
784
804
 
805
+ /** Shared across events: every payload decode is a complete, non-streaming call. */
806
+ const PAYLOAD_DECODER = new TextDecoder();
807
+
785
808
  function safeParsePayload(payload: Uint8Array): unknown {
786
809
  if (payload.length === 0) return {};
787
810
  try {
788
- return JSON.parse(new TextDecoder().decode(payload));
811
+ return JSON.parse(PAYLOAD_DECODER.decode(payload));
789
812
  } catch {
790
813
  return undefined;
791
814
  }
792
815
  }
793
816
 
817
+ /**
818
+ * Append a streamed block and index it by Bedrock's `contentBlockIndex`, so
819
+ * per-delta routing is an O(1) lookup instead of a scan over every block
820
+ * (quadratic over a long turn). The first block registered for an index wins,
821
+ * as the first-match scan did. Returns the block's content index.
822
+ */
823
+ function pushStreamBlock(
824
+ blocks: Block[],
825
+ contentIndexByBlockIndex: Map<number, number>,
826
+ block: Block,
827
+ contentBlockIndex: number,
828
+ ): number {
829
+ const contentIndex = blocks.length;
830
+ blocks.push(block);
831
+ if (!contentIndexByBlockIndex.has(contentBlockIndex)) contentIndexByBlockIndex.set(contentBlockIndex, contentIndex);
832
+ return contentIndex;
833
+ }
834
+
794
835
  function handleContentBlockStart(
795
836
  event: ContentBlockStartEvent,
796
837
  blocks: Block[],
838
+ contentIndexByBlockIndex: Map<number, number>,
797
839
  output: AssistantMessage,
798
840
  stream: AssistantMessageEventStream,
799
841
  sentinelInjected: boolean,
@@ -815,29 +857,29 @@ function handleContentBlockStart(
815
857
  [kStreamingPartialJson]: "",
816
858
  [kStreamingBlockIndex]: index,
817
859
  };
818
- output.content.push(block);
819
- stream.push({ type: "toolcall_start", contentIndex: blocks.length - 1, partial: output });
860
+ const contentIndex = pushStreamBlock(blocks, contentIndexByBlockIndex, block, index);
861
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
820
862
  }
821
863
  }
822
864
 
823
865
  function handleContentBlockDelta(
824
866
  event: ContentBlockDeltaEvent,
825
867
  blocks: Block[],
868
+ contentIndexByBlockIndex: Map<number, number>,
826
869
  output: AssistantMessage,
827
870
  stream: AssistantMessageEventStream,
828
871
  ): void {
829
872
  const contentBlockIndex = event.contentBlockIndex;
830
873
  const delta = event.delta;
831
- let index = blocks.findIndex(b => b[kStreamingBlockIndex] === contentBlockIndex);
874
+ let index = contentIndexByBlockIndex.get(contentBlockIndex) ?? -1;
832
875
  let block = blocks[index];
833
876
 
834
877
  if (delta?.text !== undefined) {
835
878
  // If no text block exists yet, create one — `handleContentBlockStart` is not sent for text blocks
836
879
  if (!block) {
837
880
  const newBlock: Block = { type: "text", text: "", [kStreamingBlockIndex]: contentBlockIndex };
838
- output.content.push(newBlock);
839
- index = blocks.length - 1;
840
- block = blocks[index];
881
+ index = pushStreamBlock(blocks, contentIndexByBlockIndex, newBlock, contentBlockIndex);
882
+ block = newBlock;
841
883
  stream.push({ type: "text_start", contentIndex: index, partial: output });
842
884
  }
843
885
  if (block.type === "text") {
@@ -863,9 +905,8 @@ function handleContentBlockDelta(
863
905
  thinkingSignature: "",
864
906
  [kStreamingBlockIndex]: contentBlockIndex,
865
907
  };
866
- output.content.push(newBlock);
867
- thinkingIndex = blocks.length - 1;
868
- thinkingBlock = blocks[thinkingIndex];
908
+ thinkingIndex = pushStreamBlock(blocks, contentIndexByBlockIndex, newBlock, contentBlockIndex);
909
+ thinkingBlock = newBlock;
869
910
  stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output });
870
911
  }
871
912
 
@@ -901,10 +942,11 @@ function handleMetadata(event: MetadataEvent, model: Model<"bedrock-converse-str
901
942
  function handleContentBlockStop(
902
943
  event: ContentBlockStopEvent,
903
944
  blocks: Block[],
945
+ contentIndexByBlockIndex: Map<number, number>,
904
946
  output: AssistantMessage,
905
947
  stream: AssistantMessageEventStream,
906
948
  ): void {
907
- const index = blocks.findIndex(b => b[kStreamingBlockIndex] === event.contentBlockIndex);
949
+ const index = contentIndexByBlockIndex.get(event.contentBlockIndex) ?? -1;
908
950
  const block = blocks[index];
909
951
  if (!block) return;
910
952
 
@@ -24,6 +24,8 @@ const PRELUDE_CRC_LEN = 4;
24
24
  const MESSAGE_CRC_LEN = 4;
25
25
  const HEADER_BLOCK_OFFSET = PRELUDE_LEN + PRELUDE_CRC_LEN;
26
26
  const MIN_MESSAGE_LEN = HEADER_BLOCK_OFFSET + MESSAGE_CRC_LEN;
27
+ /** Shared across messages: every header decode is a complete, non-streaming call. */
28
+ const HEADER_DECODER = new TextDecoder();
27
29
 
28
30
  export interface EventStreamMessage {
29
31
  /** Lower-cased copy is *not* applied — Bedrock uses casing like `:event-type` verbatim. */
@@ -64,12 +66,11 @@ export function decodeMessage(frame: Uint8Array): EventStreamMessage {
64
66
  function parseHeaders(buf: Uint8Array): Record<string, string> {
65
67
  const out: Record<string, string> = {};
66
68
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
67
- const decoder = new TextDecoder();
68
69
  let p = 0;
69
70
  while (p < buf.length) {
70
71
  const nameLen = view.getUint8(p);
71
72
  p += 1;
72
- const name = decoder.decode(buf.subarray(p, p + nameLen));
73
+ const name = HEADER_DECODER.decode(buf.subarray(p, p + nameLen));
73
74
  p += nameLen;
74
75
  const type = view.getUint8(p);
75
76
  p += 1;
@@ -108,7 +109,7 @@ function parseHeaders(buf: Uint8Array): Record<string, string> {
108
109
  // string
109
110
  const len = view.getUint16(p, false);
110
111
  p += 2;
111
- out[name] = decoder.decode(buf.subarray(p, p + len));
112
+ out[name] = HEADER_DECODER.decode(buf.subarray(p, p + len));
112
113
  p += len;
113
114
  break;
114
115
  }
@@ -1,11 +1,10 @@
1
- import { $env } from "@oh-my-pi/pi-utils";
1
+ import { $env, type ServerSentEvent } from "@oh-my-pi/pi-utils";
2
2
  import * as AIError from "../error";
3
3
  import { getEnvApiKey } from "../stream";
4
4
  import type {
5
5
  AssistantMessage,
6
6
  Context,
7
7
  Model,
8
- RawSseEvent,
9
8
  ServiceTier,
10
9
  StreamFunction,
11
10
  StreamOptions,
@@ -104,7 +103,7 @@ const streamAzureOpenAIResponsesOnce = (
104
103
  const { requestAbortController, requestSignal } = abortTracker;
105
104
  const onSseEvent = options?.onSseEvent;
106
105
  const rawSseObserver = onSseEvent
107
- ? (event: RawSseEvent) => {
106
+ ? (event: ServerSentEvent) => {
108
107
  if (!event.event && event.data && event.data !== "[DONE]") {
109
108
  try {
110
109
  const parsed = JSON.parse(event.data);
@@ -120,7 +119,7 @@ const streamAzureOpenAIResponsesOnce = (
120
119
  }
121
120
  } catch {}
122
121
  }
123
- onSseEvent(event, model);
122
+ onSseEvent({ event: event.event, data: event.data, raw: [...event.raw] }, model);
124
123
  }
125
124
  : undefined;
126
125
 
@@ -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);