@coseung2/opencodex 2.8.0-cs.15 → 2.8.0-cs.17

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 (61) hide show
  1. package/gui/dist/assets/{index-BhXIu7c0.js → index-Ch-99jy3.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/packages/ocx-notch/README.md +3 -1
  5. package/src/adapters/base.ts +12 -0
  6. package/src/adapters/identity.ts +1 -1
  7. package/src/adapters/kiro-calibration.ts +83 -0
  8. package/src/adapters/kiro-constants.ts +11 -2
  9. package/src/adapters/kiro-errors.ts +11 -0
  10. package/src/adapters/kiro-events.ts +19 -1
  11. package/src/adapters/kiro-thinking.ts +18 -2
  12. package/src/adapters/kiro-tools.ts +12 -3
  13. package/src/adapters/kiro.ts +300 -78
  14. package/src/adapters/openai-chat.ts +1 -42
  15. package/src/adapters/openai-responses.ts +126 -9
  16. package/src/adapters/xai-schema-analysis.ts +78 -0
  17. package/src/adapters/xai-tool-schema.ts +274 -0
  18. package/src/adapters/xai-web-search.ts +138 -0
  19. package/src/bridge.ts +61 -6
  20. package/src/cli/observe.ts +18 -3
  21. package/src/codex/app-server-processes.ts +3 -5
  22. package/src/codex/catalog/effort.ts +4 -2
  23. package/src/codex/catalog/metadata.ts +42 -9
  24. package/src/codex/catalog/parsing.ts +17 -2
  25. package/src/codex/catalog/provider-fetch.ts +9 -3
  26. package/src/codex/catalog/sync.ts +11 -5
  27. package/src/codex/data/upstream-models.json +169 -0
  28. package/src/grok/inject.ts +1 -1
  29. package/src/lib/errors.ts +18 -0
  30. package/src/lib/token-estimate.ts +42 -38
  31. package/src/lib/translator-budget.ts +34 -0
  32. package/src/oauth/index.ts +10 -4
  33. package/src/oauth/kiro.ts +71 -6
  34. package/src/oauth/store.ts +3 -1
  35. package/src/oauth/types.ts +4 -0
  36. package/src/providers/derive.ts +7 -5
  37. package/src/providers/opencode-go-transport.ts +59 -0
  38. package/src/providers/quota.ts +68 -60
  39. package/src/providers/registry.ts +41 -10
  40. package/src/providers/xai-transport.ts +10 -0
  41. package/src/responses/compaction.ts +8 -1
  42. package/src/responses/namespace-aliases.ts +56 -0
  43. package/src/responses/parser.ts +12 -0
  44. package/src/responses/reasoning-envelope.ts +9 -1
  45. package/src/responses/snapshot-policy.ts +108 -0
  46. package/src/responses/state.ts +23 -10
  47. package/src/responses/turn-termination.ts +108 -0
  48. package/src/responses/xai-custom-tool-compat.ts +237 -0
  49. package/src/server/grok-responses-snapshot-repair.ts +338 -0
  50. package/src/server/index.ts +2 -1
  51. package/src/server/relay-eager.ts +1 -0
  52. package/src/server/request-log-conversation.ts +8 -0
  53. package/src/server/request-log.ts +5 -4
  54. package/src/server/responses/core.ts +233 -16
  55. package/src/server/responses-image-gen-repair.ts +2 -2
  56. package/src/server/sse-payload-rewrite.ts +20 -3
  57. package/src/types.ts +10 -1
  58. package/src/usage/cost.ts +0 -0
  59. package/src/usage/expected-prices.ts +7 -0
  60. package/src/usage/log.ts +1 -2
  61. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BhXIu7c0.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-Ch-99jy3.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-CxisOo-q.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coseung2/opencodex",
3
- "version": "2.8.0-cs.15",
3
+ "version": "2.8.0-cs.17",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -45,7 +45,7 @@ Startup failures and Rust panics are written to the bounded diagnostic log `%LOC
45
45
  - Online shutdown uses OCX's authenticated `POST /api/stop` graceful-stop endpoint directly, with CLI fallback for older OCX versions. Power transitions probe health immediately and then every ~75ms with a short timeout so the control reflects the real listener state quickly.
46
46
  - The header keeps Private and WS on separate rows, with fixed 0-to-Max segmented gauges beside them. The unboxed power and minus controls retain generous invisible hit areas and show hover/pressed feedback.
47
47
  - Click the notch to expand provider details.
48
- - Use the inline Providers and Logs tabs below the memory header to switch content. Logs show only the latest 10 requests with status, duration, relative time, reasoning effort, Fast state, and token usage.
48
+ - Use the inline Providers and Logs tabs below the memory header to switch content. Logs show only the latest 10 requests with status, output tok/s, relative time, reasoning effort, Fast state, and token usage. Estimated rates are prefixed with `~`; unavailable rates use an em dash.
49
49
  - When expanded, click the top-right minus control to collapse back to the 58px notch.
50
50
  - Drag either side edge to resize the notch width. Position and width are restored on the next launch.
51
51
  - Drag anywhere on the notch to move it; its chosen position is preserved while it expands, collapses, or refreshes.
@@ -58,3 +58,5 @@ Startup failures and Rust panics are written to the bounded diagnostic log `%LOC
58
58
  - Right-click to add a provider, set the real OCX account rotation threshold, fine-tune it by 1%, **Refresh**, or **Exit**. The opaque provider modal groups supported presets into Account, Free, and Paid tabs. Canonical OpenAI adds another Codex account, OAuth presets use browser/device authorization, and required-key presets use masked API-key entry. Fixed-endpoint key-optional presets (including OpenCode Free and MiMo Free) accept an empty key; entering a key instead adds a distinct account slot to OCX's existing key pool, which can be switched from the provider row. Ollama, vLLM, and LM Studio use local auth without a key. Cloudflare Workers AI also appears in Free and asks for its Account ID before creating the provider. Endpoint-choice and other unresolved placeholder-URL presets remain omitted because this compact modal cannot preserve those setup contracts safely. `Off` writes threshold `0`.
59
59
 
60
60
  Quota percentages are shown as used percentages with 5-hour/weekly/monthly/custom-window columns in one compact row, reset countdowns, 5px progress bars, green fill, and the green-to-amber threshold warning used by the OCX dashboard. Columns are derived from whichever quota windows the management API returns, so provider-specific windows appear without a plan-name list in the notch. Provider usage is merged by exact provider name, limited to the newest day, and formatted with Korean `만/억/조` units. OpenAI account rows show their own 5-hour/weekly/monthly quotas; OAuth and key-pool rows show their masked identity and active/health state, while provider-level quota remains associated with the active account. The native window uses a subtle 238/255 global alpha.
61
+
62
+ For OpenCode Go, the collapsed header keeps the provider report's 5-hour/weekly/monthly windows rather than copying a monthly-only key row, and each expanded key row shows that key's own 5-hour/weekly/monthly allocation.
@@ -30,6 +30,13 @@ export interface ProviderAdapter {
30
30
  */
31
31
  buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta): AdapterRequest | Promise<AdapterRequest>;
32
32
 
33
+ /**
34
+ * Decide before request construction that the input already contains the completed answer and
35
+ * nothing should be sent upstream. The server turns this into an outputless completed response
36
+ * and deliberately bypasses empty-completion retry.
37
+ */
38
+ localTerminal?(parsed: OcxParsedRequest): AdapterLocalTerminal | undefined;
39
+
33
40
  fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response>;
34
41
 
35
42
  parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent>;
@@ -41,6 +48,11 @@ export interface ProviderAdapter {
41
48
  ): Promise<void>;
42
49
  }
