opencode-anthropic-fix 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +203 -26
- package/lib/accounts.mjs +30 -0
- package/lib/betas.mjs +1 -1
- package/lib/diagnose.mjs +8 -1
- package/lib/mimicry/adapter-input.mjs +177 -15
- package/lib/mimicry/headers.mjs +1 -1
- package/lib/mimicry/response-stream.mjs +816 -112
- package/lib/mimicry/system-prompt.mjs +20 -10
- package/lib/mimicry/wire-compat.mjs +132 -15
- package/lib/rolling-summarizer.mjs +20 -5
- package/lib/unicode-text.mjs +111 -0
- package/package.json +4 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isTruthyEnv } from "../env.mjs";
|
|
2
2
|
import { isOpus46Model, isOpus47Model, isOpus48Model } from "./wire-compat.mjs";
|
|
3
|
+
import { truncateGraphemes } from "../unicode-text.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The canonical Claude Code identity text.
|
|
@@ -146,16 +147,25 @@ export function tailSystemBlock(text, maxChars, turnThreshold) {
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
export function compactToolDescription(text) {
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
150
|
+
return (
|
|
151
|
+
text
|
|
152
|
+
.replace(/<example[\s\S]*?<\/example>/gi, "")
|
|
153
|
+
.replace(/\|[\s|:-]+\|/g, "")
|
|
154
|
+
.replace(/^\|.*\|$/gm, "")
|
|
155
|
+
// Budget is 200 UTF-16 units on the matched line itself; the "..." suffix
|
|
156
|
+
// is NOT counted against that budget (legacy contract: it is appended
|
|
157
|
+
// unconditionally after the cut). truncateGraphemes only ever cuts on a
|
|
158
|
+
// grapheme boundary, so a surrogate pair or combining sequence sitting
|
|
159
|
+
// across unit 200 is excluded whole rather than split — for ASCII text
|
|
160
|
+
// (single-unit graphemes) this is byte-for-byte identical to the old
|
|
161
|
+
// `m.slice(0, 200)`.
|
|
162
|
+
.replace(/^(?:\s*[-*]\s+.{200,})$/gm, (m) => truncateGraphemes(m, 200) + "...")
|
|
163
|
+
.replace(/^(#{1,3}\s+)/gm, "")
|
|
164
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
165
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
166
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
167
|
+
.trim()
|
|
168
|
+
);
|
|
159
169
|
}
|
|
160
170
|
|
|
161
171
|
export function compactSystemText(text, mode) {
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
BETA_REGISTRY_2_1_280,
|
|
9
9
|
buildClaudeCodeRequest,
|
|
10
10
|
buildClaudeCodeCountTokensRequest,
|
|
11
|
-
|
|
11
|
+
CLAUDE_CODE_2_1_280_PROFILE,
|
|
12
12
|
hasOneMillionContext,
|
|
13
13
|
isAdaptiveThinkingModel,
|
|
14
14
|
isClaude3Model,
|
|
@@ -22,8 +22,13 @@ import {
|
|
|
22
22
|
supportsStructuredOutputs,
|
|
23
23
|
supportsWebSearch,
|
|
24
24
|
} from "@tormentalabs/claude-code-wire-compat";
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
25
28
|
import { stripStainlessHelperMarkers } from "./headers.mjs";
|
|
26
29
|
|
|
30
|
+
const WIRE_COMPAT_PACKAGE_NAME = "@tormentalabs/claude-code-wire-compat";
|
|
31
|
+
|
|
27
32
|
/**
|
|
28
33
|
* Model-family predicates, re-exported so the rest of the tree classifies models
|
|
29
34
|
* through the SAME catalogue the request builder classifies them with.
|
|
@@ -76,11 +81,11 @@ export {
|
|
|
76
81
|
* than on the wire.
|
|
77
82
|
*
|
|
78
83
|
* Shape: `{ KEY: { featureKey, header } }`, deep-frozen by the package. It is
|
|
79
|
-
* pinned to 2.1.
|
|
84
|
+
* pinned to 2.1.280 rather than derived from `WIRE_PROFILE` because the package
|
|
80
85
|
* exports no profile->registry accessor; if the emulated baseline moves, this
|
|
81
86
|
* binding and `WIRE_PROFILE` move together.
|
|
82
87
|
*/
|
|
83
|
-
export {
|
|
88
|
+
export { BETA_REGISTRY_2_1_280 };
|
|
84
89
|
|
|
85
90
|
/**
|
|
86
91
|
* 1M-context eligibility under the profile the plugin actually emulates.
|
|
@@ -89,7 +94,7 @@ export { BETA_REGISTRY_2_1_233 };
|
|
|
89
94
|
* PROFILE-DEPENDENT: its second parameter selects the model catalogue, and it
|
|
90
95
|
* defaults to the package's own baseline (2.1.195), not to ours. The two
|
|
91
96
|
* disagree in practice — `claude-mythos-5` is ineligible under 2.1.195 and
|
|
92
|
-
* eligible under 2.1.
|
|
97
|
+
* eligible under 2.1.280 — so calling it bare would gate the plugin's context
|
|
93
98
|
* decisions on a profile the plugin does not emulate anywhere else.
|
|
94
99
|
*
|
|
95
100
|
* This wrapper binds `WIRE_PROFILE`, so eligibility follows the emulated
|
|
@@ -113,14 +118,118 @@ export function isEligibleFor1MContextWire(model) {
|
|
|
113
118
|
* `mimicryBaseline` reported by `lib/diagnose.mjs` — reads it from here instead
|
|
114
119
|
* of re-declaring a hand-maintained copy.
|
|
115
120
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* profile
|
|
121
|
+
* THIS IS ALSO THE PROFILE THE REQUEST PATH ACTUALLY USES, not merely a
|
|
122
|
+
* reporting baseline. `buildWireCompatibleRequest` and
|
|
123
|
+
* `buildWireCompatibleCountTokensRequest` both pass this constant as the
|
|
124
|
+
* explicit `profile` argument to `buildClaudeCodeRequest` /
|
|
125
|
+
* `buildClaudeCodeCountTokensRequest` (see the package's own 0.6.0 CHANGELOG:
|
|
126
|
+
* "the pin has to be passed on every call to each of the three entry points
|
|
127
|
+
* that read the default seam -- pinning the builders alone is not enough").
|
|
128
|
+
*
|
|
129
|
+
* WHY AN EXPLICIT PASS, RATHER THAN LETTING THE CALL INHERIT THE PACKAGE'S
|
|
130
|
+
* `DEFAULT_PROFILE`. The dependency is pinned to the `latest` dist-tag
|
|
131
|
+
* (`docs/shared-package-provenance.md`) precisely so package releases --
|
|
132
|
+
* fixes, new opt-in fields -- reach the plugin without a code change. Before
|
|
133
|
+
* this constant was threaded through, an unnamed `profile` argument meant the
|
|
134
|
+
* EMULATED CLIENT VERSION floated on that same tag: 0.6.0 moved
|
|
135
|
+
* `DEFAULT_PROFILE` to 2.1.280 in a minor release and broke 48 plugin tests
|
|
136
|
+
* that were still asserting 2.1.233 bytes. Passing `WIRE_PROFILE` explicitly on
|
|
137
|
+
* every entry point decouples the two axes: `latest` keeps bringing library
|
|
138
|
+
* fixes, and the emulated profile now moves only when this binding is
|
|
139
|
+
* deliberately repointed at a different exported profile singleton -- a
|
|
140
|
+
* one-line change reviewed like any other wire change, not a side effect of
|
|
141
|
+
* `npm update`.
|
|
120
142
|
*
|
|
121
143
|
* @type {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProtocolProfile}
|
|
122
144
|
*/
|
|
123
|
-
export const WIRE_PROFILE =
|
|
145
|
+
export const WIRE_PROFILE = CLAUDE_CODE_2_1_280_PROFILE;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Version of `@tormentalabs/claude-code-wire-compat` baked in at BUILD time by
|
|
149
|
+
* `scripts/build.mjs`'s esbuild `define`, read from the package that was
|
|
150
|
+
* actually installed in `node_modules` when the bundle was produced.
|
|
151
|
+
*
|
|
152
|
+
* WHY THIS EXISTS. `getWireCompatPackageVersion` below resolves the version at
|
|
153
|
+
* RUNTIME through `import.meta.resolve`, which requires the package to still
|
|
154
|
+
* be a separate, independently resolvable module -- true for an unbundled
|
|
155
|
+
* import (tests, `node cli.mjs`, a host importing `index.mjs` directly) but
|
|
156
|
+
* false for the bundles `scripts/build.mjs` produces: esbuild INLINES the
|
|
157
|
+
* package's source into the single output file, so there is no
|
|
158
|
+
* `node_modules/@tormentalabs/claude-code-wire-compat` for the bundle to
|
|
159
|
+
* resolve at its own runtime, and the resolution silently degrades to
|
|
160
|
+
* "unknown" -- defeating the diagnostic bundle's whole point of reporting what
|
|
161
|
+
* the running process actually built against.
|
|
162
|
+
*
|
|
163
|
+
* `__WIRE_COMPAT_PACKAGE_VERSION__` is a bare, undeclared global identifier,
|
|
164
|
+
* not an import and not `process.env`, because esbuild's `define` performs a
|
|
165
|
+
* textual substitution of exactly that shape at bundle time. `typeof` never
|
|
166
|
+
* throws on an undeclared identifier, so referencing it this way is exactly as
|
|
167
|
+
* safe unbundled as it is bundled: unbundled, esbuild never touched this file,
|
|
168
|
+
* the identifier stays undeclared, `typeof` reads `"undefined"`, and this
|
|
169
|
+
* constant is `undefined` -- `getWireCompatPackageVersion` then falls through
|
|
170
|
+
* to the runtime resolution exactly as it did before this constant existed.
|
|
171
|
+
*
|
|
172
|
+
* @type {string | undefined}
|
|
173
|
+
*/
|
|
174
|
+
/* global __WIRE_COMPAT_PACKAGE_VERSION__:readonly */
|
|
175
|
+
const BUNDLED_WIRE_COMPAT_PACKAGE_VERSION =
|
|
176
|
+
typeof __WIRE_COMPAT_PACKAGE_VERSION__ === "string" ? __WIRE_COMPAT_PACKAGE_VERSION__ : undefined;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The version of @tormentalabs/claude-code-wire-compat this process actually
|
|
180
|
+
* runs against: the build-time injected version when bundled (see
|
|
181
|
+
* `BUNDLED_WIRE_COMPAT_PACKAGE_VERSION` above), otherwise the version
|
|
182
|
+
* resolved fresh from `node_modules` on every call rather than cached at
|
|
183
|
+
* module load.
|
|
184
|
+
*
|
|
185
|
+
* WHY THE RUNTIME FALLBACK EXISTS. `package.json` pins the dependency to the
|
|
186
|
+
* `latest` dist-tag (see docs/shared-package-provenance.md), so the version an
|
|
187
|
+
* UNBUNDLED run actually runs against is whatever `package-lock.json` resolved
|
|
188
|
+
* at install time -- it is not a literal anywhere in this source tree. A
|
|
189
|
+
* diagnostic bundle that wants to say what wire the running process is
|
|
190
|
+
* actually built against (as opposed to `WIRE_PROFILE.cliVersion`, the
|
|
191
|
+
* BASELINE this file pins) has to read it off disk.
|
|
192
|
+
*
|
|
193
|
+
* Deliberately resolves through `node_modules`, not `package-lock.json`: the
|
|
194
|
+
* lock says what a future `npm ci` WOULD install, `node_modules` says what
|
|
195
|
+
* THIS process actually loaded, and the two can disagree after an `npm
|
|
196
|
+
* install` that has not been re-run (see `scripts/check-wire-compat-drift.mjs`,
|
|
197
|
+
* which checks exactly that disagreement).
|
|
198
|
+
*
|
|
199
|
+
* Resolution failure degrades to "unknown" rather than throwing -- a
|
|
200
|
+
* diagnostic bundle must never fail to build over a missing package.json.
|
|
201
|
+
*
|
|
202
|
+
* Resolves via `import.meta.resolve`, not `createRequire(...).resolve`: the
|
|
203
|
+
* package is ESM-only (its `exports` map declares an `import` condition and
|
|
204
|
+
* no `require` one), so the CJS resolver fails it with "No 'exports' main
|
|
205
|
+
* defined" even though the package is genuinely installed.
|
|
206
|
+
*
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
export function getWireCompatPackageVersion() {
|
|
210
|
+
if (BUNDLED_WIRE_COMPAT_PACKAGE_VERSION) return BUNDLED_WIRE_COMPAT_PACKAGE_VERSION;
|
|
211
|
+
try {
|
|
212
|
+
let directory = path.dirname(fileURLToPath(import.meta.resolve(WIRE_COMPAT_PACKAGE_NAME)));
|
|
213
|
+
// Walk up from the package's resolved entry file (e.g. .../dist/index.js)
|
|
214
|
+
// to the package.json alongside its root -- the entry file is not
|
|
215
|
+
// guaranteed to be a direct sibling of it.
|
|
216
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
217
|
+
const candidate = path.join(directory, "package.json");
|
|
218
|
+
if (existsSync(candidate)) {
|
|
219
|
+
const manifest = JSON.parse(readFileSync(candidate, "utf8"));
|
|
220
|
+
if (manifest.name === WIRE_COMPAT_PACKAGE_NAME && typeof manifest.version === "string") {
|
|
221
|
+
return manifest.version;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const parent = path.dirname(directory);
|
|
225
|
+
if (parent === directory) break;
|
|
226
|
+
directory = parent;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Fall through to "unknown".
|
|
230
|
+
}
|
|
231
|
+
return "unknown";
|
|
232
|
+
}
|
|
124
233
|
|
|
125
234
|
const PROFILE_OVERRIDE_ENV = "OPENCODE_ANTHROPIC_PROFILE_OVERRIDE";
|
|
126
235
|
|
|
@@ -408,7 +517,11 @@ export async function buildWireCompatibleRequest(body, transport) {
|
|
|
408
517
|
stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
|
|
409
518
|
let built;
|
|
410
519
|
try {
|
|
411
|
-
|
|
520
|
+
// `WIRE_PROFILE` is passed EXPLICITLY rather than omitted -- see its own
|
|
521
|
+
// doc comment above for why an omitted argument here would let a future
|
|
522
|
+
// package release move the emulated client profile out from under the
|
|
523
|
+
// `latest` dist-tag.
|
|
524
|
+
built = await buildClaudeCodeRequest(toClaudeCodeRequestInput(parsed, transport), WIRE_PROFILE);
|
|
412
525
|
} catch (error) {
|
|
413
526
|
throw foldSafeDetailsIntoMessage(error);
|
|
414
527
|
}
|
|
@@ -501,9 +614,10 @@ export function toClaudeCodeCountTokensInput(body, transport) {
|
|
|
501
614
|
|
|
502
615
|
/**
|
|
503
616
|
* Build a Claude Code `/v1/messages/count_tokens` request from the JSON body
|
|
504
|
-
* received from the host. Mirrors `buildWireCompatibleRequest`, including
|
|
505
|
-
*
|
|
506
|
-
*
|
|
617
|
+
* received from the host. Mirrors `buildWireCompatibleRequest`, including
|
|
618
|
+
* passing `WIRE_PROFILE` as the explicit `profile` argument so the count turn
|
|
619
|
+
* is pinned to the same emulated client profile as the main turn, rather than
|
|
620
|
+
* inheriting whatever the installed package declares as its `DEFAULT_PROFILE`.
|
|
507
621
|
*
|
|
508
622
|
* @param {string | undefined} body
|
|
509
623
|
* @param {Parameters<typeof toClaudeCodeCountTokensInput>[1]} transport
|
|
@@ -519,7 +633,10 @@ export async function buildWireCompatibleCountTokensRequest(body, transport) {
|
|
|
519
633
|
stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
|
|
520
634
|
let built;
|
|
521
635
|
try {
|
|
522
|
-
|
|
636
|
+
// See `buildWireCompatibleRequest`'s equivalent comment: explicit on
|
|
637
|
+
// purpose, so the count-tokens surface cannot silently move to a different
|
|
638
|
+
// profile than the main turn on a package bump.
|
|
639
|
+
built = await buildClaudeCodeCountTokensRequest(toClaudeCodeCountTokensInput(parsed, transport), WIRE_PROFILE);
|
|
523
640
|
} catch (error) {
|
|
524
641
|
throw foldSafeDetailsIntoMessage(error);
|
|
525
642
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { truncateGraphemes } from "./unicode-text.mjs";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Deterministic rolling summarizer — Phase B Task B3.
|
|
3
5
|
*
|
|
@@ -176,18 +178,31 @@ export function formatTemplate(parsed, maxChars) {
|
|
|
176
178
|
}
|
|
177
179
|
const longest = current[longestName];
|
|
178
180
|
if (longest.length <= EMPTY_SECTION.length) break;
|
|
179
|
-
// Chop 10% (at least 8 chars) off the end of the longest section.
|
|
181
|
+
// Chop 10% (at least 8 chars) off the end of the longest section. Cut on
|
|
182
|
+
// a grapheme boundary — never split a surrogate pair or combining
|
|
183
|
+
// sequence — while still guaranteeing progress: the target is always
|
|
184
|
+
// strictly below `longest.length` here, so truncateGraphemes always
|
|
185
|
+
// shortens it (it only ever returns the input unchanged when the input
|
|
186
|
+
// already fits the target).
|
|
180
187
|
const chop = Math.max(8, Math.floor(longest.length * 0.1));
|
|
181
|
-
|
|
188
|
+
const target = Math.max(EMPTY_SECTION.length, longest.length - chop);
|
|
189
|
+
current = { ...current, [longestName]: truncateGraphemes(longest, target) };
|
|
182
190
|
out = render(current);
|
|
183
191
|
}
|
|
184
192
|
|
|
185
|
-
// Final hard cap: if we somehow still exceed,
|
|
193
|
+
// Final hard cap: if we somehow still exceed, cut the envelope on a
|
|
194
|
+
// grapheme boundary instead of slicing UTF-16 units blindly. Still
|
|
186
195
|
// deterministic (input-driven only).
|
|
187
196
|
if (out.length > maxChars) {
|
|
188
197
|
const closing = "\n</session-summary>";
|
|
189
|
-
|
|
190
|
-
|
|
198
|
+
if (maxChars <= closing.length) {
|
|
199
|
+
// The budget doesn't even cover the closing tag by itself. Match the
|
|
200
|
+
// pre-existing fallback shape — a bare, well-formed closing tag —
|
|
201
|
+
// rather than emitting an envelope with no closing tag at all.
|
|
202
|
+
out = closing;
|
|
203
|
+
} else {
|
|
204
|
+
out = truncateGraphemes(out, maxChars, { suffix: closing });
|
|
205
|
+
}
|
|
191
206
|
}
|
|
192
207
|
return out;
|
|
193
208
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grapheme-safe text truncation.
|
|
3
|
+
*
|
|
4
|
+
* Every plugin-side text budget that ends up on the wire (system prompt
|
|
5
|
+
* compaction, rolling-summary sections/envelope, and any future caller) has
|
|
6
|
+
* historically been enforced with `String.prototype.slice(0, N)`. `slice`
|
|
7
|
+
* counts UTF-16 code units and has no notion of "user-perceived character":
|
|
8
|
+
* it can cut a surrogate pair in half (producing a lone surrogate, which
|
|
9
|
+
* `@tormentalabs/claude-code-wire-compat` rejects with `INVALID_UNICODE`) or
|
|
10
|
+
* cut a multi-codepoint grapheme cluster in half (producing well-formed
|
|
11
|
+
* UTF-16 that nonetheless renders as a broken glyph — a dangling combining
|
|
12
|
+
* mark, half a ZWJ sequence, half a flag, a skin-tone modifier with no base
|
|
13
|
+
* emoji, and so on).
|
|
14
|
+
*
|
|
15
|
+
* `truncateGraphemes` is the single, pure helper that replaces those
|
|
16
|
+
* `slice(0, N)` call sites. It only ever cuts on a grapheme-cluster boundary
|
|
17
|
+
* (per `Intl.Segmenter` with `granularity: "grapheme"`) and never emits a
|
|
18
|
+
* lone surrogate or a split cluster.
|
|
19
|
+
*
|
|
20
|
+
* Budget unit: UTF-16 code units — i.e. `String.prototype.length`. This is
|
|
21
|
+
* the same unit the `slice(0, N)` call sites it replaces already used, so
|
|
22
|
+
* swapping in this helper with the same `N` preserves the legacy budget
|
|
23
|
+
* semantics (it does NOT switch callers to a UTF-8 byte budget or a visual
|
|
24
|
+
* column budget; a caller that needs one of those must convert before and
|
|
25
|
+
* after calling in).
|
|
26
|
+
*
|
|
27
|
+
* @module lib/unicode-text
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return the longest prefix of `text` that:
|
|
32
|
+
* - ends exactly on a grapheme-cluster boundary (never splits a surrogate
|
|
33
|
+
* pair and never splits a base character from a combining mark, ZWJ
|
|
34
|
+
* sequence, flag, or emoji modifier);
|
|
35
|
+
* - together with `options.suffix` appended, has a UTF-16 length
|
|
36
|
+
* (`.length`) less than or equal to `maxUnits`.
|
|
37
|
+
*
|
|
38
|
+
* Edge-case contract (all explicit; the function never throws):
|
|
39
|
+
* - `text === ""` -> `""`.
|
|
40
|
+
* - `maxUnits` is `NaN`, or not a number at all -> treated as `0` (the
|
|
41
|
+
* most conservative reading: if the budget itself is meaningless,
|
|
42
|
+
* nothing is guaranteed to fit).
|
|
43
|
+
* - `maxUnits < 0` -> treated as `0`.
|
|
44
|
+
* - `maxUnits === Infinity` -> `text` is returned unchanged; there is no
|
|
45
|
+
* ceiling to cut against.
|
|
46
|
+
* - `text.length <= maxUnits` -> `text` is returned unchanged, WITHOUT the
|
|
47
|
+
* suffix appended. Nothing was truncated, so nothing marks that it was —
|
|
48
|
+
* this matches the legacy call sites, where the suffix only ever showed
|
|
49
|
+
* up when a cut actually happened.
|
|
50
|
+
* - The very first grapheme cluster of `text` is already wider than the
|
|
51
|
+
* room left for the prefix (`maxUnits - suffix.length`) -> the prefix is
|
|
52
|
+
* `""`. The cluster is NEVER split to make it fit; if `options.suffix`
|
|
53
|
+
* itself fits within `maxUnits` on its own it is still returned alone,
|
|
54
|
+
* otherwise the overall result is `""`.
|
|
55
|
+
* - Long combining-mark sequences, ZWJ emoji, flags, and skin-tone
|
|
56
|
+
* modifiers are each a single grapheme cluster to `Intl.Segmenter` and
|
|
57
|
+
* are therefore included or excluded as one indivisible unit, however
|
|
58
|
+
* many UTF-16 units or code points they span.
|
|
59
|
+
*
|
|
60
|
+
* Performance: `text` is segmented at most once. If `text.length <=
|
|
61
|
+
* maxUnits`, segmentation is skipped entirely (fast path) — no reason to pay
|
|
62
|
+
* for it when no truncation can possibly be needed. When truncation IS
|
|
63
|
+
* needed, the segments are walked forward exactly once, accumulating length
|
|
64
|
+
* until the next segment would exceed the budget; this is O(n) in the length
|
|
65
|
+
* of `text`, never quadratic, and always terminates because the walk is a
|
|
66
|
+
* single forward pass over a finite iterator.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @param {number} maxUnits - budget in UTF-16 code units (`.length`).
|
|
70
|
+
* @param {{suffix?: string}} [options]
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function truncateGraphemes(text, maxUnits, options) {
|
|
74
|
+
const suffix = options && typeof options.suffix === "string" ? options.suffix : "";
|
|
75
|
+
const input = typeof text === "string" ? text : String(text ?? "");
|
|
76
|
+
if (input.length === 0) return "";
|
|
77
|
+
|
|
78
|
+
// Normalize the budget: non-numbers and NaN are the most conservative
|
|
79
|
+
// reading (0); negative budgets clamp to 0; Infinity is left as-is and
|
|
80
|
+
// falls through the normal comparisons below (any finite length is <=
|
|
81
|
+
// Infinity, so it hits the "already fits" fast path further down).
|
|
82
|
+
let limit = maxUnits;
|
|
83
|
+
if (typeof limit !== "number" || Number.isNaN(limit)) {
|
|
84
|
+
limit = 0;
|
|
85
|
+
} else if (limit < 0) {
|
|
86
|
+
limit = 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fast path: already within budget. Skip segmenting entirely, and do NOT
|
|
90
|
+
// append the suffix — nothing was truncated.
|
|
91
|
+
if (input.length <= limit) return input;
|
|
92
|
+
|
|
93
|
+
const budgetForPrefix = limit - suffix.length;
|
|
94
|
+
if (budgetForPrefix <= 0) {
|
|
95
|
+
// No room for even one grapheme once the suffix is reserved. Return the
|
|
96
|
+
// suffix alone if it fits the whole budget by itself; otherwise "".
|
|
97
|
+
// Either way, no partial cluster is ever produced.
|
|
98
|
+
return suffix.length <= limit ? suffix : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
102
|
+
let prefix = "";
|
|
103
|
+
let used = 0;
|
|
104
|
+
for (const { segment } of segmenter.segment(input)) {
|
|
105
|
+
const segmentLength = segment.length;
|
|
106
|
+
if (used + segmentLength > budgetForPrefix) break;
|
|
107
|
+
prefix += segment;
|
|
108
|
+
used += segmentLength;
|
|
109
|
+
}
|
|
110
|
+
return prefix + suffix;
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-anthropic-fix",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"license": "GPL-3.0-or-later",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"install:link": "node scripts/install.mjs link",
|
|
33
33
|
"install:copy": "npm run build && node scripts/install.mjs copy",
|
|
34
34
|
"uninstall": "node scripts/install.mjs uninstall",
|
|
35
|
+
"sync:wire-compat": "node scripts/sync-wire-compat.mjs",
|
|
36
|
+
"check:wire-compat-drift": "node scripts/check-wire-compat-drift.mjs",
|
|
35
37
|
"prepare": "husky"
|
|
36
38
|
},
|
|
37
39
|
"lint-staged": {
|
|
@@ -55,6 +57,6 @@
|
|
|
55
57
|
"vitest": "^4.0.18"
|
|
56
58
|
},
|
|
57
59
|
"dependencies": {
|
|
58
|
-
"@tormentalabs/claude-code-wire-compat": "
|
|
60
|
+
"@tormentalabs/claude-code-wire-compat": "latest"
|
|
59
61
|
}
|
|
60
62
|
}
|