mirror of
https://github.com/cline/cline.git
synced 2026-09-15 04:14:34 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
131e25e1a1 | ||
|
|
a7ff007af9 | ||
|
|
ef27f45080 | ||
|
|
e3c6d51072 |
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.43
|
||||
|
||||
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
|
||||
|
||||
## 3.0.42
|
||||
|
||||
- Fixed Ollama native API routing so context window and timeout settings work again
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.42",
|
||||
"version": "3.0.43",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SkillConfig,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -558,7 +559,7 @@ async function listUserInstructionConfigs(
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
+12
@@ -56,4 +56,16 @@ describe("parseYamlFrontmatter", () => {
|
||||
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
|
||||
expect(result.body.trim()).to.equal("Content")
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151
|
||||
// A leading UTF-8 BOM (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not
|
||||
// prevent frontmatter from being recognized.
|
||||
it("parses frontmatter correctly when the content has a leading UTF-8 BOM", () => {
|
||||
const input = `\uFEFF---\nname: my-skill\ndescription: A test skill\n---\n# my-skill\nThis is a test skill.`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.parseError).to.equal(undefined)
|
||||
expect(result.data).to.deep.equal({ name: "my-skill", description: "A test skill" })
|
||||
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,6 +139,34 @@ Instructions here`)
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151:
|
||||
// SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's "UTF-8 with BOM"
|
||||
// encoding) were silently skipped because the frontmatter regex required "---" at the
|
||||
// very start of the file and never accounted for the leading \uFEFF byte sequence.
|
||||
it("should discover skills whose SKILL.md starts with a UTF-8 BOM", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`\uFEFF---
|
||||
name: my-skill
|
||||
description: A test skill
|
||||
---
|
||||
# my-skill
|
||||
This is a test skill.`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("my-skill")
|
||||
expect(skills[0].description).to.equal("A test skill")
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
it("should discover skills from project .clinerules/skills directory", async () => {
|
||||
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
|
||||
const skillDir = path.join(projectSkillsDir, "explaining-code")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripUtf8Bom } from "@cline/shared"
|
||||
import * as yaml from "js-yaml"
|
||||
|
||||
export type FrontmatterParseResult = {
|
||||
@@ -35,11 +36,16 @@ export type FrontmatterParseResult = {
|
||||
* - If no frontmatter exists, returns data={} and body=original markdown.
|
||||
*/
|
||||
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
|
||||
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
|
||||
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
|
||||
const normalizedMarkdown = stripUtf8Bom(markdown)
|
||||
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = markdown.match(frontmatterRegex)
|
||||
const match = normalizedMarkdown.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, body: markdown, hadFrontmatter: false }
|
||||
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
@@ -48,6 +54,6 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
return { data, body, hadFrontmatter: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
|
||||
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.42",
|
||||
"version": "3.0.43",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -632,7 +632,7 @@
|
||||
},
|
||||
"sdk/packages/agents": {
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"dependencies": {
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
@@ -641,7 +641,7 @@
|
||||
},
|
||||
"sdk/packages/core": {
|
||||
"name": "@cline/core",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"dependencies": {
|
||||
"@cline/agents": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
@@ -679,7 +679,7 @@
|
||||
},
|
||||
"sdk/packages/llms": {
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.89",
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
@@ -714,14 +714,14 @@
|
||||
},
|
||||
"sdk/packages/sdk": {
|
||||
"name": "@cline/sdk",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"dependencies": {
|
||||
"@cline/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"sdk/packages/shared": {
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"jsonrepair": "^3.13.2",
|
||||
@@ -1796,7 +1796,7 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="],
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.6.1", "", { "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
@@ -4610,7 +4610,7 @@
|
||||
|
||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
@@ -5734,8 +5734,6 @@
|
||||
|
||||
"@react-aria/menu/@react-stately/collections": ["@react-stately/collections@3.13.1", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-stately": "^3.46.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-o1QSrtHyR7ODTdPyna87pZlgzvxBFOR8nI8XB+tQLIW2AMhE76pLYu4TN9CrZVy6nSAtE06IntyBV4toJIhorA=="],
|
||||
|
||||
"@react-aria/selection/@react-types/shared": ["@react-types/shared@3.36.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ=="],
|
||||
|
||||
"@react-aria/table/@react-stately/collections": ["@react-stately/collections@3.13.1", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-stately": "^3.46.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-o1QSrtHyR7ODTdPyna87pZlgzvxBFOR8nI8XB+tQLIW2AMhE76pLYu4TN9CrZVy6nSAtE06IntyBV4toJIhorA=="],
|
||||
|
||||
"@react-aria/tabs/@react-aria/selection": ["@react-aria/selection@3.28.1", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-aria": "^3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gGa9HkRnWsKxRhrtVASvecNyetMtP9fNF/Vcsy9Z+6NigjGUSN4SU0bhfglZ706B4t/NSMoVhcBJXlyFiG0hQw=="],
|
||||
@@ -5788,7 +5786,7 @@
|
||||
|
||||
"@tailwindcss/node/enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
@@ -5902,6 +5900,8 @@
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg=="],
|
||||
|
||||
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
@@ -6026,6 +6026,8 @@
|
||||
|
||||
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="],
|
||||
@@ -6116,8 +6118,6 @@
|
||||
|
||||
"proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"puppeteer-core/@puppeteer/browsers": ["@puppeteer/browsers@2.6.1", "", { "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg=="],
|
||||
|
||||
"radix-ui/@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collapsible": "1.1.16", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ=="],
|
||||
@@ -6278,6 +6278,8 @@
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
@@ -6306,8 +6308,6 @@
|
||||
|
||||
"unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg=="],
|
||||
|
||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
@@ -6894,6 +6894,22 @@
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
@@ -6904,8 +6920,6 @@
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -6958,12 +6972,8 @@
|
||||
|
||||
"jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
@@ -7112,26 +7122,8 @@
|
||||
|
||||
"test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||
|
||||
"unzipper/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"webview-ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"webview-ui/@vitejs/plugin-react-swc/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
@@ -7306,6 +7298,10 @@
|
||||
|
||||
"claude-dev/@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
|
||||
|
||||
"cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
|
||||
|
||||
"exceljs/archiver/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"exceljs/archiver/archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
@@ -7384,10 +7380,6 @@
|
||||
|
||||
"test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
|
||||
|
||||
"webview-ui/vitest/@vitest/utils/loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"webview-ui/vitest/chai/check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
@@ -7444,8 +7436,6 @@
|
||||
|
||||
"claude-dev/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"exceljs/archiver/archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"exceljs/archiver/archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"exceljs/archiver/zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.64
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models)
|
||||
- Frontmatter and user-instruction files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly
|
||||
|
||||
## 0.0.63
|
||||
|
||||
- The session runtime now emits `task.mistake_limit_reached` telemetry when the consecutive-mistake limit is hit, so every host (CLI, VS Code extension, hub daemon) captures it — including auto-stops when no host prompt is configured
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"!@cline/core/telemetry",
|
||||
"!@cline/core/rpc",
|
||||
"!@cline/shared/browser",
|
||||
"!@cline/shared/node",
|
||||
"!@cline/shared/types",
|
||||
"!@cline/shared/storage",
|
||||
"!@cline/shared/db"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type AgentToolContext,
|
||||
ClineCore,
|
||||
createTool,
|
||||
stripUtf8Bom,
|
||||
} from "@cline/core";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
@@ -179,6 +180,9 @@ function parseFrontmatter(md: string): {
|
||||
data: Record<string, unknown>;
|
||||
body: string;
|
||||
} {
|
||||
// stripUtf8Bom keeps the frontmatter match below working for files saved with a leading
|
||||
// UTF-8 BOM (see cline/cline#12151).
|
||||
md = stripUtf8Bom(md);
|
||||
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { data: {}, body: md.trim() };
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -69,6 +69,50 @@ describe("AgentRuntime", () => {
|
||||
expect(model.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails a turn that hits the model output token limit before completion", async () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const model = new ScriptedModel([
|
||||
() => [
|
||||
{ type: "reasoning-delta", text: "thinking..." },
|
||||
{ type: "finish", reason: "max-tokens" },
|
||||
],
|
||||
]);
|
||||
const runtime = new AgentRuntime({ model, logger });
|
||||
|
||||
const result = await runtime.run("Hi");
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.message).toContain("maximum output token limit");
|
||||
expect(model.requests).toHaveLength(1);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "reasoning", text: "thinking..." }],
|
||||
});
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
"Agent loop caught error",
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
status: "failed",
|
||||
errorMessage: expect.stringContaining("maximum output token limit"),
|
||||
iteration: 1,
|
||||
assistantContentPartCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Agent run failed",
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringContaining("maximum output token limit"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not persist an empty assistant message when the model stream fails", async () => {
|
||||
const model = new ScriptedModel([
|
||||
() => [{ type: "finish", reason: "error", error: "upstream failed" }],
|
||||
@@ -1725,6 +1769,14 @@ describe("AgentRuntime", () => {
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
expect(events).toContain("run-failed");
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
"Agent loop caught error",
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
status: "failed",
|
||||
errorMessage: "model failed",
|
||||
}),
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(telemetry.capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
} from "@cline/shared";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const MAX_TOKENS_INCOMPLETE_TURN_MESSAGE =
|
||||
"Model reached the maximum output token limit before completing the turn";
|
||||
|
||||
// Local `createUID` helper. The clinee source imports this from
|
||||
// `@cline/shared` (see `packages/shared/dist/identifier.ts`), but
|
||||
// sdk-re's shared package does not expose it yet. Inlining here keeps
|
||||
@@ -645,6 +648,9 @@ export class AgentRuntime {
|
||||
finishReason,
|
||||
});
|
||||
|
||||
if (finishReason === "max-tokens" && toolCalls.length === 0) {
|
||||
throw new Error(MAX_TOKENS_INCOMPLETE_TURN_MESSAGE);
|
||||
}
|
||||
if (finishReason === "error" && toolCalls.length === 0) {
|
||||
throw new Error(this.state.lastError ?? "Model stream failed");
|
||||
}
|
||||
@@ -718,23 +724,33 @@ export class AgentRuntime {
|
||||
const normalized =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
const isControlledStop = normalized instanceof ControlledStopError;
|
||||
const status =
|
||||
this.abortController.signal.aborted || isControlledStop
|
||||
? "aborted"
|
||||
: "failed";
|
||||
const isAborted = this.abortController.signal.aborted || isControlledStop;
|
||||
const status = isAborted ? "aborted" : "failed";
|
||||
this.state.status = status;
|
||||
this.state.lastError = normalized.message;
|
||||
const lastAssistantMessage = this.findLastAssistantMessage();
|
||||
const result: AgentRunResult = {
|
||||
agentId: this.state.agentId,
|
||||
agentRole: this.state.agentRole,
|
||||
runId: this.state.runId ?? createUID("run"),
|
||||
status,
|
||||
iterations: this.state.iteration,
|
||||
outputText: textFromMessage(this.findLastAssistantMessage()),
|
||||
outputText: textFromMessage(lastAssistantMessage),
|
||||
messages: cloneMessages(this.state.messages),
|
||||
usage: cloneUsage(this.state.usage),
|
||||
error: status === "failed" ? normalized : undefined,
|
||||
};
|
||||
this.config.logger?.log?.("Agent loop caught error", {
|
||||
severity: status === "failed" ? "error" : "warn",
|
||||
agentId: this.state.agentId,
|
||||
agentRole: this.state.agentRole,
|
||||
runId: result.runId,
|
||||
status,
|
||||
iteration: this.state.iteration,
|
||||
errorName: normalized.name,
|
||||
errorMessage: normalized.message,
|
||||
assistantContentPartCount: lastAssistantMessage?.content.length ?? 0,
|
||||
});
|
||||
await this.callAfterRunHooks(result);
|
||||
if (status === "failed") {
|
||||
await this.emit({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -86,6 +86,24 @@ ${content}`);
|
||||
expect(updateSkillMarkdownEnabledState(content, true)).toBe(content);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151: a leading UTF-8 BOM
|
||||
// (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not prevent frontmatter
|
||||
// from being recognized when toggling a skill's enabled state.
|
||||
it("disables a skill whose content starts with a UTF-8 BOM", () => {
|
||||
const content = `\uFEFF---
|
||||
name: code-review
|
||||
description: Review code carefully
|
||||
---
|
||||
First line.`;
|
||||
|
||||
const updated = updateSkillMarkdownEnabledState(content, false);
|
||||
const parsed = parseSkillConfigFromMarkdown(updated, "fallback");
|
||||
|
||||
expect(parsed.disabled).toBe(true);
|
||||
expect(parsed.frontmatter.name).toBe("code-review");
|
||||
expect(parsed.frontmatter.description).toBe("Review code carefully");
|
||||
});
|
||||
|
||||
it("writes toggled content and returns the resulting state", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-skill-toggle-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { stripUtf8Bom } from "@cline/shared";
|
||||
import YAML from "yaml";
|
||||
|
||||
export interface ToggleSkillFrontmatterOptions {
|
||||
@@ -19,10 +20,15 @@ interface MarkdownFrontmatterParts {
|
||||
}
|
||||
|
||||
function parseMarkdownFrontmatter(content: string): MarkdownFrontmatterParts {
|
||||
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
|
||||
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
|
||||
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
|
||||
const normalizedContent = stripUtf8Bom(content);
|
||||
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
const match = normalizedContent.match(frontmatterRegex);
|
||||
if (!match) {
|
||||
return { data: {}, body: content, hadFrontmatter: false };
|
||||
return { data: {}, body: normalizedContent, hadFrontmatter: false };
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match;
|
||||
|
||||
@@ -137,6 +137,23 @@ Document rollout and rollback steps.`,
|
||||
expect(workflow.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151: a leading UTF-8 BOM
|
||||
// (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not prevent frontmatter
|
||||
// from being recognized.
|
||||
it("parses markdown frontmatter when the content starts with a UTF-8 BOM", () => {
|
||||
const skill = parseSkillConfigFromMarkdown(
|
||||
`\uFEFF---
|
||||
name: my-skill
|
||||
description: A test skill
|
||||
---
|
||||
This is a test skill.`,
|
||||
"fallback",
|
||||
);
|
||||
expect(skill.name).toBe("my-skill");
|
||||
expect(skill.description).toBe("A test skill");
|
||||
expect(skill.instructions).toBe("This is a test skill.");
|
||||
});
|
||||
|
||||
it("emits typed events for skills, rules, and workflows in one watcher", async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-user-instructions-loader-"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
import { stripUtf8Bom } from "@cline/shared";
|
||||
import {
|
||||
AGENTS_RULES_FILE_NAME,
|
||||
RULES_CONFIG_DIRECTORY_NAME,
|
||||
@@ -193,10 +194,15 @@ async function discoverManagedPluginRoots(
|
||||
function parseMarkdownFrontmatter(
|
||||
content: string,
|
||||
): ParseMarkdownFrontmatterResult {
|
||||
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
|
||||
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
|
||||
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
|
||||
const normalizedContent = stripUtf8Bom(content);
|
||||
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
const match = normalizedContent.match(frontmatterRegex);
|
||||
if (!match) {
|
||||
return { data: {}, body: content, hadFrontmatter: false };
|
||||
return { data: {}, body: normalizedContent, hadFrontmatter: false };
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match;
|
||||
@@ -211,7 +217,7 @@ function parseMarkdownFrontmatter(
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
data: {},
|
||||
body: content,
|
||||
body: normalizedContent,
|
||||
hadFrontmatter: true,
|
||||
parseError: message,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,23 @@ You are a code reviewer.`);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/12151: a leading UTF-8 BOM
|
||||
// (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not prevent frontmatter
|
||||
// from being recognized.
|
||||
it("parses frontmatter when the content starts with a UTF-8 BOM", () => {
|
||||
const config = parseConfiguredAgentConfig(`\uFEFF---
|
||||
name: code-reviewer
|
||||
description: Reviews code
|
||||
---
|
||||
You are a code reviewer.`);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
name: "code-reviewer",
|
||||
description: "Reviews code",
|
||||
systemPrompt: "You are a code reviewer.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat delimiter lines in the body as frontmatter delimiters", () => {
|
||||
const config = parseConfiguredAgentConfig(`---
|
||||
name: code-reviewer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { stripUtf8Bom } from "@cline/shared";
|
||||
import { resolveAgentConfigSearchPaths } from "@cline/shared/storage";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
@@ -40,6 +41,11 @@ function splitFrontmatter(content: string): {
|
||||
frontmatter: string;
|
||||
body: string;
|
||||
} {
|
||||
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
|
||||
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
|
||||
// match below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
|
||||
content = stripUtf8Bom(content);
|
||||
|
||||
const firstLineMatch = content.match(/^(---)[^\S\r\n]*(?:\r?\n|$)/);
|
||||
if (!firstLineMatch) {
|
||||
throw new Error("Missing YAML frontmatter block in agent config file.");
|
||||
|
||||
@@ -112,6 +112,7 @@ export {
|
||||
parseUserCommandEnvelope,
|
||||
registerDisposable,
|
||||
SDK_ERROR_TELEMETRY_EVENT,
|
||||
stripUtf8Bom,
|
||||
} from "@cline/shared";
|
||||
export * from "@cline/shared/storage";
|
||||
export {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -77,13 +77,16 @@ while generating the catalog. Conceptually:
|
||||
safeOutputTokens = min(
|
||||
modelReportedMaxOutput,
|
||||
contextWindow - estimatedPromptTokens - reserveTokens,
|
||||
userConfiguredOutputCap,
|
||||
userConfiguredOutputCap or productDefaultOutputCap,
|
||||
)
|
||||
```
|
||||
|
||||
The SDK gateway only sends an output token limit when the caller provides
|
||||
`request.options.maxTokens` or an equivalent host configuration. Catalog
|
||||
metadata does not become a request parameter by itself.
|
||||
The SDK gateway resolves an output token limit for the provider request. It uses
|
||||
`request.options.maxTokens` or an equivalent host configuration when present,
|
||||
otherwise it applies the product default output cap
|
||||
(`DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS`, currently 32000) when the model catalog has
|
||||
either an output limit or a context window. Provider modules are responsible for
|
||||
forwarding that limit only to wire API surfaces that support it.
|
||||
|
||||
The exact request-limit policy belongs in the provider/gateway/core request
|
||||
path, not in generated catalog data.
|
||||
@@ -161,5 +164,5 @@ and observable.
|
||||
- `catalog-live.test.ts`: tests catalog normalization behavior.
|
||||
- `catalog.generated.ts`: checked-in generated provider/model catalog.
|
||||
- `../../scripts/generate-models.ts`: writes generated catalog output.
|
||||
- `../providers/ai-sdk.ts`: passes `maxOutputTokens` into AI SDK.
|
||||
- `../providers/ai-sdk.ts`: conditionally passes `maxOutputTokens` into AI SDK.
|
||||
- `../providers/gateway.ts`: resolves per-request/default `maxTokens`.
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
parseJsonStream,
|
||||
sanitizeSurrogates,
|
||||
} from "@cline/shared";
|
||||
import { jsonSchema, NoSuchToolError, streamText } from "ai";
|
||||
import { type CallSettings, jsonSchema, NoSuchToolError, streamText } from "ai";
|
||||
import { nanoid } from "nanoid";
|
||||
import { extractErrorMessage } from "./format";
|
||||
import {
|
||||
@@ -53,6 +53,18 @@ interface GatewayNormalizedUsage {
|
||||
}
|
||||
type ProviderModuleKind = AiSdkProviderOptionsTarget;
|
||||
|
||||
export function buildAiSdkStreamConfig(
|
||||
request: GatewayStreamRequest,
|
||||
_context: GatewayProviderContext,
|
||||
): Partial<CallSettings> {
|
||||
return {
|
||||
...(request.maxTokens !== undefined
|
||||
? { maxOutputTokens: request.maxTokens }
|
||||
: {}),
|
||||
temperature: request.temperature,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCachedAiSdkMessages(
|
||||
request: GatewayStreamRequest,
|
||||
context: GatewayProviderContext,
|
||||
@@ -352,10 +364,15 @@ function toAiSdkMessages(
|
||||
}
|
||||
}
|
||||
|
||||
// A message left empty only because its reasoning was dropped is
|
||||
// omitted entirely instead of forwarded as an empty turn.
|
||||
const emptiedByDroppedReasoning = !includeReasoning && skippedReasoning;
|
||||
if (content.length > 0) {
|
||||
normalizedMessages.push({ role: message.role, content });
|
||||
} else if (!includeReasoning && skippedReasoning) {
|
||||
} else if (message.role === "user" || message.role === "assistant") {
|
||||
} else if (
|
||||
!emptiedByDroppedReasoning &&
|
||||
(message.role === "user" || message.role === "assistant")
|
||||
) {
|
||||
normalizedMessages.push({ role: message.role, content: "" });
|
||||
}
|
||||
}
|
||||
@@ -1178,6 +1195,9 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
|
||||
context,
|
||||
kind,
|
||||
) as never;
|
||||
const requestConfig = provider.buildStreamConfig
|
||||
? provider.buildStreamConfig(request, context)
|
||||
: buildAiSdkStreamConfig(request, context);
|
||||
recordProviderRequestCapture({
|
||||
stage: "ai_sdk_prompt",
|
||||
request,
|
||||
@@ -1186,8 +1206,7 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
|
||||
...(useSystemOption ? { system: systemPrompt } : {}),
|
||||
tools,
|
||||
providerOptions,
|
||||
maxOutputTokens: request.maxTokens,
|
||||
temperature: request.temperature,
|
||||
...requestConfig,
|
||||
},
|
||||
});
|
||||
stream = streamText({
|
||||
@@ -1195,16 +1214,13 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
|
||||
messages: messages as never,
|
||||
...(useSystemOption ? { system: systemPrompt } : {}),
|
||||
tools: tools as never,
|
||||
temperature: request.temperature,
|
||||
...(request.maxTokens !== undefined
|
||||
? { maxOutputTokens: request.maxTokens }
|
||||
: {}),
|
||||
abortSignal: request.signal,
|
||||
experimental_repairToolCall: repairMalformedToolCall as never,
|
||||
experimental_telemetry: {
|
||||
isEnabled: langfuse,
|
||||
},
|
||||
providerOptions,
|
||||
...requestConfig,
|
||||
onError: ({ error: streamError }) => {
|
||||
const msg = extractErrorMessage(streamError);
|
||||
capturedError.current = msg;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
toGatewayRequestMessages,
|
||||
} from "./compat";
|
||||
import { ClineNotSubscribedError } from "./errors";
|
||||
import { DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS } from "./gateway";
|
||||
import type { Message } from "./types";
|
||||
|
||||
const streamTextSpy = vi.fn();
|
||||
@@ -361,7 +362,7 @@ describe("createGatewayApiHandler.createMessage", () => {
|
||||
openaiCompatibleSpy.mockClear();
|
||||
});
|
||||
|
||||
it("does not convert catalog maxTokens into request maxOutputTokens", async () => {
|
||||
it("uses the default maxOutputTokens without expanding to catalog maxTokens", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "finish", finishReason: "stop" };
|
||||
@@ -395,7 +396,10 @@ describe("createGatewayApiHandler.createMessage", () => {
|
||||
const call = streamTextSpy.mock.calls.at(-1)?.[0] as
|
||||
| { maxOutputTokens?: unknown }
|
||||
| undefined;
|
||||
expect(call).not.toHaveProperty("maxOutputTokens");
|
||||
expect(call).toHaveProperty(
|
||||
"maxOutputTokens",
|
||||
DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends configured OpenAI-compatible maxOutputTokens to the provider request", async () => {
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
} from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeModelsDevProviderModels } from "../catalog/catalog-live";
|
||||
import { createGateway, resolveGatewayRequestMaxTokens } from "./gateway";
|
||||
import {
|
||||
createGateway,
|
||||
DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS,
|
||||
resolveGatewayRequestMaxTokens,
|
||||
} from "./gateway";
|
||||
|
||||
const streamTextSpy = vi.fn();
|
||||
const openaiCompatibleFactorySpy = vi.fn();
|
||||
@@ -218,14 +222,46 @@ describe("sdk-gateway", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not synthesize request max tokens from catalog metadata", () => {
|
||||
it("uses the old default output cap when request max tokens are omitted", () => {
|
||||
expect(
|
||||
resolveGatewayRequestMaxTokens({
|
||||
requestedMaxTokens: undefined,
|
||||
model: { maxOutputTokens: 202_800, contextWindow: 202_800 },
|
||||
estimatedInputTokens: 1_000,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
).toBe(DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS);
|
||||
});
|
||||
|
||||
it("lifts the default output cap above an explicit reasoning budget", () => {
|
||||
expect(
|
||||
resolveGatewayRequestMaxTokens({
|
||||
requestedMaxTokens: undefined,
|
||||
reasoningBudgetTokens: 50_000,
|
||||
model: { maxOutputTokens: 202_800, contextWindow: 202_800 },
|
||||
estimatedInputTokens: 1_000,
|
||||
outputReserveTokens: 1_024,
|
||||
}),
|
||||
).toBe(51_024);
|
||||
|
||||
// Still clamped by the model's max output tokens.
|
||||
expect(
|
||||
resolveGatewayRequestMaxTokens({
|
||||
requestedMaxTokens: undefined,
|
||||
reasoningBudgetTokens: 50_000,
|
||||
model: { maxOutputTokens: 40_000, contextWindow: 202_800 },
|
||||
estimatedInputTokens: 1_000,
|
||||
}),
|
||||
).toBe(40_000);
|
||||
|
||||
// Explicit request max tokens still win over the reasoning floor.
|
||||
expect(
|
||||
resolveGatewayRequestMaxTokens({
|
||||
requestedMaxTokens: 8_192,
|
||||
reasoningBudgetTokens: 50_000,
|
||||
model: { maxOutputTokens: 202_800, contextWindow: 202_800 },
|
||||
estimatedInputTokens: 1_000,
|
||||
}),
|
||||
).toBe(8_192);
|
||||
});
|
||||
|
||||
it("resolves explicit request max tokens from model and context caps", () => {
|
||||
@@ -290,10 +326,10 @@ describe("sdk-gateway", () => {
|
||||
expect(estimatedTokens).toBeGreaterThan(4_000);
|
||||
});
|
||||
|
||||
it("does not apply catalog output caps when the request omits max tokens", async () => {
|
||||
it("applies the old default output cap when the request omits max tokens", async () => {
|
||||
const createProvider = vi.fn(() => ({
|
||||
async *stream(request: { maxTokens?: number }) {
|
||||
expect(request.maxTokens).toBeUndefined();
|
||||
expect(request.maxTokens).toBe(DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS);
|
||||
yield { type: "finish", reason: "stop" } satisfies AgentModelEvent;
|
||||
},
|
||||
}));
|
||||
@@ -582,6 +618,41 @@ describe("sdk-gateway", () => {
|
||||
}),
|
||||
});
|
||||
expect(events.at(-1)).toEqual({ type: "finish", reason: "tool-calls" });
|
||||
const call = streamTextSpy.mock.calls.at(-1)?.[0] as
|
||||
| { maxOutputTokens?: unknown }
|
||||
| undefined;
|
||||
expect(call).not.toHaveProperty("maxOutputTokens");
|
||||
});
|
||||
|
||||
it("sends explicit maxOutputTokens through the OpenAI Responses provider", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
]),
|
||||
});
|
||||
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{
|
||||
providerId: "openai-native",
|
||||
apiKey: "test",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await collect(
|
||||
await gateway.stream({
|
||||
providerId: "openai-native",
|
||||
modelId: "gpt-5-mini",
|
||||
messages: baseMessages,
|
||||
maxTokens: 8_192,
|
||||
}),
|
||||
);
|
||||
|
||||
const call = streamTextSpy.mock.calls.at(-1)?.[0] as
|
||||
| { maxOutputTokens?: unknown }
|
||||
| undefined;
|
||||
expect(call?.maxOutputTokens).toBe(8_192);
|
||||
});
|
||||
|
||||
it("surfaces nested AI SDK stream errors as human-readable finish messages", async () => {
|
||||
@@ -2249,6 +2320,32 @@ describe("sdk-gateway", () => {
|
||||
expect(call).not.toHaveProperty("maxOutputTokens");
|
||||
});
|
||||
|
||||
it("does not send explicit maxOutputTokens to ChatGPT OAuth", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
]),
|
||||
});
|
||||
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [{ providerId: "openai-codex" }],
|
||||
});
|
||||
|
||||
await collect(
|
||||
await gateway.stream({
|
||||
providerId: "openai-codex",
|
||||
modelId: "gpt-5.4",
|
||||
messages: baseMessages,
|
||||
maxTokens: 8_192,
|
||||
}),
|
||||
);
|
||||
|
||||
const call = streamTextSpy.mock.calls.at(-1)?.[0] as
|
||||
| { maxOutputTokens?: unknown }
|
||||
| undefined;
|
||||
expect(call).not.toHaveProperty("maxOutputTokens");
|
||||
});
|
||||
|
||||
it("passes Codex instructions through provider options and removes the system message from messages", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
@@ -3496,7 +3593,7 @@ describe("sdk-gateway", () => {
|
||||
reasoning: { enabled: true },
|
||||
}),
|
||||
openrouter: expect.objectContaining({
|
||||
reasoning: { enabled: true },
|
||||
reasoning: { enabled: true, max_tokens: 19_200 },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -15,9 +15,11 @@ import { estimateRequestInputTokens } from "@cline/shared";
|
||||
import { toAsyncIterable } from "./async";
|
||||
import { BUILTIN_PROVIDER_REGISTRATIONS } from "./builtins-runtime";
|
||||
import { GatewayRegistry } from "./registry";
|
||||
import { isPositiveFiniteNumber } from "./utils";
|
||||
|
||||
export type * from "@cline/shared";
|
||||
|
||||
export const DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS = 32_000;
|
||||
const GATEWAY_OUTPUT_RESERVE_TOKENS = 1_024;
|
||||
|
||||
function mergeRequestMetadata(
|
||||
@@ -115,27 +117,42 @@ class GatewayModelAdapter implements AgentModel {
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
export function resolveGatewayRequestMaxTokens(input: {
|
||||
requestedMaxTokens?: number;
|
||||
model: Pick<GatewayModelDefinition, "contextWindow" | "maxOutputTokens">;
|
||||
estimatedInputTokens: number;
|
||||
defaultMaxOutputTokens?: number;
|
||||
outputReserveTokens?: number;
|
||||
reasoningBudgetTokens?: number;
|
||||
onContextOverflow?: (details: {
|
||||
contextWindow: number;
|
||||
estimatedInputTokens: number;
|
||||
reserveTokens: number;
|
||||
}) => void;
|
||||
}): number | undefined {
|
||||
if (!isPositiveFiniteNumber(input.requestedMaxTokens)) {
|
||||
return undefined;
|
||||
const caps: number[] = [];
|
||||
if (isPositiveFiniteNumber(input.requestedMaxTokens)) {
|
||||
caps.push(Math.floor(input.requestedMaxTokens));
|
||||
} else {
|
||||
// Providers like Anthropic require max_tokens to exceed the thinking
|
||||
// budget, so an explicit reasoning budget lifts the synthesized default
|
||||
// (still clamped by model max output and remaining context below).
|
||||
const reasoningFloor = isPositiveFiniteNumber(input.reasoningBudgetTokens)
|
||||
? Math.floor(input.reasoningBudgetTokens) +
|
||||
(input.outputReserveTokens ?? GATEWAY_OUTPUT_RESERVE_TOKENS)
|
||||
: 0;
|
||||
const defaultMaxOutputTokens = Math.max(
|
||||
input.defaultMaxOutputTokens ?? DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS,
|
||||
reasoningFloor,
|
||||
);
|
||||
if (
|
||||
isPositiveFiniteNumber(input.model.maxOutputTokens) ||
|
||||
isPositiveFiniteNumber(input.model.contextWindow)
|
||||
) {
|
||||
caps.push(defaultMaxOutputTokens);
|
||||
}
|
||||
}
|
||||
|
||||
const caps: number[] = [Math.floor(input.requestedMaxTokens)];
|
||||
|
||||
if (isPositiveFiniteNumber(input.model.maxOutputTokens)) {
|
||||
caps.push(Math.floor(input.model.maxOutputTokens));
|
||||
}
|
||||
@@ -234,30 +251,31 @@ export class DefaultGateway implements Gateway {
|
||||
request.providerId,
|
||||
);
|
||||
const provider = await providerRecord.createProvider(providerRecord.config);
|
||||
const maxTokens = isPositiveFiniteNumber(request.maxTokens)
|
||||
? resolveGatewayRequestMaxTokens({
|
||||
requestedMaxTokens: request.maxTokens,
|
||||
model: resolved.model,
|
||||
estimatedInputTokens: estimateRequestInputTokens(request),
|
||||
onContextOverflow: (details) => {
|
||||
this.logger?.log(
|
||||
"Estimated prompt tokens exceed model context window",
|
||||
{
|
||||
severity: "warn",
|
||||
providerId: resolved.provider.id,
|
||||
modelId: resolved.model.id,
|
||||
...details,
|
||||
},
|
||||
);
|
||||
const maxTokens = resolveGatewayRequestMaxTokens({
|
||||
requestedMaxTokens: request.maxTokens,
|
||||
model: resolved.model,
|
||||
estimatedInputTokens: estimateRequestInputTokens(request),
|
||||
reasoningBudgetTokens: request.reasoning?.budgetTokens,
|
||||
onContextOverflow: (details) => {
|
||||
this.logger?.log(
|
||||
"Estimated prompt tokens exceed model context window",
|
||||
{
|
||||
severity: "warn",
|
||||
providerId: resolved.provider.id,
|
||||
modelId: resolved.model.id,
|
||||
...details,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
);
|
||||
},
|
||||
});
|
||||
const stream = await provider.stream(
|
||||
{
|
||||
...request,
|
||||
modelId: resolved.model.id,
|
||||
providerId: resolved.provider.id,
|
||||
maxTokens,
|
||||
defaultedMaxTokens:
|
||||
maxTokens !== undefined && !isPositiveFiniteNumber(request.maxTokens),
|
||||
},
|
||||
{
|
||||
provider: resolved.provider,
|
||||
|
||||
@@ -283,7 +283,7 @@ const openRouterReasoningRule: ProviderOptionRule = {
|
||||
build: (input) =>
|
||||
buildReasoningPatchForProvider(
|
||||
input,
|
||||
buildOpenRouterReasoningOptions(input.request),
|
||||
buildOpenRouterReasoningOptions(input.request, input.context),
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ type ContextOverrides = {
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
family?: string;
|
||||
maxOutputTokens?: number;
|
||||
modelMetadata?: NonNullable<GatewayProviderContext["model"]["metadata"]>;
|
||||
capabilities?: GatewayProviderContext["model"]["capabilities"];
|
||||
metadata?: GatewayProviderContext["provider"]["metadata"];
|
||||
@@ -87,6 +88,7 @@ function makeContext(options?: ContextOverrides): GatewayProviderContext {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
providerId,
|
||||
maxOutputTokens: options?.maxOutputTokens,
|
||||
capabilities: options?.capabilities,
|
||||
metadata: modelMetadata,
|
||||
},
|
||||
@@ -605,7 +607,7 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
|
||||
expect: [
|
||||
{
|
||||
bucket: "openrouter",
|
||||
has: { reasoning: { enabled: true } },
|
||||
has: { reasoning: { enabled: true, max_tokens: 19_200 } },
|
||||
lacks: ["thinking", "effort", "reasoningEffort"],
|
||||
},
|
||||
{
|
||||
@@ -614,6 +616,65 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "openrouter reasoning enabled-only uses resolved output cap for reasoning budget",
|
||||
request: {
|
||||
providerId: "openrouter",
|
||||
modelId: "openai/gpt-oss-120b",
|
||||
maxTokens: 10_000,
|
||||
reasoning: { enabled: true },
|
||||
},
|
||||
expect: [
|
||||
{
|
||||
bucket: "openrouter",
|
||||
has: { reasoning: { enabled: true, max_tokens: 6_000 } },
|
||||
lacks: ["thinking", "effort", "reasoningEffort"],
|
||||
},
|
||||
{
|
||||
bucket: "openaiCompatible",
|
||||
lacks: ["thinking", "reasoning", "effort", "reasoningEffort"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "openrouter reasoning enabled-only uses model output cap when request cap is absent",
|
||||
request: {
|
||||
providerId: "openrouter",
|
||||
modelId: "openai/gpt-oss-120b",
|
||||
reasoning: { enabled: true },
|
||||
},
|
||||
context: { maxOutputTokens: 12_000 },
|
||||
expect: [
|
||||
{
|
||||
bucket: "openrouter",
|
||||
has: { reasoning: { enabled: true, max_tokens: 7_200 } },
|
||||
lacks: ["thinking", "effort", "reasoningEffort"],
|
||||
},
|
||||
{
|
||||
bucket: "openaiCompatible",
|
||||
lacks: ["thinking", "reasoning", "effort", "reasoningEffort"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "openrouter reasoning effort sends only effort (no max_tokens, which OpenRouter rejects alongside effort)",
|
||||
request: {
|
||||
providerId: "openrouter",
|
||||
modelId: "openai/gpt-oss-120b",
|
||||
reasoning: { effort: "high" },
|
||||
},
|
||||
expect: [
|
||||
{
|
||||
bucket: "openrouter",
|
||||
has: { reasoning: { effort: "high" } },
|
||||
lacks: ["thinking", "reasoningEffort"],
|
||||
},
|
||||
{
|
||||
bucket: "openaiCompatible",
|
||||
lacks: ["thinking", "reasoning", "effort", "reasoningEffort"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "openrouter unset reasoning -> no reasoning field",
|
||||
request: {
|
||||
@@ -665,7 +726,7 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
|
||||
expect: [
|
||||
{
|
||||
bucket: "openrouter",
|
||||
has: { reasoning: { enabled: true } },
|
||||
has: { reasoning: { enabled: true, max_tokens: 19_200 } },
|
||||
lacks: ["thinking"],
|
||||
},
|
||||
{
|
||||
@@ -1307,7 +1368,7 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
|
||||
expect: [
|
||||
{
|
||||
bucket: "openrouter",
|
||||
has: { reasoning: { enabled: true } },
|
||||
has: { reasoning: { enabled: true, max_tokens: 19_200 } },
|
||||
lacks: ["thinking", "effort", "reasoningEffort"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { GatewayStreamRequest } from "@cline/shared";
|
||||
import type {
|
||||
GatewayProviderContext,
|
||||
GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
import { DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS } from "../gateway";
|
||||
import { isPositiveFiniteNumber } from "../utils";
|
||||
|
||||
// OpenRouter separates total output limits (`max_tokens` / `max_output_tokens`)
|
||||
// from `reasoning.max_tokens`, which caps only the reasoning-token portion.
|
||||
// Sources:
|
||||
// - https://openrouter.ai/docs/api/reference/parameters
|
||||
// - https://openrouter.ai/docs/api/reference/responses/reasoning
|
||||
const OPENROUTER_REASONING_BUDGET_FRACTION = 0.6;
|
||||
|
||||
export function hasReasoningControls(
|
||||
reasoning: GatewayStreamRequest["reasoning"],
|
||||
@@ -10,8 +22,24 @@ export function hasReasoningControls(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOpenRouterReasoningMaxTokens(
|
||||
request: GatewayStreamRequest,
|
||||
context?: GatewayProviderContext,
|
||||
): number {
|
||||
const outputMaxTokens = isPositiveFiniteNumber(request.maxTokens)
|
||||
? request.maxTokens
|
||||
: isPositiveFiniteNumber(context?.model.maxOutputTokens)
|
||||
? context.model.maxOutputTokens
|
||||
: DEFAULT_GATEWAY_MAX_OUTPUT_TOKENS;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(outputMaxTokens * OPENROUTER_REASONING_BUDGET_FRACTION),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOpenRouterReasoningOptions(
|
||||
request: GatewayStreamRequest,
|
||||
context?: GatewayProviderContext,
|
||||
): Record<string, unknown> | undefined {
|
||||
const reasoning = request.reasoning;
|
||||
if (!hasReasoningControls(reasoning)) {
|
||||
@@ -22,8 +50,13 @@ export function buildOpenRouterReasoningOptions(
|
||||
return { effort: "none" };
|
||||
}
|
||||
|
||||
// OpenRouter accepts one reasoning control mode. Preserve this precedence:
|
||||
// explicit disable, exact token budget, effort level, then plain enable.
|
||||
// AI SDK `maxOutputTokens` still caps the whole response. This provider option
|
||||
// reserves room within that response by capping OpenRouter reasoning tokens.
|
||||
// Preserve explicit reasoning budgets when present; otherwise derive the cap
|
||||
// from the resolved request budget, model catalog output limit, or default.
|
||||
// OpenRouter rejects requests carrying both `reasoning.effort` and
|
||||
// `reasoning.max_tokens`, so the effort branch sends only `effort`.
|
||||
// DOCS: https://openrouter.ai/docs/api/reference/responses/reasoning
|
||||
if (typeof reasoning?.budgetTokens === "number") {
|
||||
return { max_tokens: reasoning.budgetTokens };
|
||||
}
|
||||
@@ -33,7 +66,10 @@ export function buildOpenRouterReasoningOptions(
|
||||
}
|
||||
|
||||
if (reasoning?.enabled === true) {
|
||||
return { enabled: true };
|
||||
return {
|
||||
enabled: true,
|
||||
max_tokens: resolveOpenRouterReasoningMaxTokens(request, context),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
GatewayResolvedProviderConfig,
|
||||
GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOpenAIProviderModule } from "./openai";
|
||||
|
||||
const createOpenAIMock = vi.hoisted(() => vi.fn());
|
||||
const responsesModelMock = vi.hoisted(() =>
|
||||
vi.fn((modelId: string) => ({ provider: "openai", modelId })),
|
||||
);
|
||||
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: createOpenAIMock,
|
||||
}));
|
||||
|
||||
describe("createOpenAIProviderModule", () => {
|
||||
beforeEach(() => {
|
||||
createOpenAIMock.mockReset();
|
||||
createOpenAIMock.mockReturnValue({
|
||||
responses: responsesModelMock,
|
||||
});
|
||||
responsesModelMock.mockClear();
|
||||
});
|
||||
|
||||
it("forwards maxOutputTokens for explicit caps from direct callers", async () => {
|
||||
const provider = await createOpenAIProviderModule(config(), context());
|
||||
|
||||
const streamConfig = provider.buildStreamConfig?.(
|
||||
request({ maxTokens: 8_192 }),
|
||||
context(),
|
||||
);
|
||||
|
||||
expect(streamConfig?.maxOutputTokens).toBe(8_192);
|
||||
});
|
||||
|
||||
it("forwards maxOutputTokens for gateway-resolved explicit caps", async () => {
|
||||
const provider = await createOpenAIProviderModule(config(), context());
|
||||
|
||||
const streamConfig = provider.buildStreamConfig?.(
|
||||
request({ maxTokens: 8_192, defaultedMaxTokens: false }),
|
||||
context(),
|
||||
);
|
||||
|
||||
expect(streamConfig?.maxOutputTokens).toBe(8_192);
|
||||
});
|
||||
|
||||
it("drops gateway-synthesized default caps", async () => {
|
||||
const provider = await createOpenAIProviderModule(config(), context());
|
||||
|
||||
const streamConfig = provider.buildStreamConfig?.(
|
||||
request({ maxTokens: 32_000, defaultedMaxTokens: true }),
|
||||
context(),
|
||||
);
|
||||
|
||||
expect(streamConfig).not.toHaveProperty("maxOutputTokens");
|
||||
});
|
||||
|
||||
it("drops explicit caps for the ChatGPT OAuth backend", async () => {
|
||||
const provider = await createOpenAIProviderModule(
|
||||
config({ baseUrl: "https://chatgpt.com/backend-api/codex" }),
|
||||
context(),
|
||||
);
|
||||
|
||||
const streamConfig = provider.buildStreamConfig?.(
|
||||
request({ maxTokens: 8_192 }),
|
||||
context(),
|
||||
);
|
||||
|
||||
expect(streamConfig).not.toHaveProperty("maxOutputTokens");
|
||||
});
|
||||
|
||||
it("does not treat non-chatgpt.com hosts containing 'chatgpt.com' as OAuth", async () => {
|
||||
for (const baseUrl of [
|
||||
"https://example.com/chatgpt.com",
|
||||
"https://chatgpt.com.example.com/v1",
|
||||
"https://notchatgpt.com/v1",
|
||||
]) {
|
||||
const provider = await createOpenAIProviderModule(
|
||||
config({ baseUrl }),
|
||||
context(),
|
||||
);
|
||||
|
||||
const streamConfig = provider.buildStreamConfig?.(
|
||||
request({ maxTokens: 8_192 }),
|
||||
context(),
|
||||
);
|
||||
|
||||
expect(streamConfig?.maxOutputTokens).toBe(8_192);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function config(
|
||||
overrides: Partial<GatewayResolvedProviderConfig> = {},
|
||||
): GatewayResolvedProviderConfig {
|
||||
return {
|
||||
providerId: "openai-native",
|
||||
apiKey: "test-api-key",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function context() {
|
||||
return {
|
||||
provider: {
|
||||
id: "openai-native",
|
||||
name: "OpenAI",
|
||||
defaultModelId: "gpt-5-mini",
|
||||
models: [],
|
||||
},
|
||||
model: {
|
||||
providerId: "openai-native",
|
||||
id: "gpt-5-mini",
|
||||
name: "gpt-5-mini",
|
||||
},
|
||||
config: config(),
|
||||
};
|
||||
}
|
||||
|
||||
function request(
|
||||
overrides: Partial<GatewayStreamRequest>,
|
||||
): GatewayStreamRequest {
|
||||
return {
|
||||
providerId: "openai-native",
|
||||
modelId: "gpt-5-mini",
|
||||
messages: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,18 @@ import type {
|
||||
import { resolveApiKey } from "../http";
|
||||
import type { ProviderFactoryResult } from "./types";
|
||||
|
||||
function isChatGptOAuthBaseUrl(baseUrl: string | undefined): boolean {
|
||||
if (!baseUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { hostname } = new URL(baseUrl);
|
||||
return hostname === "chatgpt.com" || hostname.endsWith(".chatgpt.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOpenAIProviderModule(
|
||||
config: GatewayResolvedProviderConfig,
|
||||
context: GatewayProviderContext,
|
||||
@@ -18,7 +30,22 @@ export async function createOpenAIProviderModule(
|
||||
fetch: config.fetch,
|
||||
name: context.provider.id,
|
||||
});
|
||||
// The ChatGPT OAuth Codex backend rejects `max_output_tokens`, and the
|
||||
// OpenAI Responses API applies its own defaults, so gateway-synthesized
|
||||
// caps are never forwarded. Explicit caps — whether resolved by the
|
||||
// gateway from a caller request or passed straight to this provider —
|
||||
// are honored for API-key usage because that endpoint supports output
|
||||
// limits.
|
||||
const isChatGptOAuth = isChatGptOAuthBaseUrl(config.baseUrl);
|
||||
return {
|
||||
model: (modelId) => provider.responses(modelId),
|
||||
buildStreamConfig: (request) => ({
|
||||
...(!isChatGptOAuth &&
|
||||
request.maxTokens !== undefined &&
|
||||
request.defaultedMaxTokens !== true
|
||||
? { maxOutputTokens: request.maxTokens }
|
||||
: {}),
|
||||
temperature: request.temperature,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type {
|
||||
GatewayProviderContext,
|
||||
GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
import type { CallSettings } from "ai";
|
||||
|
||||
export interface ProviderFactoryResult {
|
||||
model: (modelId: string) => unknown;
|
||||
buildStreamConfig?: (
|
||||
request: GatewayStreamRequest,
|
||||
context: GatewayProviderContext,
|
||||
) => Partial<CallSettings>;
|
||||
}
|
||||
|
||||
export interface AiSdkStreamPart {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"response": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"ID_REDACTED\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":22,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"OK\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":22,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":4}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
|
||||
"responseIsBinary": false,
|
||||
"contentType": "text/event-stream; charset=utf-8",
|
||||
"requestBody": "{\"max_tokens\":128000,\"messages\":[{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"Reply with the single word OK.\",\"type\":\"text\"}],\"role\":\"user\"}],\"model\":\"claude-sonnet-4-6\",\"stream\":true,\"system\":[{\"text\":\"You are a concise assistant.\",\"type\":\"text\"}]}"
|
||||
"requestBody": "{\"max_tokens\":32000,\"messages\":[{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"Reply with the single word OK.\",\"type\":\"text\"}],\"role\":\"user\"}],\"model\":\"claude-sonnet-4-6\",\"stream\":true,\"system\":[{\"text\":\"You are a concise assistant.\",\"type\":\"text\"}]}"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"response": "data: {\"id\":\"ID_REDACTED\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"REDACTED\"}\n\ndata: {\"id\":\"ID_REDACTED\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"OK\"},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"REDACTED\"}\n\ndata: {\"id\":\"ID_REDACTED\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"provider_metadata\":{\"anthropic\":{\"usage\":{\"input_tokens\":22,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":4,\"service_tier\":\"standard\",\"inference_geo\":\"global\"},\"cacheCreationInputTokens\":0,\"stopSequence\":null,\"iterations\":null,\"container\":null,\"contextManagement\":null},\"gateway\":{\"routing\":{\"originalModelId\":\"anthropic/claude-sonnet-4.6\",\"resolvedProvider\":\"anthropic\",\"fallbacksAvailable\":[\"vertexAnthropic\",\"bedrock\"],\"planningReasoning\":\"REDACTED\",\"canonicalSlug\":\"anthropic/claude-sonnet-4.6\",\"finalProvider\":\"anthropic\",\"modelAttemptCount\":1,\"modelAttempts\":[{\"canonicalSlug\":\"anthropic/claude-sonnet-4.6\",\"success\":true,\"providerAttemptCount\":1,\"providerAttempts\":[{\"provider\":\"anthropic\",\"credentialType\":\"byok\",\"success\":true,\"startTime\":0,\"endTime\":0,\"providerRequestId\":\"ID_REDACTED\",\"statusCode\":200,\"providerResponseId\":\"ID_REDACTED\"}]}],\"totalProviderAttemptCount\":1},\"cost\":\"0\",\"marketCost\":\"0.000126\",\"surchargeCost\":\"0\",\"gatewayCost\":\"0\",\"generationId\":\"ID_REDACTED\"}}},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":4,\"total_tokens\":26,\"cost\":0,\"is_byok\":true,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.000126,\"upstream_inference_prompt_cost\":0,\"upstream_inference_completions_cost\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0},\"cache_creation_input_tokens\":0,\"market_cost\":0.000126},\"system_fingerprint\":\"REDACTED\",\"generationId\":\"ID_REDACTED\"}\n\ndata: [DONE]\n\n",
|
||||
"responseIsBinary": false,
|
||||
"contentType": "text/event-stream",
|
||||
"requestBody": "{\"cache_control\":{\"type\":\"ephemeral\"},\"messages\":[{\"content\":\"You are a concise assistant.\",\"role\":\"system\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"Reply with the single word OK.\",\"role\":\"user\"}],\"model\":\"anthropic/claude-sonnet-4.6\",\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
|
||||
"requestBody": "{\"cache_control\":{\"type\":\"ephemeral\"},\"max_tokens\":32000,\"messages\":[{\"content\":\"You are a concise assistant.\",\"role\":\"system\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"Reply with the single word OK.\",\"role\":\"user\"}],\"model\":\"anthropic/claude-sonnet-4.6\",\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/sdk",
|
||||
"description": "Cline SDK - user-facing alias for @cline/core",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -37,6 +37,7 @@ await runBuild("node", {
|
||||
"./src/index.ts",
|
||||
"./src/automation/index.ts",
|
||||
"./src/db/index.ts",
|
||||
"./src/node.ts",
|
||||
"./src/remote-config/index.ts",
|
||||
"./src/storage/index.ts",
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.63",
|
||||
"version": "0.0.64",
|
||||
"description": "Shared utilities, types, and schemas for Cline packages",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,6 +33,10 @@
|
||||
"types": "./dist/db/index.d.ts",
|
||||
"import": "./dist/db/index.js"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./dist/node.d.ts",
|
||||
"import": "./dist/node.js"
|
||||
},
|
||||
"./automation": {
|
||||
"types": "./dist/automation/index.d.ts",
|
||||
"import": "./dist/automation/index.js"
|
||||
|
||||
@@ -224,6 +224,7 @@ export { getDefaultShell, getShellArgs } from "./parse/shell";
|
||||
export {
|
||||
maskSecret,
|
||||
sanitizeFileName,
|
||||
stripUtf8Bom,
|
||||
trimNonEmpty,
|
||||
truncateSplit,
|
||||
truncateStr,
|
||||
|
||||
@@ -238,6 +238,7 @@ export { getDefaultShell, getShellArgs } from "./parse/shell";
|
||||
export {
|
||||
maskSecret,
|
||||
sanitizeFileName,
|
||||
stripUtf8Bom,
|
||||
trimNonEmpty,
|
||||
truncateSplit,
|
||||
truncateStr,
|
||||
|
||||
@@ -166,6 +166,14 @@ export interface GatewayStreamRequest {
|
||||
tools?: readonly AgentToolDefinition[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
/**
|
||||
* Set by the gateway when `maxTokens` was synthesized from gateway/model
|
||||
* defaults rather than derived from an explicit caller cap. Providers can
|
||||
* use this to avoid forwarding synthesized caps to backends that reject
|
||||
* them, while still honoring explicit caps from any caller — including
|
||||
* ones that reach the provider without going through the gateway.
|
||||
*/
|
||||
defaultedMaxTokens?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
reasoning?: {
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readFileStrippingUtf8Bom, readFileSyncStrippingUtf8Bom } from "./node";
|
||||
|
||||
describe("UTF-8 file readers", () => {
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function writeTempFile(content: string): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "cline-utf8-file-"));
|
||||
tempDirectories.push(directory);
|
||||
const filePath = join(directory, "example.txt");
|
||||
await writeFile(filePath, content, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
it("strips a leading BOM in synchronous reads", async () => {
|
||||
const filePath = await writeTempFile("\uFEFFcontent");
|
||||
|
||||
expect(readFileSyncStrippingUtf8Bom(filePath)).toBe("content");
|
||||
});
|
||||
|
||||
it("strips a leading BOM in asynchronous reads", async () => {
|
||||
const filePath = await writeTempFile("\uFEFFcontent");
|
||||
|
||||
await expect(readFileStrippingUtf8Bom(filePath)).resolves.toBe("content");
|
||||
});
|
||||
|
||||
it("preserves interior BOM characters", async () => {
|
||||
const filePath = await writeTempFile("a\uFEFFb");
|
||||
|
||||
expect(readFileSyncStrippingUtf8Bom(filePath)).toBe("a\uFEFFb");
|
||||
await expect(readFileStrippingUtf8Bom(filePath)).resolves.toBe("a\uFEFFb");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { stripUtf8Bom } from "./parse/string";
|
||||
|
||||
type ReadFileSyncPath = Parameters<typeof readFileSync>[0];
|
||||
type ReadFilePath = Parameters<typeof readFile>[0];
|
||||
|
||||
/** Read a UTF-8 text file and remove its optional leading byte order mark. */
|
||||
export function readFileSyncStrippingUtf8Bom(path: ReadFileSyncPath): string {
|
||||
return stripUtf8Bom(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
/** Read a UTF-8 text file and remove its optional leading byte order mark. */
|
||||
export async function readFileStrippingUtf8Bom(
|
||||
path: ReadFilePath,
|
||||
): Promise<string> {
|
||||
return stripUtf8Bom(await readFile(path, "utf8"));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { trimNonEmpty } from "./string";
|
||||
import { stripUtf8Bom, trimNonEmpty } from "./string";
|
||||
|
||||
describe("trimNonEmpty", () => {
|
||||
it("returns trimmed strings and omits empty values", () => {
|
||||
@@ -10,3 +10,23 @@ describe("trimNonEmpty", () => {
|
||||
expect(trimNonEmpty(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripUtf8Bom", () => {
|
||||
it("removes a leading BOM character", () => {
|
||||
expect(stripUtf8Bom("\uFEFF---\nname: foo\n---\n")).toBe(
|
||||
"---\nname: foo\n---\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves text without a BOM unchanged", () => {
|
||||
expect(stripUtf8Bom("---\nname: foo\n---\n")).toBe("---\nname: foo\n---\n");
|
||||
});
|
||||
|
||||
it("only strips a BOM at the start of the string", () => {
|
||||
expect(stripUtf8Bom("a\uFEFFb")).toBe("a\uFEFFb");
|
||||
});
|
||||
|
||||
it("handles empty strings", () => {
|
||||
expect(stripUtf8Bom("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,3 +34,22 @@ export function maskSecret(value: string): string {
|
||||
}
|
||||
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading UTF-8 byte order mark (BOM, U+FEFF) from decoded text.
|
||||
*
|
||||
* Text editors on Windows (e.g. Notepad's "UTF-8" encoding option) prepend this mark to
|
||||
* signal the encoding, but `fs.readFileSync(path, "utf8")` does not strip it, so it survives
|
||||
* into the decoded string as a leading `\uFEFF` character. Frontmatter parsers anchor on
|
||||
* `^---` and silently fail to match when that character is present, hiding the file's
|
||||
* name/description (see cline/cline#12151).
|
||||
*
|
||||
* We only need to check for this one mark: a BOM disambiguates byte order for multi-byte
|
||||
* code units (UTF-16, UTF-32), but UTF-8 is a byte-oriented encoding with no byte-order
|
||||
* ambiguity to resolve, so it has exactly one BOM encoding (`EF BB BF`, i.e. U+FEFF) rather
|
||||
* than a family of them. Every caller of this helper already reads its input as `utf8`, so a
|
||||
* file actually encoded as UTF-16/32 would be mis-decoded well before reaching here.
|
||||
*/
|
||||
export function stripUtf8Bom(text: string): string {
|
||||
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user