43
50
 
51
+ export interface AdapterLocalTerminal {
52
+ /** Fixed diagnostic identifier; never derived from conversation content. */
53
+ reason: string;
54
+ }
55
+
44
56
  export interface AdapterRequest {
45
57
  url: string;
46
58
  method: string;
@@ -25,7 +25,7 @@ export const CODEX_GPT5_IDENTITY_LINE_AGENT = "You are Codex, an agent based on
25
25
  * Avoid a broad `You are Codex.*` rewrite that could touch unrelated content.
26
26
  */
27
27
  const CODEX_GPT5_IDENTITY_RE =
28
- /You are Codex, (?:a coding agent|an agent) based on GPT-5(?:\.[0-9]+)*\./g;
28
+ /You are Codex, (?:a coding agent|an agent) based on GPT-[0-9]+(?:\.[0-9]+)*\./g;
29
29
 
30
30
  /** Proxy-neutral replacement: no "opencodex proxy" mention, just the GPT-5/OpenAI disclaimer. */
31
31
  export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be GPT-5 or to be made by OpenAI.";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Per-conversation correction for Kiro's heuristic context estimate.
3
+ *
4
+ * Kiro reports `contextUsagePercentage`; against a known context window that is an authoritative
5
+ * post-response checkpoint. We learn a bounded, smoothed charged/estimated factor per conversation
6
+ * and apply it to later turns. State is process-local, bounded, and never persisted.
7
+ */
8
+
9
+ const MIN_FACTOR = 0.7;
10
+ const MAX_FACTOR = 3;
11
+ const SMOOTHING = 0.35;
12
+ const MAX_TRACKED_CONVERSATIONS = 256;
13
+ const MAX_PLAUSIBLE_OBSERVATION = 6;
14
+
15
+ interface Calibration {
16
+ factor?: number;
17
+ /** Raw heuristic estimate most recently built for this conversation. */
18
+ rawEstimate?: number;
19
+ }
20
+
21
+ const calibrations = new Map<string, Calibration>();
22
+
23
+ function touch(conversationId: string, update: (current: Calibration) => Calibration): void {
24
+ const current = calibrations.get(conversationId) ?? {};
25
+ calibrations.delete(conversationId);
26
+ calibrations.set(conversationId, update(current));
27
+ while (calibrations.size > MAX_TRACKED_CONVERSATIONS) {
28
+ const oldest = calibrations.keys().next();
29
+ if (oldest.done) break;
30
+ calibrations.delete(oldest.value);
31
+ }
32
+ }
33
+
34
+ function clamp(value: number): number {
35
+ return Math.min(MAX_FACTOR, Math.max(MIN_FACTOR, value));
36
+ }
37
+
38
+ /** Record one terminal turn's measured Kiro input charge. Invalid/implausible readings are ignored. */
39
+ export function recordKiroCalibration(
40
+ conversationId: string | undefined,
41
+ estimated: number,
42
+ charged: number,
43
+ ): void {
44
+ if (!conversationId || !Number.isFinite(estimated) || !Number.isFinite(charged)) return;
45
+ if (estimated <= 0 || charged <= 0) return;
46
+ const entry = calibrations.get(conversationId);
47
+ // Learning must use the RAW heuristic, not an already-corrected estimate, or the correction
48
+ // measures its own residual and converges short of the actual provider charge.
49
+ const baseline = entry?.rawEstimate ?? estimated;
50
+ if (!Number.isFinite(baseline) || baseline <= 0) return;
51
+ const observed = charged / baseline;
52
+ if (!Number.isFinite(observed) || observed <= 0 || observed > MAX_PLAUSIBLE_OBSERVATION) return;
53
+
54
+ const previous = entry?.factor ?? 1;
55
+ const next = clamp(previous + (observed - previous) * SMOOTHING);
56
+ // Consume the baseline so duplicate metadata cannot re-score a stale payload.
57
+ touch(conversationId, () => ({ factor: next }));
58
+ }
59
+
60
+ /** Apply the learned factor and remember the raw estimate used for the next observation. */
61
+ export function calibrateKiroEstimate(conversationId: string | undefined, estimate: number): number {
62
+ if (!conversationId || !Number.isFinite(estimate) || estimate <= 0) return estimate;
63
+ touch(conversationId, current => ({ ...current, rawEstimate: estimate }));
64
+ const factor = calibrations.get(conversationId)?.factor;
65
+ return factor === undefined ? estimate : Math.ceil(estimate * factor);
66
+ }
67
+
68
+ /** Carry calibration to a provider-returned conversation id when it differs from the request id. */
69
+ export function rekeyKiroCalibration(
70
+ fromConversationId: string | undefined,
71
+ toConversationId: string | undefined,
72
+ ): void {
73
+ if (!fromConversationId || !toConversationId || fromConversationId === toConversationId) return;
74
+ const entry = calibrations.get(fromConversationId);
75
+ if (!entry) return;
76
+ calibrations.delete(fromConversationId);
77
+ touch(toConversationId, current => ({ ...current, ...entry }));
78
+ }
79
+
80
+ /** Test-only reset. */
81
+ export function resetKiroCalibration(): void {
82
+ calibrations.clear();
83
+ }
@@ -1,14 +1,23 @@
1
1
  export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer";
2
+
3
+ /** Kiro CLI's public service profile for Builder ID requests, NOT an account identity.
4
+ * Request construction only: never persist it or use it to infer account/region ownership.
5
+ * Upstream reference: 0209234e4 + 1241021d8. */
6
+ export const KIRO_BUILDER_ID_SERVICE_PROFILE_ARN =
7
+ "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX";
2
8
  export const KIRO_CONTINUATION_MESSAGE =
3
9
  "Continue from the prior conversation. Do not quote or mention this instruction.";
4
10
  export const KIRO_COMPLETION_RETRY_MESSAGE =
5
- `Continue the existing task without quoting this instruction. If the task is complete, call ${KIRO_COMPLETION_TOOL_NAME} now with the complete final answer. Otherwise issue the next real tool call now. Do not ask the user for another task or emit another progress-only message.`;
11
+ `Continue the existing task without quoting this instruction. If the task is complete, call ${KIRO_COMPLETION_TOOL_NAME} now with the complete final answer. If you cannot continue until the user supplies a decision, information, or a clarification that only they can give, call ${KIRO_COMPLETION_TOOL_NAME} now with that question as the answer. Otherwise issue the next real tool call now. Do not solicit a new task and do not emit another progress-only message.`;
6
12
 
7
13
  export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is attached.";
8
14
  export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output.";
15
+ /** Structurally valid trailing user turn for history that already ended in a delivered final answer. */
16
+ export const KIRO_ANSWER_DELIVERED_MESSAGE =
17
+ "The previous final answer was delivered to the user and that task is closed. No new request has been made yet. Do not repeat, revise, or continue that work; wait for the user's next instruction.";
9
18
 
10
19
  export const KIRO_COMPLETION_INSTRUCTIONS =
11
- `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates, but never repeat or paraphrase an earlier progress update; call the next real tool directly unless a new concise update adds material information. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
20
+ `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates, but never repeat or paraphrase an earlier progress update; call the next real tool directly unless a new concise update adds material information. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text. This completion tool is not an ordinary work tool. When the task is complete, call it instead of emitting answer-shaped ordinary assistant text. The call is terminal and is the exception to generic tool-result counting: it is complete when issued, ends the turn, returns no tool result, and no text or tool call may follow it. If you cannot continue until the user supplies a decision, information, or a clarification that only they can give, that question is your final answer: call ${KIRO_COMPLETION_TOOL_NAME} with the question and stop. Do not write the question as ordinary text and then answer it yourself.`;
12
21
 
13
22
  export type KiroCompletionMode = "disabled" | "required" | "text_fallback";
14
23
 
@@ -109,6 +109,17 @@ function classifyKiroFailure(
109
109
  retryable: false,
110
110
  };
111
111
  }
112
+ // #993: a gated model demanding a profileArn gets a stable, actionable code
113
+ // instead of the generic validation bucket. Non-retryable by definition.
114
+ if (evidence.includes("profilearn") && evidence.includes("required")) {
115
+ return {
116
+ message: "kiro_profile_required: Kiro requires a CodeWhisperer profileArn for this account and model. Re-login or re-import the matching Kiro account (ocx account login kiro --reauth) so the profile is captured, then retry.",
117
+ status: 400,
118
+ errorType: "invalid_request_error",
119
+ code: "kiro_profile_required",
120
+ retryable: false,
121
+ };
122
+ }
112
123
  if (
113
124
  evidence.includes("insufficient_quota")
114
125
  || evidence.includes("quota exhausted")
@@ -3,7 +3,8 @@ import { kiroTruncationReason } from "./kiro-truncation";
3
3
 
4
4
  export type ParsedKiroEvent =
5
5
  | { type: "content"; data?: string; modelId?: string }
6
- | { type: "reasoning"; data?: string }
6
+ | { type: "reasoning"; data?: string; redactedContent?: string }
7
+ | { type: "context_usage"; contextUsagePercentage: number }
7
8
  | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
8
9
  | { type: "truncation"; data: string }
9
10
  | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string }
@@ -17,6 +18,10 @@ const KNOWN_EVENT_TYPES = new Set([
17
18
  "toolUseEvent",
18
19
  "messageMetadataEvent",
19
20
  "metadataEvent",
21
+ // Authoritative context pressure. Every capture (kiro-cli 2.14.1 and 2.16.0) put the percentage
22
+ // HERE and left `metadataEvent` carrying only `stopReason`; metadataEvent's own
23
+ // contextUsagePercentage stays supported as a fallback rather than being dropped.
24
+ "contextUsageEvent",
20
25
  "invalidStateEvent",
21
26
  "error",
22
27
  ]);
@@ -114,11 +119,17 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
114
119
  : {}),
115
120
  };
116
121
  case "reasoningContentEvent":
122
+ // `text` is plaintext reasoning; `redactedContent` is the encrypted blob the GPT-5.6 family
123
+ // (sol/terra/luna) actually returns — they never send `text`. Keyed off the wire field, not
124
+ // the model id. Both may be absent on a bare event.
117
125
  return {
118
126
  type: "reasoning",
119
127
  ...(optionalString(eventType, parsed, "text") !== undefined
120
128
  ? { data: optionalString(eventType, parsed, "text") }
121
129
  : {}),
130
+ ...(optionalString(eventType, parsed, "redactedContent") !== undefined
131
+ ? { redactedContent: optionalString(eventType, parsed, "redactedContent") }
132
+ : {}),
122
133
  };
123
134
  case "toolUseEvent":
124
135
  return {
@@ -161,6 +172,13 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
161
172
  ...(stopReason !== undefined ? { stopReason } : {}),
162
173
  };
163
174
  }
175
+ case "contextUsageEvent": {
176
+ const contextUsagePercentage = parsed.contextUsagePercentage;
177
+ if (typeof contextUsagePercentage !== "number" || !Number.isFinite(contextUsagePercentage)) {
178
+ return malformed(eventType, "contextUsagePercentage must be a finite number");
179
+ }
180
+ return { type: "context_usage", contextUsagePercentage };
181
+ }
164
182
  case "invalidStateEvent":
165
183
  return { type: "invalid_state", message: optionalString(eventType, parsed, "message") };
166
184
  case "error":
@@ -75,6 +75,14 @@ export class KiroThinkingParser {
75
75
  return [];
76
76
  }
77
77
 
78
+ /** Release any partial tag/content carry when the owning stream stops early. */
79
+ dispose(): void {
80
+ this.replaceCarry("preBuffer", "");
81
+ this.replaceCarry("thinkingBuffer", "");
82
+ this.closeTag = "";
83
+ this.state = "streaming";
84
+ }
85
+
78
86
  private drainThinking(): AdapterEvent[] {
79
87
  const close = this.closeTag;
80
88
  const idx = this.thinkingBuffer.indexOf(close);
@@ -89,8 +97,16 @@ export class KiroThinkingParser {
89
97
  return events;
90
98
  }
91
99
  if (this.thinkingBuffer.length <= MAX_CLOSE_TAG) return [];
92
- const send = this.thinkingBuffer.slice(0, -MAX_CLOSE_TAG);
93
- this.replaceCarry("thinkingBuffer", this.thinkingBuffer.slice(-MAX_CLOSE_TAG));
100
+ // Never split a surrogate pair at the send boundary: a lone high
101
+ // surrogate at the end of one delta encodes as U+FFFD. Move the cut one
102
+ // unit earlier so the whole pair stays in the carry.
103
+ let cut = this.thinkingBuffer.length - MAX_CLOSE_TAG;
104
+ if (cut > 0 && cut < this.thinkingBuffer.length) {
105
+ const atCut = this.thinkingBuffer.charCodeAt(cut - 1);
106
+ if (atCut >= 0xd800 && atCut <= 0xdbff) cut -= 1;
107
+ }
108
+ const send = this.thinkingBuffer.slice(0, cut);
109
+ this.replaceCarry("thinkingBuffer", this.thinkingBuffer.slice(cut));
94
110
  return send ? [{ type: "reasoning_raw_delta", text: send }] : [];
95
111
  }
96
112
  }
@@ -103,7 +103,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
103
103
  // Seed with the root's own properties/required so a schema like
104
104
  // { type:"object", properties:{path}, required:["path"], oneOf:[...] } keeps them.
105
105
  if (obj.properties && typeof obj.properties === "object") {
106
- Object.assign(props, sanitizeKiroSchema(obj.properties) as Record<string, unknown>);
106
+ Object.assign(props, sanitizeSchemaMap(obj.properties) as Record<string, unknown>);
107
107
  }
108
108
  if (Array.isArray(obj.required)) {
109
109
  for (const r of obj.required) if (typeof r === "string") required.add(r);
@@ -118,7 +118,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
118
118
  if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
119
119
  const v = variant as Record<string, unknown>;
120
120
  if (v.properties && typeof v.properties === "object") {
121
- Object.assign(props, sanitizeKiroSchema(v.properties) as Record<string, unknown>);
121
+ Object.assign(props, sanitizeSchemaMap(v.properties) as Record<string, unknown>);
122
122
  }
123
123
  if (mergeRequired && Array.isArray(v.required)) {
124
124
  for (const r of v.required) if (typeof r === "string") required.add(r);
@@ -149,7 +149,16 @@ function toolDescriptionLimit(modelId: string): number {
149
149
  function truncateDescription(description: string, limit: number): string {
150
150
  if (description.length <= limit) return description;
151
151
  if (limit <= 1) return description.slice(0, limit);
152
- return `${description.slice(0, limit - 1)}…`;
152
+ let end = limit - 1;
153
+ // Never end the kept text on a lone high surrogate; one step back keeps
154
+ // the whole pair out instead of a U+FFFD-producing half.
155
+ if (description.charCodeAt(end - 1) >= 0xd800 && description.charCodeAt(end - 1) <= 0xdbff) end -= 1;
156
+ return `${description.slice(0, end)}…`;
157
+ }
158
+
159
+ /** Test-only: exercise the surrogate-safe description truncation directly. */
160
+ export function truncateDescriptionForTests(description: string, limit: number): string {
161
+ return truncateDescription(description, limit);
153
162
  }
154
163
 
155
164
  function serializedToolCatalogBytes(tools: readonly unknown[]): number {