diff --git a/.gitignore b/.gitignore index ad23d07463..5da1f76a39 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,6 @@ .idea/ .vercel -# Exported user lists (PII) - scripts/export-user-emails.ts -user-emails-*.csv - # Environment files (secrets) - Bun loads .env.* files natively .env .env.* @@ -28,9 +25,6 @@ npm-app/src/__tests__/data/ debug/ .context/ docs/bot-detection.md -# Issue-specific plans/specs belong in Linear, not the repository. Existing -# tracked historical specs remain tracked; this keeps new local artifacts out. -docs/specs/ .codex # Nx cache directories @@ -76,9 +70,3 @@ scripts/_tmp-* # Local operator launch manifests contain real advertiser identifiers. The # checked-in example is intentionally non-routable. ads-pilot-launch.local.json - -# Linked git worktrees created by agent tooling. Each is a full checkout with -# its own .git file, so committing one nests a second working tree inside this -# one — and because a worktree carries its own generated migrations, it also -# drags a DUPLICATE migration index into whichever branch swept it up. -.claude/worktrees/ diff --git a/README.md b/README.md index 2f8dcb4540..a209fa009f 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ Freebuff includes a curated model catalog. The regular picker currently offers: | Model | Access | Best for | | --------------------------- | ----------------------- | ----------------------------------------------------------------- | -| **GLM 5.3 Flash** | Full access | The default in full mode; deepest reasoning, unmetered | -| **GPT-5.6 Luna** | Full access | Strong all-around with native images | +| **GPT-5.6 Luna** | Full access | The default in full mode; strong all-around with native images | | **DeepSeek V4 Flash 07/31** | Full access | Fast coding and tool use; pauses during peak hours | | **MiMo 2.5** | Full and limited access | The limited-mode default; balanced performance with image support | -| **Solar Pro 4** | Full access | Limited-time trial; 524K context, text only | +| **Ox Alpha** | Full and limited access | 1M context, no session cost; experimental — may be unreliable | +| **GLM 5.3 Flash** | Full access | Deepest reasoning; 2 sessions a day | -Most models draw on your normal daily sessions rather than a separate limit. GLM 5.3 Flash and MiMo 2.5 are unmetered and cost no session at all. Models may still serve from a quantized (Q8_0) build. +Most models draw on your normal daily sessions rather than a separate limit. GLM 5.3 Flash is the exception, capped at 2 sessions a day while we measure what it costs at scale. MiMo 2.5 and Ox Alpha stay unmetered and cost no session at all. Models may still serve from a quantized (Q8_0) build. DeepSeek V4 Pro was retired from the catalog; GLM 5.3 Flash replaces it as the deep-reasoning pick. @@ -63,7 +63,7 @@ Freebuff uses specialized agents instead of sending every task through one model ## Free access -Freebuff is available in every country. Supported regions receive full access; other regions and VPN users receive limited access, currently MiMo 2.5 with three one-hour sessions per day, earnable up to seven. +Freebuff is available in every country. Supported regions receive full access; other regions and VPN users receive limited access, currently MiMo 2.5 and Ox Alpha with three one-hour sessions per day, earnable up to seven. Text ads support the included models. Freebuff shows the applicable session limits and any model-specific data-use notice before you start. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8965932aec..cc57649473 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,13 +34,13 @@ Freebuff 提供经过筛选的模型目录。常规模型选择器目前包括 | 模型 | 访问范围 | 适用场景 | | --------------------------- | -------------- | ------------------------------------------------ | -| **GLM 5.3 Flash** | 完整访问 | 完整模式下的默认模型;推理最深入,且不消耗会话 | -| **GPT-5.6 Luna** | 完整访问 | 综合能力强,原生支持图像 | +| **GPT-5.6 Luna** | 完整访问 | 完整模式下的默认模型;综合能力强,原生支持图像 | | **DeepSeek V4 Flash 07/31** | 完整访问 | 快速编程和工具调用;高峰时段暂停 | | **MiMo 2.5** | 完整和受限访问 | 受限模式的默认模型;均衡性能并支持图像 | -| **Solar Pro 4** | 完整访问 | 限时试用;52.4 万上下文,仅支持文本 | +| **Ox Alpha** | 完整和受限访问 | 100 万上下文,不消耗会话;实验性功能,可能不稳定 | +| **GLM 5.3 Flash** | 完整访问 | 推理最深入;每天 2 次会话 | -大多数模型使用你的常规每日会话,而不再各自设限。GLM 5.3 Flash 和 MiMo 2.5 保持无限使用,完全不消耗会话。模型仍可能由量化(Q8_0)版本提供服务。 +大多数模型使用你的常规每日会话,而不再各自设限。GLM 5.3 Flash 是例外:在我们评估其规模化成本期间,每天限用 2 次会话。MiMo 2.5 和 Ox Alpha 保持无限使用,完全不消耗会话。模型仍可能由量化(Q8_0)版本提供服务。 DeepSeek V4 Pro 已从模型目录中下线,由 GLM 5.3 Flash 接替其深度推理的位置。 @@ -63,7 +63,7 @@ Freebuff 使用专业化智能体,而不是把所有任务都交给同一个 ## 免费访问 -Freebuff 在所有国家和地区均可使用。受支持的地区提供完整访问;其他地区以及使用 VPN 的用户获得受限访问,目前包括 MiMo 2.5,每天可使用六个一小时会话。 +Freebuff 在所有国家和地区均可使用。受支持的地区提供完整访问;其他地区以及使用 VPN 的用户获得受限访问,目前包括 MiMo 2.5 和 Ox Alpha,每天可使用六个一小时会话。 内置模型由文字广告支持。开始前,Freebuff 会显示适用的会话限制以及模型特定的数据使用提示。 diff --git a/agents/__tests__/base3.test.ts b/agents/__tests__/base3.test.ts index f4870bcce1..cfbd9acd6e 100644 --- a/agents/__tests__/base3.test.ts +++ b/agents/__tests__/base3.test.ts @@ -17,7 +17,6 @@ import base3FreeLuna from '../base3-free-luna' import base3FreeMimo from '../base3-free-mimo' import base3FreeMinimaxM3 from '../base3-free-minimax-m3' import base3FreeOxAlpha from '../base3-free-ox-alpha' -import base3FreeSolarPro4 from '../base3-free-solar-pro4' import base3Lite from '../base3-lite' /** @@ -51,14 +50,13 @@ const CLI_ROOTS = [ base3FreeLuna, base3FreeFable, base3FreeOxAlpha, - base3FreeSolarPro4, ] describe('base3 CLI roots', () => { test('keeps the efficiency flags the runtime reads', () => { - // 14 since Solar Pro 4 reached the CLI. The count is asserted so a root - // added without the flags below cannot slip in unnoticed. - expect(CLI_ROOTS.length).toBe(14) + // 13 since GLM 5.3 Flash shipped on 2026-08-26. The count is asserted so a + // root added without the flags below cannot slip in unnoticed. + expect(CLI_ROOTS.length).toBe(13) for (const agent of CLI_ROOTS) { // Windowed reads + the 100-entry glob cap + search-first tool wording. expect(agent.windowedFileReads).toBe(true) diff --git a/agents/base-chat.ts b/agents/base-chat.ts index 0ce94245ac..b90f1c1d62 100644 --- a/agents/base-chat.ts +++ b/agents/base-chat.ts @@ -68,8 +68,6 @@ End every response by calling the suggest_followups tool with exactly 3 followup 'stealth/ox-alpha': 1_000_000, // GLM 5.3 Flash: 1,310,720 published, entered low for the same reason. 'z-ai/glm-5.3-flash': 1_000_000, - // Solar Pro 4: 524,288 published, entered low for the same reason. - 'upstage/solar-pro4': 500_000, } /** For any model not listed above. Assuming a window is smaller than it is diff --git a/agents/base2/base2-free-solar-pro4.ts b/agents/base2/base2-free-solar-pro4.ts deleted file mode 100644 index 14ebc965ab..0000000000 --- a/agents/base2/base2-free-solar-pro4.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FREEBUFF_SOLAR_PRO_4_MODEL_ID } from '@codebuff/common/constants/freebuff-models' - -import { createBase2 } from './base2' - -const definition = { - ...createBase2('free', { - model: FREEBUFF_SOLAR_PRO_4_MODEL_ID, - }), - id: 'base2-free-solar-pro4', - displayName: 'Buffy the Solar Pro 4 Free Orchestrator', -} - -export default definition diff --git a/agents/base3-free-solar-pro4.ts b/agents/base3-free-solar-pro4.ts deleted file mode 100644 index 07ecf58c18..0000000000 --- a/agents/base3-free-solar-pro4.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FREEBUFF_SOLAR_PRO_4_MODEL_ID } from '@codebuff/common/constants/freebuff-models' - -import { createBase3CliRoot } from './base3' - -const definition = { - ...createBase3CliRoot({ - model: FREEBUFF_SOLAR_PRO_4_MODEL_ID, - isFreebuff: true, - }), - id: 'base3-free-solar-pro4', - displayName: 'Buffy on Solar Pro 4', -} - -export default definition diff --git a/agents/reviewer/code-reviewer-solar-pro4.ts b/agents/reviewer/code-reviewer-solar-pro4.ts deleted file mode 100644 index 9af85f33d1..0000000000 --- a/agents/reviewer/code-reviewer-solar-pro4.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FREEBUFF_SOLAR_PRO_4_MODEL_ID } from '@codebuff/common/constants/freebuff-models' - -import { publisher } from '../constants' -import type { SecretAgentDefinition } from '../types/secret-agent-definition' -import { createReviewer } from './code-reviewer' - -const definition: SecretAgentDefinition = { - id: 'code-reviewer-solar-pro4', - publisher, - ...createReviewer(FREEBUFF_SOLAR_PRO_4_MODEL_ID), -} - -export default definition diff --git a/bun.lock b/bun.lock index 990ccad9e7..ea8f5ce38b 100644 --- a/bun.lock +++ b/bun.lock @@ -200,11 +200,11 @@ "packages": { "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.70", "", { "dependencies": { "@ai-sdk/provider": "4.0.9", "@ai-sdk/provider-utils": "5.0.34", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0tzAH2vwXOs/kVktAZRS04dATEQJk1hf1QR+VuVfvo9QmW3UPgcjhhJD9QFgP8HZLxkrEGDImwLIQ7sUfQTIsA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.67", "", { "dependencies": { "@ai-sdk/provider": "4.0.8", "@ai-sdk/provider-utils": "5.0.32", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LtyxLkg7dZ2iz8Ouh1806BJbA+q+FKc/mXUCl4v/wdNNIGtbfk80dNtlhqjhqOZa4dnfc3caVafRL7ocxzoegA=="], "@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2eSw90hn32Je6n2a8Gf4dJ2EoecPJuOCWqwZCw+BkhPq2LOS01HX3s6ljgOm0iIkZiD5aAuMdpOw17rYKQF/Zg=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/5z8tRGuYXwFy0ID+WtiWiECJzH5x/rI/g/3H8x3GQvE4i4etnZfKWAiU23VZEymInh+4l7uMNQJyaNN/54QFw=="], "@auth/core": ["@auth/core@0.41.3", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7 || ^8.0.5" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw=="], @@ -384,29 +384,29 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], - "@next/env": ["@next/env@16.3.4", "", {}, "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q=="], + "@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -438,9 +438,9 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@posthog/core": ["@posthog/core@1.49.2", "", { "dependencies": { "@posthog/types": "^1.407.1" } }, "sha512-AXHDo/4nisUg7OPG1TQNgREK7n+chBQXLQyttp7bDTDsDg2k9lV06TmnpvuNbwJs53q9mP9hjezKEZu4fYadfg=="], + "@posthog/core": ["@posthog/core@1.48.11", "", { "dependencies": { "@posthog/types": "^1.405.3" } }, "sha512-fvKbxGaUM8RuCDB1jdhSqAHYjk42INfJDWT1KVezn74sCrfnr1YaUafxzmcS+87D5FzbA2tu7IT0GQRV2WkkRg=="], - "@posthog/types": ["@posthog/types@1.407.1", "", {}, "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg=="], + "@posthog/types": ["@posthog/types@1.407.0", "", {}, "sha512-7J/aFVi7JWFt/ekGVsMFvkTcADoE9MTNf1N9cpBSMUQ/SPLiBjbg386Fzk5OlgWG/u5OemBy4wlgD7YebqeppQ=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], @@ -448,9 +448,9 @@ "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.102.6", "", {}, "sha512-jp+GucyQ+fel2ILb8ZLQeqMaAqZL/Bzaj+dKOQiPk4vPdf2rQPEV7heUyEVIhatpY42j4AFHav4DzBa1oTIp/A=="], - "@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="], + "@tanstack/react-query": ["@tanstack/react-query@5.102.6", "", { "dependencies": { "@tanstack/query-core": "5.102.6" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-ANz4KZ8z80D85fiz5IAHjpb8XBPLrjOb/0natlD9Ascyy/3p96V86Zw8UbOfA0HDLcvK/A4gNd95r723y5UT2w=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], @@ -550,7 +550,7 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ai": ["ai@7.0.86", "", { "dependencies": { "@ai-sdk/gateway": "4.0.70", "@ai-sdk/provider": "4.0.9", "@ai-sdk/provider-utils": "5.0.34" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-11Hovs3BI98tPJiOuA85Be+ktxbZ2QUIqqLJqfHJ55zz4106pjRkEP9OQ95glyjBXPEtTdr6/z4ISsk6G13rvw=="], + "ai": ["ai@7.0.83", "", { "dependencies": { "@ai-sdk/gateway": "4.0.67", "@ai-sdk/provider": "4.0.8", "@ai-sdk/provider-utils": "5.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bg7+SopUwqA7DeQ2O8I9qELyQTHCeeI/0RuNUlT/gGz+LqWrIl5vbYRQv3eMBEnUsVFaM33n6SA8Vqe9gi8L1w=="], "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=="], @@ -606,7 +606,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -808,7 +808,7 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + "express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -826,7 +826,7 @@ "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="], - "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], @@ -942,7 +942,7 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1186,11 +1186,11 @@ "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], - "next": ["next@16.3.4", "", { "dependencies": { "@next/env": "16.3.4", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.4", "@next/swc-darwin-x64": "16.3.4", "@next/swc-linux-arm64-gnu": "16.3.4", "@next/swc-linux-arm64-musl": "16.3.4", "@next/swc-linux-x64-gnu": "16.3.4", "@next/swc-linux-x64-musl": "16.3.4", "@next/swc-win32-arm64-msvc": "16.3.4", "@next/swc-win32-x64-msvc": "16.3.4", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA=="], + "next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": "16.3.3", "@next/swc-win32-x64-msvc": "16.3.3", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g=="], "next-auth": ["next-auth@4.24.15", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^11.1.1" }, "peerDependencies": { "@auth/core": "0.34.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-NnjYtjrSOAx/TIVFGTX4IfI/9yHnNpi4B7FuLUwuV20v2Zxgr2OGP/YN0ynJuI7y8QOnTBPitfOdEXZrVvhIuA=="], - "node-abi": ["node-abi@3.96.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg=="], + "node-abi": ["node-abi@3.95.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-T9iGctuocf0qIWFFOTxPzjT5q0SILqaBYXt272tlBHvTKC5+3JnkMirLxNJNkXHtFyBjU2Jx+NL4Zipr0B/c6Q=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], @@ -1324,7 +1324,7 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "posthog-node": ["posthog-node@5.51.4", "", { "dependencies": { "@posthog/core": "^1.49.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-gI6JMBnU3vjDNclUBWonw3y7k8Y0UPIAVO4AQ2zu9eyW+7sY8UQRofx4JIA7IGYLMEbs4gykfogOmXp16+oUdg=="], + "posthog-node": ["posthog-node@5.51.2", "", { "dependencies": { "@posthog/core": "^1.48.11" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-K+aQnqyHEtx2O5YmjxByh3WnXXMsrAisW0hNSXwejM4v7cngrbqoA6rZevMSyaXNqx9xhBedTxz0orb1Xwxyew=="], "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], @@ -1352,7 +1352,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "queue-lit": ["queue-lit@1.5.2", "", {}, "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw=="], @@ -1514,7 +1514,7 @@ "supports-terminal-graphics": ["supports-terminal-graphics@0.1.0", "", {}, "sha512-+KdfozhS0Fw8y5Sghw8kkZNGT8nWYzJ1EzcoIvVjxhl+26TJTs26y02yfBgvc1jh5AS/c8jcI3xtahhR95KRyQ=="], - "systeminformation": ["systeminformation@5.33.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-hMOQG/eRUzuopuYGGdl8ntkau0nEC7fOaRoTUg1RSr2GTQIk2VNa76DA0+ApajkGfzmcgAupgIP/vt+jtoe5EA=="], + "systeminformation": ["systeminformation@5.33.4", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-poXpX8M9NBll1NDMW1ho6qRFkjrlttfm8E/YtPmn5x0TQ1z8LoJUHg9kwr/sRDmCjktqfnFRxqsoUNpaRakeMQ=="], "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], @@ -1542,7 +1542,7 @@ "ts-pattern": ["ts-pattern@5.9.0", "", {}, "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg=="], - "tsc-alias": ["tsc-alias@1.9.3", "", { "dependencies": { "chokidar": "^3.5.3", "commander": "^9.0.0", "get-tsconfig": "^4.10.0", "globby": "^11.0.4", "mylas": "^2.1.9", "normalize-path": "^3.0.0", "plimit-lit": "^1.2.6" }, "bin": { "tsc-alias": "dist/bin/index.js" } }, "sha512-GKrkA/K5hwae80rlfJRazukMMMIUsIHRyb75lbEp+qaUP57sYmur2Z05dosZNBspByX3ZrxbLHkMRgLfVuUcYg=="], + "tsc-alias": ["tsc-alias@1.9.2", "", { "dependencies": { "chokidar": "^3.5.3", "commander": "^9.0.0", "get-tsconfig": "^4.10.0", "globby": "^11.0.4", "mylas": "^2.1.9", "normalize-path": "^3.0.0", "plimit-lit": "^1.2.6" }, "bin": { "tsc-alias": "dist/bin/index.js" } }, "sha512-VTWQGMv0xXCEyHDLpmV2DEvGYHMxwsyx87dZeou2ynkM0+WOFdHe+KWiRucavMPUEdQysr7xSu60Y/WY0R4YKA=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], @@ -1648,7 +1648,7 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-from-json-schema": ["zod-from-json-schema@0.4.2", "", { "dependencies": { "zod": "^3.25.25" } }, "sha512-U+SIzUUT7P6w1UNAz81Sj0Vko77eQPkZ8LbJeXqQbwLmq1MZlrjB3Gj4LuebqJW25/CzS9WA8SjTgR5lvuv+zA=="], @@ -1662,9 +1662,9 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="], - "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@4.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XnGXPWiBIfqjsVEud5pOaVneRByJQOu2sYNwlSVJTPCvakdCDkVuYKKfNuStkIpMUYl7JIkBZGBx+B5YfNeVjA=="], + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@4.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-aWO7iwhFUGf347tCwNGggggfmZigaSu7TF739IZSrWWABUp7zkb4Cr3fMqvBe5EIS7ABJJu3Cadn0g/zs1G0QQ=="], - "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.34", "", { "dependencies": { "@ai-sdk/provider": "4.0.9", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tRBdgRcys/4d8wyQdOdyYScq1AxfMdMd0hIlwolxJKVIbBwXUgClZuQT0VIsz4e7pylY8FE6utYCCZ494UAMJQ=="], + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.32", "", { "dependencies": { "@ai-sdk/provider": "4.0.8", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MZUhlINn6FzKIWuX3T36h+yM9d7bG+yatH+kC99ZCe0DHxXfP73KwaoLiLcZDPQDamFyO3umPPBLJieZJyG4DQ=="], "@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], @@ -1688,8 +1688,6 @@ "@opentui/react/react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "@types/diff/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@7.18.0", "", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0" } }, "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA=="], @@ -1704,9 +1702,9 @@ "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XnGXPWiBIfqjsVEud5pOaVneRByJQOu2sYNwlSVJTPCvakdCDkVuYKKfNuStkIpMUYl7JIkBZGBx+B5YfNeVjA=="], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-aWO7iwhFUGf347tCwNGggggfmZigaSu7TF739IZSrWWABUp7zkb4Cr3fMqvBe5EIS7ABJJu3Cadn0g/zs1G0QQ=="], - "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.34", "", { "dependencies": { "@ai-sdk/provider": "4.0.9", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tRBdgRcys/4d8wyQdOdyYScq1AxfMdMd0hIlwolxJKVIbBwXUgClZuQT0VIsz4e7pylY8FE6utYCCZ494UAMJQ=="], + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.32", "", { "dependencies": { "@ai-sdk/provider": "4.0.8", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MZUhlINn6FzKIWuX3T36h+yM9d7bG+yatH+kC99ZCe0DHxXfP73KwaoLiLcZDPQDamFyO3umPPBLJieZJyG4DQ=="], "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], diff --git a/cli/README.md b/cli/README.md index 45d8af675a..fb66abf25d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -77,6 +77,14 @@ Or use the binary directly: codebuff-tui ``` +## Prompt History + +Submitted prompts are saved in `message-history.json` under the current +project's config directory and can be recalled with the up/down arrow keys. +History is stored as plaintext and capped at 500 entries by default. Set +`FREEBUFF_HISTORY_SIZE=0` to disable prompt history persistence, or set +`FREEBUFF_HISTORY_SCOPE=global` to use one shared history file across projects. + ## Features - Built with OpenTUI for modern terminal interfaces diff --git a/cli/release/package.json b/cli/release/package.json index 2218ac8870..6182990a49 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.686", + "version": "1.0.685", "description": "AI coding agent", "license": "MIT", "bin": { diff --git a/cli/src/__tests__/unit/freebuff-reasoning.test.ts b/cli/src/__tests__/unit/freebuff-reasoning.test.ts deleted file mode 100644 index 193fb924ae..0000000000 --- a/cli/src/__tests__/unit/freebuff-reasoning.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, test, beforeEach, afterAll } from 'bun:test' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { - FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, - FREEBUFF_GLM_V53_FLASH_MODEL_ID, - FREEBUFF_MIMO_V25_MODEL_ID, - getFreebuffModelDefaultEffort, - getFreebuffModelEfforts, -} from '@codebuff/common/constants/freebuff-models' - -import type { ReasoningEffort } from '@codebuff/common/constants/reasoning-effort' - -/** - * `/reasoning` is the CLI's counterpart to Desktop's effort picker. Both end up - * writing the same `freebuff_reasoning_effort` metadata key, and the server - * treats it as a REQUEST it re-clamps — so the client's job is only to send a - * rung the selected model actually offers, and to send NOTHING when the user - * has expressed no preference. - * - * That last part is the one a type-check cannot catch: sending the model - * default explicitly type-checks, reads correctly, and silently overrides an - * agent's own declared reasoning while looking like a user decision. - * - * Settings are redirected by HOME rather than by mocking `../utils/settings`. - * `mock.module` is process-global in bun and is NOT scoped to the file that - * calls it: a settings mock here reached the freebuff-model-selector suite that - * runs later in the same process and failed 18 of its tests. A temp HOME also - * exercises the real load/save round trip, which is where the catalog - * validation lives. - */ -const realHome = process.env.HOME -const tempHome = mkdtempSync(join(tmpdir(), 'freebuff-reasoning-')) -process.env.HOME = tempHome -afterAll(() => { - if (realHome === undefined) delete process.env.HOME - else process.env.HOME = realHome - rmSync(tempHome, { recursive: true, force: true }) -}) - -const { handleReasoningCommand } = await import('../../commands/reasoning') -const { - getFreebuffReasoningEffortForModel, - getEffectiveFreebuffReasoningEffort, - getSelectedFreebuffReasoningEffort, - useFreebuffModelStore, -} = await import('../../state/freebuff-model-store') -const { loadFreebuffReasoningEfforts } = await import('../../utils/settings') - -const LADDERED = FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID -const NO_LADDER = FREEBUFF_MIMO_V25_MODEL_ID - -describe('/reasoning', () => { - beforeEach(() => { - useFreebuffModelStore.setState({ - selectedModel: LADDERED, - reasoningEffortByModel: {}, - }) - useFreebuffModelStore.getState().setReasoningEffort(LADDERED, undefined) - useFreebuffModelStore.getState().setReasoningEffort(NO_LADDER, undefined) - }) - - test('the catalog still gives the model under test a ladder', () => { - // Guards the rest of the file: if V4 Flash ever loses its `efforts`, every - // assertion below would pass vacuously against the no-ladder branch. - expect(getFreebuffModelEfforts(LADDERED)).toBeTruthy() - expect(getFreebuffModelEfforts(NO_LADDER)).toBeNull() - }) - - test('with no argument it reports the model default and does not set one', () => { - const { message } = handleReasoningCommand('') - expect(message).toContain(getFreebuffModelDefaultEffort(LADDERED)!) - expect(message).toContain('model default') - // The read path must stay a read: nothing sent until the user picks. - expect(getSelectedFreebuffReasoningEffort()).toBeNull() - }) - - test('a valid rung is set, sent, and survives a reload', () => { - handleReasoningCommand('max') - expect(getSelectedFreebuffReasoningEffort()).toBe('max') - expect(loadFreebuffReasoningEfforts()[LADDERED]).toBe('max') - }) - - test('an invalid rung changes nothing and names the ladder', () => { - handleReasoningCommand('max') - const { message } = handleReasoningCommand('gigantic') - // DeepSeek accepts any string for reasoning_effort and silently ignores - // what it does not recognize, so a bad word must never reach the wire. - expect(getSelectedFreebuffReasoningEffort()).toBe('max') - expect(message).toContain('low') - }) - - test('a rung the model does not offer is refused even though it is a real effort', () => { - // `xhigh` is on the shared ladder but not on DeepSeek V4's. - expect(getFreebuffModelEfforts(LADDERED)).not.toContain('xhigh') - handleReasoningCommand('xhigh') - expect(getSelectedFreebuffReasoningEffort()).toBeNull() - }) - - test('default/reset clears the override rather than storing the default', () => { - handleReasoningCommand('low') - handleReasoningCommand('default') - // Absent, not "low" and not the default value: absence is how the client - // says "no preference", and it is what lets the server apply the catalog - // default without treating the turn as a user choice. - expect(loadFreebuffReasoningEfforts()[LADDERED]).toBeUndefined() - expect(getSelectedFreebuffReasoningEffort()).toBeNull() - // The row still displays what it will run at. - expect(getEffectiveFreebuffReasoningEffort(LADDERED)).toBe( - getFreebuffModelDefaultEffort(LADDERED), - ) - }) - - test('a model with no ladder is told so and nothing is stored', () => { - useFreebuffModelStore.setState({ selectedModel: NO_LADDER }) - const { message } = handleReasoningCommand('high') - expect(message).toContain('no reasoning levels') - expect(loadFreebuffReasoningEfforts()[NO_LADDER]).toBeUndefined() - }) - - test('overrides are per model, so switching model does not carry a rung across', () => { - handleReasoningCommand('max') - useFreebuffModelStore.setState({ selectedModel: NO_LADDER }) - expect(getSelectedFreebuffReasoningEffort()).toBeNull() - useFreebuffModelStore.setState({ selectedModel: LADDERED }) - expect(getSelectedFreebuffReasoningEffort()).toBe('max') - }) - - test('GLM 5.3 Flash is pickable here, at its own ladder and default', () => { - // The row shipped with no ladder at all and the CLI answered "no reasoning - // levels to adjust" for it. Asserted on the CONCRETE model rather than - // through the generic laddered path because the regression to guard is the - // catalog row losing `efforts` again, which the LADDERED constant above - // would not notice. - useFreebuffModelStore.setState({ selectedModel: FREEBUFF_GLM_V53_FLASH_MODEL_ID }) - expect(getFreebuffModelEfforts(FREEBUFF_GLM_V53_FLASH_MODEL_ID)).toEqual([ - 'low', - 'high', - 'max', - ]) - - const before = handleReasoningCommand('') - expect(before.message).toContain('max (model default)') - expect(before.message).toContain('low, high, max') - // Nothing sent until the user actually picks — the model default is the - // server's job, and sending it would look like a decision. - expect(getSelectedFreebuffReasoningEffort()).toBeNull() - - handleReasoningCommand('low') - expect(getSelectedFreebuffReasoningEffort()).toBe('low') - expect( - loadFreebuffReasoningEfforts()[FREEBUFF_GLM_V53_FLASH_MODEL_ID], - ).toBe('low') - - // `xhigh` is on the shared ladder but not this model's, so the CLI refuses - // it locally rather than letting the server clamp it to something the user - // did not choose. - const refused = handleReasoningCommand('xhigh') - expect(refused.message).toContain('is not a reasoning level') - expect(getSelectedFreebuffReasoningEffort()).toBe('low') - }) - - test('a stored rung the model no longer offers is ignored, not clamped', () => { - // Simulates a catalog change landing under a settings file written by an - // older client. Sending it would have the server clamp DOWN to a rung the - // user never picked; sending nothing lands on the model's own default. - useFreebuffModelStore.setState({ - reasoningEffortByModel: { [LADDERED]: 'xhigh' as ReasoningEffort }, - }) - expect(getFreebuffReasoningEffortForModel(LADDERED)).toBeNull() - }) -}) - -/** - * The send path, asserted by reading the source for the same reason the runner's - * effortForwarding test does: an absent metadata field IS how "use the default" - * is expressed, so a dropped value is invisible to every other test. - */ -describe('the CLI turn carries the chosen effort', () => { - const source = readFileSync( - join(import.meta.dir, '..', '..', 'hooks', 'use-send-message.ts'), - 'utf8', - ) - - test('it reaches extraCodebuffMetadata under the name the server reads', () => { - const metadata = source.slice(source.indexOf('extraCodebuffMetadata:')) - expect(metadata).toContain('freebuff_reasoning_effort') - expect(metadata).toContain('freebuffReasoningEffort') - }) -}) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 8a2770b575..2f40cae618 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -425,7 +425,11 @@ export const Chat = ({ }, [cursorPosition, inputValue, setInputValue]) const { saveToHistory, navigateUp, navigateDown, resetHistoryNavigation } = - useInputHistory(inputValue, setInputValue, { inputMode, setInputMode }) + useInputHistory(inputValue, setInputValue, { + inputMode, + setInputMode, + projectRoot: getProjectRoot(), + }) // Use extracted streaming hook for connection, timer, queue, and exit handling const { @@ -535,7 +539,6 @@ export const Chat = ({ logoutMutation, streamMessageIdRef, addToQueue, - hasQueuedMessages: () => queuedCount > 0, clearMessages, saveToHistory, scrollToLatest, @@ -799,7 +802,8 @@ export const Chat = ({ // The panel closes itself once the queue drains, so opening an empty // one would just flash. Say so instead. if (queuedCount > 0) useQueuePanelStore.getState().openQueuePanel() - else setMessages((prev) => [...prev, getSystemMessage('Nothing queued.')]) + else + setMessages((prev) => [...prev, getSystemMessage('Nothing queued.')]) } }, [ diff --git a/cli/src/commands/__tests__/router-steering.test.ts b/cli/src/commands/__tests__/router-steering.test.ts deleted file mode 100644 index cb15dcf92b..0000000000 --- a/cli/src/commands/__tests__/router-steering.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' - -import { useChatStore } from '../../state/chat-store' -import { - __resetSteeringForTests, - activateSteering, - drainSteeringMessages, -} from '../../utils/steering-buffer' -import { routeUserPrompt } from '../router' - -import type { RouterParams } from '../command-registry' - -const createMockParams = (overrides: Partial = {}): RouterParams => - ({ - agentMode: 'DEFAULT', - inputRef: { current: null }, - inputValue: '', - isChainInProgressRef: { current: false }, - isStreaming: false, - logoutMutation: {} as RouterParams['logoutMutation'], - streamMessageIdRef: { current: null }, - addToQueue: mock(() => {}), - hasQueuedMessages: () => false, - clearMessages: mock(() => {}), - saveToHistory: mock(() => {}), - scrollToLatest: mock(() => {}), - sendMessage: mock(async () => {}), - setCanProcessQueue: mock(() => {}), - setInputFocused: mock(() => {}), - setInputValue: mock(() => {}), - setIsAuthenticated: mock(() => {}), - setMessages: mock(() => {}), - setUser: mock(() => {}), - ...overrides, - }) as RouterParams - -beforeEach(() => { - useChatStore.getState().clearPendingBashMessages() -}) - -afterEach(() => { - __resetSteeringForTests() - useChatStore.getState().clearPendingBashMessages() -}) - -describe('mid-turn routing', () => { - test('plain text steers the active run and echoes a bubble immediately', async () => { - activateSteering('run-1') - const params = createMockParams({ - inputValue: 'actually use zod for validation', - isStreaming: true, - }) - await routeUserPrompt(params) - - expect(params.addToQueue).not.toHaveBeenCalled() - expect(params.sendMessage).not.toHaveBeenCalled() - // Bubble echoed at push time so the submit is visible right away. - expect(params.setMessages).toHaveBeenCalledTimes(1) - const drained = drainSteeringMessages('run-1') - expect(drained.map((entry) => entry.text)).toEqual([ - 'actually use zod for validation', - ]) - expect(drained[0]!.messageId).toStartWith('user-') - }) - - test('falls back to the queue when no run is accepting steering', async () => { - const params = createMockParams({ - inputValue: 'between chained runs', - isStreaming: true, - }) - await routeUserPrompt(params) - - expect(params.addToQueue).toHaveBeenCalledTimes(1) - const [queued] = (params.addToQueue as ReturnType).mock - .calls[0] as [string] - expect(queued).toBe('between chained runs') - }) - - test('queues instead of steering when earlier messages are already queued', async () => { - activateSteering('run-1') - const params = createMockParams({ - inputValue: 'this must not overtake the queue', - isStreaming: true, - hasQueuedMessages: () => true, - }) - await routeUserPrompt(params) - - expect(drainSteeringMessages('run-1')).toEqual([]) - expect(params.addToQueue).toHaveBeenCalledTimes(1) - }) - - test('queues instead of steering while bash output is pending', async () => { - activateSteering('run-1') - useChatStore.getState().addPendingBashMessage({ - command: 'bun test', - output: '3 fail', - } as never) - const params = createMockParams({ - inputValue: 'fix those failures', - isStreaming: true, - }) - await routeUserPrompt(params) - - expect(drainSteeringMessages('run-1')).toEqual([]) - expect(params.addToQueue).toHaveBeenCalledTimes(1) - }) - - test('slash commands never steer', async () => { - activateSteering('run-1') - const params = createMockParams({ - inputValue: '/definitely-not-a-command', - isStreaming: true, - }) - await routeUserPrompt(params) - - expect(drainSteeringMessages('run-1')).toEqual([]) - expect(params.addToQueue).toHaveBeenCalledTimes(1) - }) - - test('idle submits are unaffected and send normally', async () => { - activateSteering('run-1') - const params = createMockParams({ inputValue: 'a fresh task' }) - await routeUserPrompt(params) - - expect(params.sendMessage).toHaveBeenCalledTimes(1) - expect(drainSteeringMessages('run-1')).toEqual([]) - }) -}) diff --git a/cli/src/commands/__tests__/skill-command.test.ts b/cli/src/commands/__tests__/skill-command.test.ts deleted file mode 100644 index 9ff609622a..0000000000 --- a/cli/src/commands/__tests__/skill-command.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' - -import { useChatStore } from '../../state/chat-store' -import { - __resetSkillRegistryForTests, - __setSkillsForTests, -} from '../../utils/skill-registry' -import { findCommand } from '../command-registry' -import { buildSkillPrompt } from '../prompt-builders' -import { routeUserPrompt } from '../router' - -import type { RouterParams } from '../command-registry' -import type { SkillDefinition } from '@codebuff/common/types/skill' - -const TEST_SKILL: SkillDefinition = { - name: 'release-notes', - description: 'Draft release notes from recent commits', - content: - '---\nname: release-notes\ndescription: Draft release notes\n---\n\nDo the thing.', - filePath: '/tmp/skills/release-notes/SKILL.md', -} - -const createMockParams = (overrides: Partial = {}): RouterParams => - ({ - agentMode: 'DEFAULT', - inputRef: { current: null }, - inputValue: '', - isChainInProgressRef: { current: false }, - isStreaming: false, - logoutMutation: {} as RouterParams['logoutMutation'], - streamMessageIdRef: { current: null }, - addToQueue: mock(() => {}), - clearMessages: mock(() => {}), - saveToHistory: mock(() => {}), - scrollToLatest: mock(() => {}), - sendMessage: mock(async () => {}), - setCanProcessQueue: mock(() => {}), - setInputFocused: mock(() => {}), - setInputValue: mock(() => {}), - setIsAuthenticated: mock(() => {}), - setMessages: mock(() => {}), - setUser: mock(() => {}), - ...overrides, - }) as RouterParams - -const resetChatStore = () => { - useChatStore.getState().setInputMode('default') - useChatStore.getState().setPendingSkillName(null) -} - -beforeEach(() => { - __setSkillsForTests({ [TEST_SKILL.name]: TEST_SKILL }) - resetChatStore() -}) - -afterEach(() => { - __resetSkillRegistryForTests() - resetChatStore() -}) - -describe('/skill: command', () => { - test('bare invocation enters skill input mode instead of sending', async () => { - const command = findCommand('skill:release-notes') - expect(command).toBeDefined() - - const params = createMockParams({ inputValue: '/skill:release-notes' }) - await command!.handler(params, '') - - expect(useChatStore.getState().inputMode).toBe('skill') - expect(useChatStore.getState().pendingSkillName).toBe('release-notes') - expect(params.sendMessage).not.toHaveBeenCalled() - expect(params.addToQueue).not.toHaveBeenCalled() - }) - - test('invocation with trailing text sends immediately', async () => { - const command = findCommand('skill:release-notes') - const params = createMockParams({ - inputValue: '/skill:release-notes for v2.1 only', - }) - await command!.handler(params, 'for v2.1 only') - - expect(useChatStore.getState().inputMode).toBe('default') - expect(params.sendMessage).toHaveBeenCalledTimes(1) - const [{ content }] = (params.sendMessage as ReturnType).mock - .calls[0] as [{ content: string }] - expect(content).toBe(buildSkillPrompt(TEST_SKILL, 'for v2.1 only')) - expect(content).toContain('') - expect(content).toContain('User request: for v2.1 only') - }) -}) - -describe('skill input mode submit', () => { - const enterSkillMode = () => { - useChatStore.getState().setInputMode('skill') - useChatStore.getState().setPendingSkillName(TEST_SKILL.name) - } - - test('submit with text sends the skill plus the user request', async () => { - enterSkillMode() - const params = createMockParams({ inputValue: 'focus on the API changes' }) - await routeUserPrompt(params) - - expect(useChatStore.getState().inputMode).toBe('default') - expect(useChatStore.getState().pendingSkillName).toBeNull() - expect(params.sendMessage).toHaveBeenCalledTimes(1) - const [{ content }] = (params.sendMessage as ReturnType).mock - .calls[0] as [{ content: string }] - expect(content).toBe( - buildSkillPrompt(TEST_SKILL, 'focus on the API changes'), - ) - }) - - test('empty submit runs the skill without a user request', async () => { - enterSkillMode() - const params = createMockParams({ inputValue: '' }) - await routeUserPrompt(params) - - expect(params.sendMessage).toHaveBeenCalledTimes(1) - const [{ content }] = (params.sendMessage as ReturnType).mock - .calls[0] as [{ content: string }] - expect(content).toBe(buildSkillPrompt(TEST_SKILL, '')) - expect(content).not.toContain('User request:') - }) - - test('submit while a turn is running queues instead of sending', async () => { - enterSkillMode() - const params = createMockParams({ - inputValue: 'and be brief', - isStreaming: true, - }) - await routeUserPrompt(params) - - expect(params.sendMessage).not.toHaveBeenCalled() - expect(params.addToQueue).toHaveBeenCalledTimes(1) - const [queued] = (params.addToQueue as ReturnType).mock - .calls[0] as [string] - expect(queued).toBe(buildSkillPrompt(TEST_SKILL, 'and be brief')) - }) - - test('a skill deleted mid-session reports instead of sending nothing', async () => { - enterSkillMode() - __resetSkillRegistryForTests() - const params = createMockParams({ inputValue: 'anything' }) - await routeUserPrompt(params) - - expect(params.sendMessage).not.toHaveBeenCalled() - expect(params.setMessages).toHaveBeenCalled() - expect(useChatStore.getState().inputMode).toBe('default') - }) - - test('leaving skill mode clears the pending skill', () => { - enterSkillMode() - useChatStore.getState().setInputMode('default') - expect(useChatStore.getState().pendingSkillName).toBeNull() - }) -}) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index b308c7bb91..a0a6f291a7 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -9,8 +9,7 @@ import { collectProcessDiagnostics, formatProcessDiagnostics, } from './process-diagnostics' -import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs, buildSkillPrompt } from './prompt-builders' -import { handleReasoningCommand } from './reasoning' +import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders' import { runBashCommand } from './router' import { handleUsageCommand } from './usage' import { returnToFreebuffLanding } from '../hooks/use-freebuff-session' @@ -44,9 +43,6 @@ export type RouterParams = { logoutMutation: UseMutationResult streamMessageIdRef: React.MutableRefObject addToQueue: (message: string, attachments?: PendingAttachment[]) => void - /** Whether the message queue currently holds anything. Steering checks it - * so a mid-turn submit can't overtake earlier queued submissions. */ - hasQueuedMessages?: () => boolean clearMessages: () => void saveToHistory: (message: string) => void scrollToLatest: () => void @@ -187,11 +183,6 @@ const FREEBUFF_ONLY_COMMANDS = new Set([ 'plan', 'end-session', 'dashboard', - // Freebuff-only because the ladder it reads is the FREEBUFF catalog's, and - // the metadata it sets is honored only for free-mode traffic - // (isFreebuffOriginatedRequest). On Codebuff the command would take a value - // and silently drop it. - 'reasoning', ]) const ALL_COMMANDS: CommandDefinition[] = [ @@ -626,24 +617,6 @@ const ALL_COMMANDS: CommandDefinition[] = [ clearInput(params) }, }), - // /reasoning (freebuff-only) — read or set the thinking level for the - // selected model. Takes effect on the NEXT message: the effort rides - // codebuff_metadata on each request, so nothing about the live session has to - // be restarted for a change to land. - defineCommandWithArgs({ - name: 'reasoning', - aliases: ['effort', 'think'], - handler: (params, args) => { - const { message } = handleReasoningCommand(args) - params.setMessages((prev) => [ - ...prev, - getUserMessage(params.inputValue.trim()), - getSystemMessage(message), - ]) - params.saveToHistory(params.inputValue.trim()) - clearInput(params) - }, - }), // /end-session (freebuff-only) — end the active session early and drop back // to the model picker. The hook flips status to 'none', which unmounts // and mounts , where the user picks a model @@ -718,50 +691,36 @@ function createSkillCommand(skillName: string): CommandDefinition { params.saveToHistory(trimmed) params.setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) - // Bare invocation: like /interview, drop into an input mode so the - // user can add instructions before the skill is sent. Enter with an - // empty composer still runs the skill as-is (the router's skill-mode - // branch), so a no-args run costs one extra keystroke, not a feature. - if (!args.trim()) { - useChatStore.getState().enterSkillMode(skill.name) + // Build the message content with skill context and optional user args + const skillContext = ` +${skill.content} +` + + const userPrompt = `I invoke the following skill:\n\n${skillContext}\n\n` + + (args.trim() + ? `User request: ${args.trim()}` + : '') + + // Check streaming/queue state + if ( + params.isStreaming || + params.streamMessageIdRef.current || + params.isChainInProgressRef.current + ) { + const pendingAttachments = capturePendingAttachments() + params.addToQueue(userPrompt, pendingAttachments) params.setInputFocused(true) params.inputRef.current?.focus() return } - dispatchSkillPrompt(params, skill, args) + params.sendMessage({ + content: userPrompt, + agentMode: params.agentMode, + }) + setTimeout(() => { + params.scrollToLatest() + }, 0) }, }) } - -/** - * Send (or queue, mid-turn) a user-invoked skill prompt. Shared by the - * /skill: args form and the skill input mode's submit (router), so the - * two entry paths for the same feature cannot drift. - */ -export function dispatchSkillPrompt( - params: RouterParams, - skill: { name: string; content: string }, - input: string, -): void { - const userPrompt = buildSkillPrompt(skill, input) - - if ( - params.isStreaming || - params.streamMessageIdRef.current || - params.isChainInProgressRef.current - ) { - params.addToQueue(userPrompt, capturePendingAttachments()) - params.setInputFocused(true) - params.inputRef.current?.focus() - return - } - - params.sendMessage({ - content: userPrompt, - agentMode: params.agentMode, - }) - setTimeout(() => { - params.scrollToLatest() - }, 0) -} diff --git a/cli/src/commands/prompt-builders.ts b/cli/src/commands/prompt-builders.ts index 2435238212..4dc9979778 100644 --- a/cli/src/commands/prompt-builders.ts +++ b/cli/src/commands/prompt-builders.ts @@ -39,26 +39,6 @@ export function buildInterviewPrompt(input: string): string { return `${INTERVIEW_BASE_PROMPT}\n\n${trimmedInput}` } -/** - * Build the prompt for a user-invoked skill. Shared by the /skill: - * command (when it carries trailing text) and the skill input mode's second - * submit, so both entry paths produce byte-identical prompts. - * - * `content` is the whole SKILL.md (frontmatter included) — same as the - * agent-runtime's own skill tool output. - */ -export function buildSkillPrompt( - skill: { name: string; content: string }, - input: string, -): string { - const skillContext = `\n${skill.content}\n` - const trimmedInput = input.trim() - return ( - `I invoke the following skill:\n\n${skillContext}\n\n` + - (trimmedInput ? `User request: ${trimmedInput}` : '') - ) -} - /** * Review scope presets for the review screen. */ diff --git a/cli/src/commands/reasoning.ts b/cli/src/commands/reasoning.ts deleted file mode 100644 index f3fe0af775..0000000000 --- a/cli/src/commands/reasoning.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - getFreebuffModel, - getFreebuffModelDefaultEffort, - getFreebuffModelEfforts, -} from '@codebuff/common/constants/freebuff-models' -import { isReasoningEffort } from '@codebuff/common/constants/reasoning-effort' - -import { - getFreebuffReasoningEffortForModel, - getSelectedFreebuffModel, - useFreebuffModelStore, -} from '../state/freebuff-model-store' - -import type { ReasoningEffort } from '@codebuff/common/constants/reasoning-effort' - -/** Words that mean "stop overriding" rather than naming a rung. `default` is - * the obvious one; `auto` and `reset` are what people type instead. */ -const CLEAR_WORDS = new Set(['default', 'auto', 'reset', 'clear', 'none']) - -function displayName(model: string): string { - return getFreebuffModel(model)?.displayName ?? model -} - -/** - * `/reasoning [level]` — read or set how hard the selected model thinks. - * - * Effort is a REQUEST, not a command: the server re-clamps whatever we send - * against the model that actually runs the turn (resolveFreebuffReasoningEffort), - * which is not always the one selected here — a limited-tier user's premium - * pick is coerced, and a saturated turn can be rerouted mid-flight. So this - * validates against the local catalog for a good error message, and does not - * pretend the answer is final. - * - * Returns the message to post; the caller owns chat state. - */ -export function handleReasoningCommand(args: string): { message: string } { - const model = getSelectedFreebuffModel() - const label = displayName(model) - const efforts = getFreebuffModelEfforts(model) - - if (!efforts) { - return { - message: `${label} has no reasoning levels to adjust — it runs at the provider's own setting. Switch models with /end-session to pick one that does.`, - } - } - - const modelDefault = getFreebuffModelDefaultEffort(model) - const override = getFreebuffReasoningEffortForModel(model) - const ladder = efforts.join(', ') - - const requested = args.trim().toLowerCase() - if (!requested) { - const current = override ?? modelDefault - const suffix = override ? '' : ' (model default)' - return { - message: [ - `Reasoning for ${label}: ${current}${suffix}`, - `Available: ${ladder}`, - `Set it with /reasoning , or /reasoning default to go back to ${modelDefault}.`, - ].join('\n'), - } - } - - if (CLEAR_WORDS.has(requested)) { - useFreebuffModelStore.getState().setReasoningEffort(model, undefined) - return { - message: `Reasoning for ${label} back to the model default (${modelDefault}).`, - } - } - - if (!isReasoningEffort(requested) || !efforts.includes(requested)) { - return { - message: `"${args.trim()}" is not a reasoning level for ${label}. Available: ${ladder}.`, - } - } - - const effort: ReasoningEffort = requested - useFreebuffModelStore.getState().setReasoningEffort(model, effort) - return { - message: `Reasoning for ${label} set to ${effort}. Applies from your next message.`, - } -} diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index 7453034ebe..d9b08aa766 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -3,7 +3,6 @@ import { runTerminalCommand } from '@codebuff/sdk' import { - dispatchSkillPrompt, findCommand, type RouterParams, type CommandResult, @@ -26,8 +25,6 @@ import { IS_FREEBUFF } from '../utils/constants' import { getSystemProcessEnv } from '../utils/env' import { terminalCommandBroker } from '../utils/terminal-command-broker' import { getSystemMessage, getUserMessage } from '../utils/message-history' -import { getSkillByName } from '../utils/skill-registry' -import { pushSteeringMessage } from '../utils/steering-buffer' import { capturePendingAttachments, hasProcessingFiles, @@ -261,7 +258,6 @@ export async function routeUserPrompt( isStreaming, streamMessageIdRef, addToQueue, - hasQueuedMessages, saveToHistory, scrollToLatest, sendMessage, @@ -276,10 +272,9 @@ export async function routeUserPrompt( const pendingImages = pendingAttachments.filter((a) => a.kind === 'image') const trimmed = inputValue.trim() - // Allow empty messages if there are pending attachments (images or text). - // Skill mode also accepts an empty submit: it means "run the skill as-is". + // Allow empty messages if there are pending attachments (images or text) const hasAttachments = pendingAttachments.length > 0 - if (!trimmed && !hasAttachments && inputMode !== 'skill') return + if (!trimmed && !hasAttachments) return // DAU signal: one un-sampled event per user-submitted prompt. The CLI's // distinct id resolves to the canonical codebuff user id (anonymous id is @@ -348,38 +343,6 @@ export async function routeUserPrompt( return } - // Handle skill mode input: the user picked a skill (bare /skill:) - // and is now adding instructions. Empty input runs the skill without any. - if (inputMode === 'skill') { - const skillName = useChatStore.getState().pendingSkillName - const skill = skillName ? getSkillByName(skillName) : undefined - - if (!skill) { - // Mode without a resolvable skill (state got out of sync): explain, - // and leave the user's typed text in the composer rather than - // destroying it — only the mode is reset. - setInputMode('default') - setInputFocused(true) - inputRef.current?.focus() - setMessages((prev) => [ - ...prev, - getSystemMessage(`Skill not found: ${skillName ?? '(unknown)'}`), - ]) - return - } - - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) - setInputMode('default') - setInputFocused(true) - inputRef.current?.focus() - - if (trimmed) { - saveToHistory(trimmed) - } - dispatchSkillPrompt(params, skill, trimmed) - return - } - // Handle review mode input if (inputMode === 'review') { if (!trimmed) return @@ -467,35 +430,6 @@ export async function routeUserPrompt( streamMessageIdRef.current || isChainInProgressRef.current ) { - // Steer the running turn when possible: plain text is handed to the - // active run and injected at its next step boundary, so the user can - // redirect the agent without waiting the turn out. Falls back to the - // queue for anything the steering hook can't carry faithfully: - // attachments (strings only), a slash command (queued today so it can - // error/execute after the turn), pending `!` bash output (only - // prepareUserMessage folds it into the message that referenced it), a - // non-empty queue (steering would deliver this text ahead of earlier - // submissions), and the window where no run is accepting steering. - const canSteer = - !hasAttachments && - !isSlashCommand(trimmed) && - useChatStore.getState().pendingBashMessages.length === 0 && - !hasQueuedMessages?.() - if (canSteer) { - // Echo the bubble now, so the submit is visible immediately, and hand - // its id to the buffer: if the run ends before draining this entry, - // use-send-message retracts the bubble and requeues the text (which - // mints its own bubble at dequeue) — no invisible message, no dupe. - const steeredMessage = getUserMessage(trimmed) - if ( - pushSteeringMessage({ messageId: steeredMessage.id, text: trimmed }) - ) { - setMessages((prev) => [...prev, steeredMessage]) - setInputFocused(true) - inputRef.current?.focus() - return - } - } const pendingAttachmentsForQueue = capturePendingAttachments() // Pass a copy of pending attachments to the queue addToQueue(trimmed, pendingAttachmentsForQueue) diff --git a/cli/src/components/__tests__/deepseek-quota-row.test.tsx b/cli/src/components/__tests__/deepseek-quota-row.test.tsx index c99e61c200..5f699cbbf4 100644 --- a/cli/src/components/__tests__/deepseek-quota-row.test.tsx +++ b/cli/src/components/__tests__/deepseek-quota-row.test.tsx @@ -77,14 +77,6 @@ describe('a section holding two pools', () => { ).toBe('DeepSeek: 1 of 1 used') }) - test('an admission-counted chip says starts', () => { - const counted = { - ...quotas[FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID], - countsAdmissions: true as const, - } - expect(formatFreebuffRowQuota(counted)).toBe('DeepSeek: 1 of 1 starts') - }) - test('nothing is singled out when every row shares a pool', () => { const onePool = { [FREEBUFF_GPT_5_6_LUNA_MODEL_ID]: quotas[FREEBUFF_GPT_5_6_LUNA_MODEL_ID]!, diff --git a/cli/src/components/__tests__/freebuff-model-selector.test.tsx b/cli/src/components/__tests__/freebuff-model-selector.test.tsx index c4d0a60cf4..e824311286 100644 --- a/cli/src/components/__tests__/freebuff-model-selector.test.tsx +++ b/cli/src/components/__tests__/freebuff-model-selector.test.tsx @@ -11,7 +11,6 @@ import { FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_MIMO_V25_MODEL_ID, FREEBUFF_GLM_V53_FLASH_MODEL_ID, - FREEBUFF_SOLAR_PRO_4_MODEL_ID, FREEBUFF_FABLE_5_MODEL_ID, FREEBUFF_GLM_V52_MODEL_ID, FREEBUFF_GPT_5_6_LUNA_MODEL_ID, @@ -165,21 +164,19 @@ describe('FreebuffModelSelector tier layout', () => { // premium or the tier headers it is being ordered against don't apply to // it, non-hero or the landing picker opens collapsed and there are no tier // headers at all. The hero is GPT-5.6 Luna since 2026-08-24, which leaves - // exactly one other premium row — Solar Pro 4 today. - // - // The occupants keep leaving downward: V4 Flash left - // FREEBUFF_PREMIUM_MODEL_IDS on 2026-08-24, V4 Pro was withdrawn on 08-26, - // GLM 5.3 Flash was un-premiumed on 08-28 and moved into UNLIMITED — below - // the header this asserts it sits above. Read the list, not this comment. + // GLM 5.3 Flash as the only row that is both. Flash filled this slot until + // 2026-08-24, when it left FREEBUFF_PREMIUM_MODEL_IDS and moved down into + // UNLIMITED -- below the header this asserts it sits above; V4 Pro until + // its withdrawal on 2026-08-26. useFreebuffModelStore .getState() - .setSelectedModel(FREEBUFF_SOLAR_PRO_4_MODEL_ID) + .setSelectedModel(FREEBUFF_GLM_V53_FLASH_MODEL_ID) const setup = await renderSelector() const frame = setup.captureCharFrame() const premiumHeaderIndex = frame.indexOf('PREMIUM') const recommendedModelIndex = frame.indexOf('GPT-5.6 Luna') - const selectedModelIndex = frame.indexOf('Solar Pro 4') + const selectedModelIndex = frame.indexOf('GLM 5.3 Flash') const unlimitedHeaderIndex = frame.indexOf('UNLIMITED') expect(premiumHeaderIndex).toBeGreaterThanOrEqual(0) @@ -189,7 +186,7 @@ describe('FreebuffModelSelector tier layout', () => { // 2026-08-20 and left the picker entirely. expect(unlimitedHeaderIndex).toBeGreaterThan(selectedModelIndex) // The cursor sits on the SAVED pick, not on the recommendation. - expect(frame).toContain('› Solar Pro 4') + expect(frame).toContain('› GLM 5.3 Flash') expect(frame).not.toContain('› GPT-5.6 Luna') }) @@ -303,25 +300,23 @@ describe('FreebuffModelSelector tier layout', () => { }) test('collapses to the unlimited hero when the premium default is spent', async () => { - // A returning user sitting on a spent PREMIUM row opens the picker already - // on a row `pick` silently refuses. Both the selection AND the cursor have - // to leave it, or Enter does nothing with no explanation — and the picker - // has to collapse onto the replacement, or it opens on greyed, unusable - // premium rows with the recommendation below them. - // - // KEYED ON A PREMIUM ROW (Luna), NOT ON THE DEFAULT. It used to key on - // DEFAULT_FREEBUFF_MODEL_ID, which was right for as long as every default - // was premium — 2026-08-12 to 08-30. The default is now unmetered, so - // exhausting "its pool" exhausts nothing and the step-down under test never - // fires. Keying on the row that actually HAS a pool keeps this covering the - // behaviour rather than passing vacuously. + // The default selection has been premium since 2026-08-12, so a returning + // user who has spent their pool opens the picker already sitting on a row + // `pick` silently refuses. Both the selection AND the cursor have to leave + // it, or Enter does nothing with no explanation — and the picker has to + // collapse onto the replacement, or it opens on three greyed, unusable + // premium rows with the recommendation fourth. const resetAt = new Date(FIXED_NOW_MS + 60_000).toISOString() useFreebuffSessionStore.getState().setSession({ status: 'none', accessTier: 'full', + // Keyed on the CURRENT default rather than on a named model: the default + // moved from Flash to V4 Pro on 2026-08-21, and this fixture has to + // exhaust the pool of whichever row the picker will actually open on, or + // the step-down under test never triggers. rateLimitsByModel: { - [FREEBUFF_GPT_5_6_LUNA_MODEL_ID]: { - model: FREEBUFF_GPT_5_6_LUNA_MODEL_ID, + [DEFAULT_FREEBUFF_MODEL_ID]: { + model: DEFAULT_FREEBUFF_MODEL_ID, limit: 6, period: 'pacific_day', resetTimeZone: 'America/Los_Angeles', @@ -331,23 +326,17 @@ describe('FreebuffModelSelector tier layout', () => { }, }, }) - useFreebuffModelStore - .getState() - .setSelectedModel(FREEBUFF_GPT_5_6_LUNA_MODEL_ID) + useFreebuffModelStore.getState().setSelectedModel(DEFAULT_FREEBUFF_MODEL_ID) const setup = await renderSelector() await Promise.resolve() await setup.renderOnce() await setup.renderOnce() - // Lands on the RECOMMENDATION, which is now unmetered — so unlike every - // version of this test since 2026-08-12 the destination is not the - // fallback. The user is moved off the row they cannot use and onto the one - // the picker leads with, rather than being demoted two steps. - expect(getSelectedFreebuffModel()).toBe(DEFAULT_FREEBUFF_MODEL_ID) + expect(getSelectedFreebuffModel()).toBe(FALLBACK_FREEBUFF_MODEL_ID) const frame = setup.captureCharFrame() // `›` is the cursor: it has to be on the row Enter now commits. - expect(frame).toContain('› GLM 5.3 Flash') + expect(frame).toContain('› MiMo 2.5') // …and that row is the whole screen, exactly as for a user who is already // on the recommendation. The spent rows live behind the toggle. expect(frame).toContain('See all') @@ -378,11 +367,8 @@ describe('FreebuffModelSelector tier layout', () => { await setup.renderOnce() await setup.renderOnce() - // Repaired onto the recommendation. Was the fallback while the default was - // premium; an unmetered default is always joinable, so an invalid selection - // now lands on the row the picker leads with. - expect(getSelectedFreebuffModel()).toBe(DEFAULT_FREEBUFF_MODEL_ID) - expect(setup.captureCharFrame()).toContain('› GLM 5.3 Flash') + expect(getSelectedFreebuffModel()).toBe(FALLBACK_FREEBUFF_MODEL_ID) + expect(setup.captureCharFrame()).toContain('› MiMo 2.5') }) test('shows every limited-tier model when the access tier arrives after mount', async () => { @@ -473,11 +459,12 @@ describe('FreebuffModelSelector tier layout', () => { // // WHICH row wears it is arithmetic, not semantic: getFreebuffSectionQuotas // gives the header to the pool MOST rows share and breaks ties toward the - // earlier row. The occupant has moved with every premium departure — Flash - // out on 2026-08-24, V4 Pro withdrawn 08-26, GLM 5.3 Flash un-premiumed - // 08-28. The invariant under test — a second line the width and height math - // must both know about — is unchanged; only the row it lands on moves, so - // this drives it from the CURRENT premium list rather than naming a row. + // earlier row. With Flash in the section that was 2-1 for the shared + // premium pool, so Luna's one-a-day ceiling wore the chip. Flash left that + // pool on 2026-08-24, leaving Luna and V4 Pro tied 1-1, so the header now + // speaks for Luna and it is V4 PRO that carries its own count. The + // invariant under test — a second line the width and height math must + // both know about — is unchanged; only the row it lands on moved. const resetAt = new Date(FIXED_NOW_MS + 60_000).toISOString() const pool = ( model: string, @@ -506,16 +493,10 @@ describe('FreebuffModelSelector tier layout', () => { 'Premium', 4, ), - // A row answering to a pool the section header does NOT speak for, so - // it carries its own chip. SYNTHESISED rather than read from - // FREEBUFF_PER_MODEL_SESSION_CAPS, which is empty since 2026-08-28 — - // this test is about the width and height math around a second line, - // not about which model happens to be capped this week, and tying it to - // a real cap is what made it break every time one moved. - [FREEBUFF_SOLAR_PRO_4_MODEL_ID]: pool( - FREEBUFF_SOLAR_PRO_4_MODEL_ID, - 'solar_trial', - 'Solar Pro 4', + [FREEBUFF_GLM_V53_FLASH_MODEL_ID]: pool( + FREEBUFF_GLM_V53_FLASH_MODEL_ID, + 'glm_v53_flash', + 'GLM 5.3 Flash', 2, ), }, @@ -524,10 +505,9 @@ describe('FreebuffModelSelector tier layout', () => { .getState() // NOT the hero, so the picker opens expanded and the chip under test is // drawn at all. Luna took the hero slot on 2026-08-24; selecting it here - // collapses the list to a single row and the chip disappears. V4 Flash - // also supplies the warning-ONLY second line asserted below, which the - // chip row cannot: every row carrying a pool row here also carries a - // chip. + // collapses the list to a single row and the chip disappears. Flash also + // supplies the warning-ONLY second line asserted below, which the chip + // row cannot: every row carrying a pool row here also carries a chip. .setSelectedModel(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) const frame = (await renderSelector()).captureCharFrame() @@ -543,11 +523,12 @@ describe('FreebuffModelSelector tier layout', () => { ] } const lines = frame.split('\n') - // The second line carrying a per-row chip. Anchored on the chip TEXT, so a - // chip that stops being drawn fails here rather than quietly re-measuring - // some warning-only line instead. A per-row label is longer than the shared - // one, which is the case the width math has to survive. - const chipLine = lines.find((l) => l.includes('Solar Pro 4: 0 of 2 used')) + // GLM 5.3 Flash's second line carries its per-model ceiling chip. Anchored + // on the chip text, so a chip that stops being drawn fails here rather than + // quietly re-measuring some warning-only line instead. Deliberately the + // PER-MODEL pool rather than the shared one: its label is the longest the + // caps table can produce, which is the case the width math has to survive. + const chipLine = lines.find((l) => l.includes('GLM 5.3 Flash: 0 of 2 used')) // Flash carries the training warning with nothing after it — the shape the // width math already handled, which is the "ordinary warning line" above. const warningOnlyLine = lines.find( @@ -609,26 +590,10 @@ describe('FreebuffModelSelector tier layout', () => { // The reserved cue gutter used to sit between the last badge and the right // border, padding the card out by ~17 columns of empty space. What remains // is ordinary slack from the widest row in the set. - // - // So this bound tracks the WIDEST ROW, not the hero's own content, and it - // moves whenever any row in the set grows. It went 10 -> 14 when GLM 5.3 - // Flash gained a reasoning ladder, which widens its row two different ways: - // a model with a pinned `reasoningEffort` shows ` · Reasoning: `, and - // a model the user has picked a rung for shows ` · Reasoning: *` - // whether or not one is pinned (see reasoningSuffixFor). GLM 5.3 Flash has - // no pinned effort — it runs at the provider's own setting — but an earlier - // test in this file leaves a saved pick in the store, so the starred form is - // what is actually being measured here. That is the card sizing itself to - // its content, which is the behaviour under test. - // - // Kept well under 17 deliberately — the number has to stay small enough to - // fail if the reserved gutter ever comes back, which is the only thing this - // assertion is really guarding. Widen it again only for a real content - // change, and check WHICH row got wider before you do. const gapToBorder = heroRow.length - 1 - (heroRow.indexOf('NEW') + 'NEW'.length) expect(heroRow.endsWith('│')).toBe(true) - expect(gapToBorder).toBeLessThan(14) + expect(gapToBorder).toBeLessThan(10) }) }) @@ -741,77 +706,3 @@ describe('FreebuffModelSelector limited-model offer', () => { expect(isFreebuffModelId(getSelectedFreebuffModel())).toBe(true) }) }) - -describe('FreebuffModelSelector plan line', () => { - const PLAN_SESSION = { - status: 'none', - accessTier: 'full', - subscription: { - tierId: 'starter', - tiers: [ - { - id: 'starter', - displayName: 'Starter', - priceUsd: 8, - firstPeriodPriceUsd: 2.5, - dailySessions: 2, - fiveDaySessions: 6, - monthlySessions: 50, - monthlySpendLimitUsd: 40, - dailyPremiumSessions: 2, - disclaimers: [], - current: true, - upgrade: false, - downgrade: false, - }, - ], - usage: { - dayUsed: 1.3, - dayLimit: 2, - fiveDayUsed: 3, - fiveDayLimit: 6, - monthUsed: 11, - monthLimit: 50, - dayPremiumUsed: 1, - dayPremiumLimit: 2, - dayResetAt: new Date(FIXED_NOW_MS + 3 * 3600_000).toISOString(), - periodEndsAt: new Date( - FIXED_NOW_MS + 20 * 24 * 3600_000, - ).toISOString(), - monthSpendUsd: 3.21, - monthSpendLimitUsd: 40, - }, - }, - } as never - - test('a subscriber sees their plan windows under the catalog', async () => { - useFreebuffSessionStore.getState().setSession(PLAN_SESSION) - const frame = (await renderSelector()).captureCharFrame() - expect(frame).toContain('STARTER PLAN') - expect(frame).toContain('today 1.3 of 2') - expect(frame).toContain('5-day 3 of 6') - expect(frame).toContain('month 11 of 50') - }) - - test('a blocking limit names itself and its reset', async () => { - useFreebuffSessionStore.getState().setSession({ - ...(PLAN_SESSION as Record), - subscription: { - ...(PLAN_SESSION as { subscription: Record }) - .subscription, - blockedBy: 'daily', - }, - } as never) - const frame = (await renderSelector()).captureCharFrame() - expect(frame).toContain("today's plan sessions are used") - expect(frame).toContain('resets in 3h') - }) - - test('no plan means no plan line', async () => { - useFreebuffSessionStore - .getState() - .setSession({ status: 'none', accessTier: 'full' } as never) - const frame = (await renderSelector()).captureCharFrame() - expect(frame).not.toContain('PLAN ·') - }) -}) diff --git a/cli/src/components/__tests__/freebuff-offer-invariants.test.ts b/cli/src/components/__tests__/freebuff-offer-invariants.test.ts index 536c46698d..01ff6a1191 100644 --- a/cli/src/components/__tests__/freebuff-offer-invariants.test.ts +++ b/cli/src/components/__tests__/freebuff-offer-invariants.test.ts @@ -9,35 +9,12 @@ import { describe, expect, test } from 'bun:test' import { getFreebuffRootAgentIdForModel } from '@codebuff/common/constants/free-agents' import { FREEBUFF_GLM_V52_MODEL_ID, - FREEBUFF_GPT_5_6_LUNA_MODEL_ID, - LIMITED_FREEBUFF_MODEL_ID, + resolveFreebuffModelForAccessTier, } from '@codebuff/common/constants/freebuff-models' import { freebuffOfferViolations } from '@codebuff/common/testing/freebuff-offer-invariants' -import { - resolveFreebuffModelPickForSession, - resolveFreebuffModelSelectionForSession, -} from '../../hooks/use-freebuff-session' import { freebuffCliOfferedModelIds } from '../freebuff-model-selector' -import type { FreebuffAccessTier } from '@codebuff/common/constants/freebuff-models' -import type { FreebuffSessionResponse } from '../../types/freebuff-session' - -function cliAcceptsModel( - model: string, - accessTier: FreebuffAccessTier, - hasPaidSubscription = false, -): boolean { - const session: FreebuffSessionResponse = { - status: 'none', - accessTier, - ...(hasPaidSubscription - ? { subscription: { tierId: 'starter', tiers: [] } } - : {}), - } - return resolveFreebuffModelPickForSession(model, session) === model -} - describe('freebuff rows the CLI offers', () => { for (const accessTier of ['full', 'limited'] as const) { test(`are all usable on the ${accessTier} tier`, () => { @@ -48,7 +25,8 @@ describe('freebuff rows the CLI offers', () => { offered: freebuffCliOfferedModelIds(accessTier), // the CLI's own resolver, which every session start runs the selection through: a model // it coerces away is one the user picked and never got - accepts: (model) => cliAcceptsModel(model, accessTier), + accepts: (model) => + resolveFreebuffModelForAccessTier(model, accessTier) === model, rootAgentIdFor: getFreebuffRootAgentIdForModel, catalog: 'supported', }), @@ -56,62 +34,10 @@ describe('freebuff rows the CLI offers', () => { }) } - // A paid plan reaches limited regions, so a limited-region subscriber's grid gains the models - // their plan meters. Its own surface: the CLI's own resolver has to keep the pick too, or the - // user picks the model they bought and the session starts on MiMo. - test('are all usable on the limited tier for a subscriber', () => { - expect( - freebuffOfferViolations({ - surface: 'cli picker + referral banner (limited, subscriber)', - accessTier: 'limited', - hasPaidSubscription: true, - offered: freebuffCliOfferedModelIds('limited', true), - accepts: (model) => cliAcceptsModel(model, 'limited', true), - rootAgentIdFor: getFreebuffRootAgentIdForModel, - catalog: 'supported', - }), - ).toEqual([]) - }) - - // The plan widens what may be PICKED, never what the free pools give. - test('the limited grid keeps every free row for a subscriber', () => { - const free = freebuffCliOfferedModelIds('limited') - const paid = freebuffCliOfferedModelIds('limited', true) - for (const id of free) expect(paid).toContain(id) - expect(paid.length).toBeGreaterThan(free.length) - }) - - test('a limited subscriber startup keeps their saved plan model selected', () => { - const paidSession: FreebuffSessionResponse = { - status: 'none', - accessTier: 'limited', - subscription: { tierId: 'starter', tiers: [] }, - } - const unpaidSession: FreebuffSessionResponse = { - status: 'none', - accessTier: 'limited', - } - - expect( - resolveFreebuffModelSelectionForSession( - FREEBUFF_GPT_5_6_LUNA_MODEL_ID, - paidSession, - ), - ).toBe(FREEBUFF_GPT_5_6_LUNA_MODEL_ID) - expect( - resolveFreebuffModelSelectionForSession( - FREEBUFF_GPT_5_6_LUNA_MODEL_ID, - unpaidSession, - ), - ).toBe(LIMITED_FREEBUFF_MODEL_ID) - }) - test('the earned reward is offered on BOTH tiers, and the grid never shows it', () => { // Limited access included: a bounty grant is redeemable there, so the row has to be // reachable there. The banner still only renders it against a live balance. - expect(freebuffCliOfferedModelIds('full')).toContain( - FREEBUFF_GLM_V52_MODEL_ID, - ) + expect(freebuffCliOfferedModelIds('full')).toContain(FREEBUFF_GLM_V52_MODEL_ID) expect(freebuffCliOfferedModelIds('limited')).toContain( FREEBUFF_GLM_V52_MODEL_ID, ) diff --git a/cli/src/components/__tests__/status-bar.test.tsx b/cli/src/components/__tests__/status-bar.test.tsx index 2db6c9afd5..b0af467ef3 100644 --- a/cli/src/components/__tests__/status-bar.test.tsx +++ b/cli/src/components/__tests__/status-bar.test.tsx @@ -1,18 +1,12 @@ import { beforeAll, describe, expect, test } from 'bun:test' -import { FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID } from '@codebuff/common/constants/freebuff-model-ids' import { createTestRenderer } from '@opentui/core/testing' import { createRoot, flushSync } from '@opentui/react' import React from 'react' import { StatusBar } from '../status-bar' import { initializeThemeStore } from '../../hooks/use-theme' -import { useChatStore } from '../../state/chat-store' -import { IS_FREEBUFF } from '../../utils/constants' import { getStatusIndicatorState } from '../../utils/status-indicator-state' -import type { FreebuffSessionResponse } from '../../types/freebuff-session' -import type { RunState } from '@codebuff/sdk' - beforeAll(() => { initializeThemeStore() }) @@ -47,60 +41,4 @@ describe('StatusBar', () => { setup.renderer.destroy() } }) - - // The idle session line (and therefore the context readout) only renders in - // freebuff builds — useFreebuffSessionProgress returns null otherwise. - test.skipIf(!IS_FREEBUFF)( - 'renders context usage next to the unlimited label', - async () => { - const now = Date.now() - const session = { - status: 'active', - accessTier: 'full', - instanceId: 'test-instance', - model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, - admittedAt: new Date(now - 60_000).toISOString(), - expiresAt: new Date(now + 3_600_000).toISOString(), - remainingMs: 3_600_000, - } as FreebuffSessionResponse - useChatStore.getState().setRunState({ - sessionState: { - mainAgentState: { contextTokenCount: 142_310 }, - }, - } as RunState) - - const statusIndicatorState = getStatusIndicatorState({ - statusMessage: null, - streamStatus: 'idle', - nextCtrlCWillExit: false, - isConnected: true, - }) - // Wide frame: the right-hand flex column takes half the row, and the - // left label truncates rather than wraps. - const setup = await createTestRenderer({ width: 140, height: 3 }) - const root = createRoot(setup.renderer) - flushSync(() => { - root.render( - {}} - statusIndicatorState={statusIndicatorState} - freebuffSession={session} - />, - ) - }) - - try { - await setup.renderOnce() - const frame = setup.captureCharFrame() - // 142,310 of DeepSeek V4 Flash's 1,048,576-token window → 14%. - expect(frame).toContain('unlimited · 142.3K (14%)') - } finally { - flushSync(() => root.unmount()) - setup.renderer.destroy() - useChatStore.getState().setRunState(null) - } - }, - ) }) diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index bd69227021..8f2aadf578 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -123,25 +123,8 @@ export const ChatInputBar = ({ }: ChatInputBarProps) => { const inputMode = useChatStore((state) => state.inputMode) const setInputMode = useChatStore((state) => state.setInputMode) - const pendingSkillName = useChatStore((state) => state.pendingSkillName) - - const baseModeConfig = getInputModeConfig(inputMode) - // Skill mode names the pending skill in the banner so the user can see - // what their text will be attached to. Skill names run up to 64 chars; - // keep the banner narrow enough to leave room for typing. - const skillLabel = - inputMode === 'skill' && pendingSkillName - ? pendingSkillName.length > 24 - ? `${pendingSkillName.slice(0, 23)}…` - : pendingSkillName - : null - const modeConfig = skillLabel - ? { - ...baseModeConfig, - label: skillLabel, - widthAdjustment: skillLabel.length + 3, - } - : baseModeConfig + + const modeConfig = getInputModeConfig(inputMode) const askUserState = useChatStore((state) => state.askUserState) const hasAnyPreview = hasSuggestionMenu diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index db1bb9ee74..b17a78b187 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -33,14 +33,8 @@ import { getRateLimitsByModel, getGlmPromo, getReferralInfo, - getSubscriptionInfo, } from '@codebuff/common/types/freebuff-session' -import { - formatPlanWindows, - freebuffPlanSummary, -} from '@codebuff/common/util/freebuff-plan-summary' - import { startFreebuffSession } from '../hooks/use-freebuff-session' import { useNow } from '../hooks/use-now' import { useFreebuffModelStore } from '../state/freebuff-model-store' @@ -167,14 +161,8 @@ interface FreebuffModelSelectorProps { * model, so it reaches the user through FreebuffReferralBanner instead. */ function gridModels( accessTier: FreebuffAccessTier, - /** Live paid plan. A plan reaches limited regions, so a subscriber there is - * offered the rows their plan meters instead of MiMo alone — the server - * admits them (see `hasPaidSubscription` on - * isFreebuffSessionModelAllowedForAccessTier), and a picker that hid them - * would sell a plan whose models never appear. */ - hasPaidSubscription = false, ): readonly FreebuffModelOption[] { - return getFreebuffModelsForAccessTier(accessTier, hasPaidSubscription).filter( + return getFreebuffModelsForAccessTier(accessTier).filter( (m) => !isFreebuffGlmV52ModelId(m.id), ) } @@ -190,13 +178,8 @@ function gridModels( * renders the action when the server reports sessions left — and the tier never was. */ export function freebuffCliOfferedModelIds( accessTier: FreebuffAccessTier, - /** See gridModels. */ - hasPaidSubscription = false, ): readonly string[] { - return [ - ...gridModels(accessTier, hasPaidSubscription).map((m) => m.id), - FREEBUFF_GLM_V52_MODEL_ID, - ] + return [...gridModels(accessTier).map((m) => m.id), FREEBUFF_GLM_V52_MODEL_ID] } export const FreebuffModelSelector: React.FC = ({ @@ -213,13 +196,6 @@ export const FreebuffModelSelector: React.FC = ({ const { contentMaxWidth } = useTerminalDimensions() const selectedModel = useFreebuffModelStore((s) => s.selectedModel) const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel) - // Subscribed, not read imperatively: `/reasoning` can change a row's effort - // while the picker is unmounted, and the width maths below memoizes on this - // value. Reading the store outside React would leave the memo stale and - // truncate the row it just widened. - const reasoningEffortByModel = useFreebuffModelStore( - (s) => s.reasoningEffortByModel, - ) const session = useFreebuffSessionStore((s) => s.session) const accessTier = (session && 'accessTier' in session ? session.accessTier : undefined) ?? @@ -235,20 +211,7 @@ export const FreebuffModelSelector: React.FC = ({ const [pending, setPending] = useState(null) const [hoveredId, setHoveredId] = useState(null) - // `subscription.tierId` is non-null exactly when the server resolved an - // ENTITLING plan row, so the picker widens on the server's own verdict rather - // than on anything it decides for itself. - const subscriptionInfo = getSubscriptionInfo(session) - const hasPaidSubscription = Boolean(subscriptionInfo?.tierId) - // The paid plan's own windows, rendered as a single muted line below the - // catalog — the CLI counterpart of the web dropdown's plan panel. The same - // shared summary drives Desktop and the web usage page, so all three name - // the same binding limit and the same reset. - const planSummary = freebuffPlanSummary(subscriptionInfo) - const availableModels = useMemo( - () => gridModels(accessTier, hasPaidSubscription), - [accessTier, hasPaidSubscription], - ) + const availableModels = useMemo(() => gridModels(accessTier), [accessTier]) // Capacity-limited models the SERVER decided to offer on this response. The // client has no catalog of its own for these on purpose: when the wave's pool // empties (or the offer is switched off) the payload stops arriving and every @@ -617,32 +580,6 @@ export const FreebuffModelSelector: React.FC = ({ setSelectedModel, ]) - // What the row advertises as this model's reasoning: the user's `/reasoning` - // pick when they made one, otherwise the effort the server pins from the - // catalog. ONE function for both the width maths and the render — they were - // separate strings before the picker gained an override, and a row whose - // suffix outgrows what the width maths budgeted for is a truncated row. - // - // A model with a LADDER but no pinned `reasoningEffort` (Fable 5) still shows - // nothing until the user picks: its default is the provider's own, and - // spending row width to restate it pushed the "see all models" toggle off a - // short terminal. The suffix appears the moment it carries information the - // user did not already have. - const reasoningSuffixFor = useCallback( - (model: FreebuffModelOption): string => { - const chosen = reasoningEffortByModel[model.id] - if (chosen && model.efforts?.includes(chosen)) { - // The '*' marks a rung the USER chose, so a pick is distinguishable - // from the catalog default without a second line. - return ` · Reasoning: ${chosen}*` - } - return model.reasoningEffort - ? ` · Reasoning: ${model.reasoningEffort}` - : '' - }, - [reasoningEffortByModel], - ) - const BUTTON_CHROME = 4 // 2 border + 2 padding const NAME_GAP = 2 // spaces between name column and details column @@ -684,7 +621,7 @@ export const FreebuffModelSelector: React.FC = ({ m.multimodal ? 9 : 0 // Same treatment for the " · Reasoning: high" effort suffix. const reasoningSuffixLen = (m: FreebuffModelOption) => - reasoningSuffixFor(m).length + m.reasoningEffort ? 14 + m.reasoningEffort.length : 0 // Same treatment for the " · NEW" badge (6 chars). const newSuffixLen = 6 // Ox Alpha reached the CLI on 2026-08-24 as an experimental row. The badge is @@ -758,7 +695,6 @@ const testSuffixLen = ' · TEST'.length availableModels, offerModels, contentMaxWidth, - reasoningSuffixFor, rowDetailsText, supersededNoticeFor, ]) @@ -791,15 +727,6 @@ const testSuffixLen = ' · TEST'.length y += rowHeight(m) }) }) - // The plan summary contributes real rows like everything else here: left - // out of the estimate, the first frame's viewport ends exactly one row - // short per line — the plan line steals the toggle's row and the blocked - // row is clipped outright, which is precisely the row a blocked user - // needs. - if (planSummary) { - y += SECTION_GAP + 1 - if (planSummary.blocked) y += 1 - } if (canCollapse) { y += TOGGLE_MARGIN y += 1 @@ -812,7 +739,6 @@ const testSuffixLen = ' · TEST'.length canCollapse, showStandaloneRecommended, supersededNoticeFor, - planSummary, ]) // When a referral exists, start at the parent's full allowance until the @@ -997,7 +923,11 @@ const testSuffixLen = ' · TEST'.length // see pixels. const imagesSuffix = model.multimodal ? ' · Images' : '' - const reasoningSuffix = reasoningSuffixFor(model) + // The effort the server runs this model at (the same catalog field the + // completions layer sends) — see FreebuffModelOption.reasoningEffort. + const reasoningSuffix = model.reasoningEffort + ? ` · Reasoning: ${model.reasoningEffort}` + : '' return (