From 347d30605e46bf061962001d68ae9d8f7403e2f1 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Sun, 5 Jul 2026 11:17:19 +0800 Subject: [PATCH] docs(tooling): translate build/CI, scripts, and pragent-shim to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final code-comment layer (22 files): CI workflows, electron-builder.yml, electron.vite.config.ts, after-pack.cjs, assemble-pragent-runtime.mjs, the pragent-shim Python subsystem (14 files), and .gitignore comments. Beyond comments/docstrings, the developer-facing log/error OUTPUT strings in the dev scripts are also translated (per the logs/exceptions-in-English convention) — assemble-runtime logs, shim RuntimeError/_debug/_warn messages, after-pack logs. Functional strings (LLM prompts, config keys, sentinels, upstream-matched messages) left verbatim. Also fixes a stale doc path in .gitignore (modules/04 → arch/02-agent/05). Verified: node --check + py_compile + YAML parse all pass; prepare:pragent re-synced the shim and the smoke test passes (shim patch active). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-cli.yml | 6 +- .github/workflows/release.yml | 122 +++++----- .gitignore | 10 +- apps/desktop/build-resources/after-pack.cjs | 32 +-- apps/desktop/electron-builder.yml | 68 +++--- apps/desktop/electron.vite.config.ts | 6 +- .../scripts/assemble-pragent-runtime.mjs | 212 +++++++++--------- apps/desktop/scripts/pragent-shim/.gitignore | 2 +- .../meebox_pragent_shim/__init__.py | 25 ++- .../pragent-shim/meebox_pragent_shim/chat.py | 41 ++-- .../meebox_pragent_shim/cli/__init__.py | 4 +- .../meebox_pragent_shim/cli/install.py | 88 ++++---- .../meebox_pragent_shim/cli/parsers.py | 34 +-- .../meebox_pragent_shim/cli/specs.py | 32 +-- .../meebox_pragent_shim/patches/__init__.py | 5 +- .../patches/describe_assessment.py | 30 +-- .../patches/litellm_handler.py | 104 ++++----- .../meebox_pragent_shim/patches/load_yaml.py | 72 +++--- .../patches/local_git_provider.py | 68 +++--- .../meebox_pragent_shim/runtime.py | 46 ++-- .../pragent-shim/meebox_pragent_shim/usage.py | 18 +- .../scripts/pragent-shim/sitecustomize.py | 18 +- 22 files changed, 526 insertions(+), 517 deletions(-) diff --git a/.github/workflows/ci-cli.yml b/.github/workflows/ci-cli.yml index 2e03888d..869ff69d 100644 --- a/.github/workflows/ci-cli.yml +++ b/.github/workflows/ci-cli.yml @@ -1,8 +1,8 @@ name: CLI -# meebox CLI(cli/,独立 Go module)的门禁,与 Node/Nx 的 CI 分开: -# 路径过滤只能加在 workflow 的 on 层(不能按 job 过滤),故独立成一条流水线——仅当 cli/ 变更时才跑, -# 既隔离 Go 工具链、又省 CI 分钟。发布期的交叉编译 / 出包见 release.yml 的 cli job。 +# Gate for the meebox CLI (cli/, standalone Go module), separate from the Node/Nx CI: +# path filters can only live at the workflow `on` level (not per-job), so this is its own pipeline — runs only when cli/ changes, +# both isolating the Go toolchain and saving CI minutes. For release-time cross-compile / packaging see the cli job in release.yml. on: push: branches: [master] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56142338..26cc650c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,19 @@ -# 发布 workflow —— tag 触发,自动出 Windows + macOS(arm64) 安装包并挂到 GitHub Release。 +# Release workflow — tag-triggered, automatically builds Windows + macOS(arm64) installers and attaches them to a GitHub Release. # -# 免费路线(当前):仓库无 Apple 签名 secrets → mac 包由 afterPack 做 ad-hoc 签名 -# (arm64 能跑,含嵌入式 python),但不公证,用户首次需"仍要打开"。见 docs/mac-build.md。 +# Free route (current): repo has no Apple signing secrets → mac package is ad-hoc signed by afterPack +# (runs on arm64, includes embedded python), but not notarized, so users need "Open anyway" on first launch. See docs/mac-build.md. # -# 升级到公证(将来有 Apple Developer ID):把下列 secrets 配进仓库即可,workflow 不用改—— -# afterPack 检测到凭据会自动让位给 electron-builder 的正式签名 + 公证: -# MAC_CSC_LINK Developer ID 证书 .p12 的 base64 -# MAC_CSC_KEY_PASSWORD .p12 密码 -# APPLE_API_KEY App Store Connect API key .p8 的 base64(或路径) +# Upgrading to notarization (once an Apple Developer ID is available): just configure the following secrets in the repo, no workflow change needed — +# on detecting credentials afterPack yields to electron-builder's proper signing + notarization: +# MAC_CSC_LINK base64 of the Developer ID certificate .p12 +# MAC_CSC_KEY_PASSWORD .p12 password +# APPLE_API_KEY base64 (or path) of the App Store Connect API key .p8 # APPLE_API_KEY_ID / APPLE_API_ISSUER -# 并在 electron-builder.yml mac 段加 hardenedRuntime / entitlements / notarize。 +# and add hardenedRuntime / entitlements / notarize to the mac section of electron-builder.yml. # -# 发布采用**两阶段**:gui / cli 各 matrix job 只构建并把产物上传为 workflow artifact;末置单个 -# release job 汇总下载 + 组装正文 + 一次性上传到 Release。此前各 job 各自调 softprops 直传,多个 job -# 并发对同一 tag 创建 / finalize Release 会撞 `already_exists` 竞态——单点发布根除之。 +# Release is **two-stage**: the gui / cli matrix jobs only build and upload their artifacts as workflow artifacts; a final single +# release job aggregates the downloads + assembles the body + uploads to the Release in one shot. Previously each job called softprops directly, and multiple jobs +# concurrently creating / finalizing the Release for the same tag hit an `already_exists` race — a single publish point eliminates it. name: Release @@ -24,7 +24,7 @@ on: workflow_dispatch: {} permissions: - contents: write # 创建 Release + 上传产物 + contents: write # create Release + upload artifacts concurrency: group: release-${{ github.ref }} @@ -38,27 +38,27 @@ jobs: include: - os: windows-latest artifacts: apps/desktop/release/*.exe - - os: macos-14 # arm64 runner,原生构建 mac arm64 + - os: macos-14 # arm64 runner, natively builds mac arm64 artifacts: apps/desktop/release/*.dmg runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: - lfs: true # 拉 LFS 图标(assets/icons/icon.ico 等),否则只是指针 → 构建失败 + lfs: true # pull LFS icons (assets/icons/icon.ico etc.), otherwise they are just pointers → build failure - uses: actions/setup-node@v4 with: node-version: '22' cache: npm - - name: 安装依赖 + - name: Install dependencies run: npm ci - # 有 Apple 凭据时才提升为签名 env(走正式签名 + 公证);没有则一个都不设。 - # 关键:不能把缺失的 secret 直接挂到 env —— 那样 CSC_LINK 会变成空串 "", - # electron-builder 会把它当证书路径解析成 projectDir(apps/desktop) → "not a file" 报错。 - # 无凭据时交给 afterPack 做 ad-hoc 签名(见文件头注释 + after-pack.cjs)。 - - name: 准备 mac 签名凭据 + # Only promote to signing env when Apple credentials are present (proper signing + notarization); otherwise set none. + # Key point: do not attach a missing secret straight to env — that turns CSC_LINK into an empty string "", + # and electron-builder resolves it as a certificate path relative to projectDir(apps/desktop) → "not a file" error. + # Without credentials, hand off to afterPack for ad-hoc signing (see file-header comment + after-pack.cjs). + - name: Prepare mac signing credentials if: runner.os == 'macOS' shell: bash env: @@ -76,28 +76,28 @@ jobs: echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" } >> "$GITHUB_ENV" - echo "检测到 Apple 凭据 → 正式签名 + 公证" + echo "Apple credentials detected → proper signing + notarization" else - echo "无 Apple 凭据 → afterPack ad-hoc 签名(免费路线)" + echo "No Apple credentials → afterPack ad-hoc signing (free route)" fi - - name: 构建 + 出包 - shell: bash # win/mac 统一用 bash,保证多行命令 fail-fast + - name: Build + package + shell: bash # use bash on both win/mac to guarantee fail-fast on multi-line commands working-directory: apps/desktop run: | - npm run prepare:pragent # 组装当前平台的嵌入式 pr-agent 运行时 + npm run prepare:pragent # assemble the embedded pr-agent runtime for the current platform npm run build # electron-vite build - npm run notices # 生成第三方声明,供 electron-builder 打入包 + npm run notices # generate third-party notices for electron-builder to bundle npx electron-builder --publish never env: - # 无 CSC_LINK 时禁止 electron-builder 去钥匙串自动找签名身份(afterPack 已 ad-hoc); - # 有真证书时走 CSC_LINK 导入签名,不受此项影响。 + # Without CSC_LINK, forbid electron-builder from auto-finding a signing identity in the keychain (afterPack already did ad-hoc); + # with a real certificate, CSC_LINK imports the signature and this setting has no effect. CSC_IDENTITY_AUTO_DISCOVERY: false - # prepare:pragent 调 GitHub API 列 python-build-standalone release 资产;匿名 60 次/h/IP - # 易被限流(HTTP 403)。带上 Actions 自带 token 提到认证额度(脚本已支持 GITHUB_TOKEN)。 + # prepare:pragent calls the GitHub API to list python-build-standalone release assets; anonymous 60/h/IP + # is easily rate-limited (HTTP 403). Pass the Actions-provided token to raise it to the authenticated quota (the script supports GITHUB_TOKEN). GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: 生成 SHA256 校验和 + - name: Generate SHA256 checksums if: startsWith(github.ref, 'refs/tags/') shell: bash working-directory: apps/desktop/release @@ -111,8 +111,8 @@ jobs: fi done - # 安装包 + 校验和上传为 workflow artifact,交末置 release job 单点发布(不在此直传 Release)。 - - name: 上传构建产物 + # Upload installers + checksums as workflow artifacts, leaving the final release job as the single publish point (no direct Release upload here). + - name: Upload build artifacts if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v4 with: @@ -123,9 +123,9 @@ jobs: if-no-files-found: error retention-days: 1 - # meebox CLI(cli/,独立 Go module):纯 Go、无 CGO → 单 runner 交叉编译全平台。出四平台压缩包 + - # 校验和,随桌面安装包挂到同一个 GitHub Release(不打进安装包,是独立可分发物)。同样只上传为 artifact, - # 由末置 release job 单点发布;workflow_dispatch 仍构建做编译冒烟、不上传(无 tag)。 + # meebox CLI (cli/, standalone Go module): pure Go, no CGO → cross-compile all platforms on a single runner. Produces four-platform archives + + # checksums, attached alongside the desktop installers to the same GitHub Release (not bundled into the installer, a separate distributable). Likewise only uploaded as an artifact, + # published by the final release job as the single point; workflow_dispatch still builds as a compile smoke test but does not upload (no tag). cli: name: CLI (${{ matrix.goos }}/${{ matrix.goarch }}) runs-on: ubuntu-latest @@ -145,12 +145,12 @@ jobs: go-version-file: cli/go.mod cache-dependency-path: cli/go.sum - # 显式声明 node:版本号取自 apps/desktop/package.json(`node -p` 读取),不依赖 runner 隐式自带 node。 + # Declare node explicitly: the version is read from apps/desktop/package.json (via `node -p`), not relying on the runner's implicit node. - uses: actions/setup-node@v4 with: node-version: '22' - - name: 交叉编译 meebox CLI + - name: Cross-compile meebox CLI shell: bash working-directory: cli env: @@ -158,20 +158,20 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: '0' run: | - # 版本与 app 同源:取自 apps/desktop/package.json(app 运行期版本的唯一真相源), - # 而非独立依赖 git tag——发布前置已校验 tag == 该版本,故二者一致但只有一个来源。 + # Version shares the app's source: taken from apps/desktop/package.json (the single source of truth for the app's runtime version), + # rather than independently relying on the git tag — release prechecks already verified tag == that version, so they agree but there is only one source. VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" mkdir -p dist go build -trimpath \ -ldflags "-s -w -X github.com/huhamhire/code-meeseeks/cli/cmd.version=${VERSION}" \ -o "dist/meebox${{ matrix.ext }}" . - - name: 打包压缩包 + 校验和 + - name: Package archive + checksum if: startsWith(github.ref, 'refs/tags/') shell: bash working-directory: cli/dist run: | - VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" # 与 app 同源 + VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" # shares the app's source BIN="meebox${{ matrix.ext }}" ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}.${{ matrix.archive }}" # Bundle LICENSE + README + SKILL.md so the archive is a drop-in agent skill @@ -183,13 +183,13 @@ jobs: else tar -czf "${ARCHIVE}" "${FILES[@]}" fi - # 只对本 matrix 实际产出的那个压缩包做校验和。此前遍历 .zip/.tar.gz 两种扩展名:zip 变体 - # (windows/mac)最后一轮 `[ -e X.tar.gz ]` 为假、返回 1,恰是步骤末命令 → 整步以 1 退出而挂; - # linux(.tar.gz 末轮命中)才幸免。直接对已知文件名求 sha256,去掉这个脆弱循环。 + # Checksum only the one archive this matrix actually produced. Previously it iterated both .zip/.tar.gz extensions: for the zip variants + # (windows/mac) the last round `[ -e X.tar.gz ]` was false and returned 1, and being the step's final command → the whole step exited 1 and failed; + # only linux (.tar.gz matched on the last round) escaped. Compute sha256 on the known filename directly, dropping this fragile loop. sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256" - # 压缩包 + 校验和上传为 workflow artifact(glob 仅取 meebox-cli-*,排除 dist 里的裸二进制与随包 LICENSE 等)。 - - name: 上传构建产物 + # Upload archive + checksum as workflow artifacts (glob takes only meebox-cli-*, excluding the bare binary and bundled LICENSE etc. in dist). + - name: Upload build artifacts if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v4 with: @@ -198,44 +198,44 @@ jobs: if-no-files-found: error retention-days: 1 - # 单点发布:等所有构建产物就绪后,一个 job 汇总下载 + 组装正文 + 一次性上传到 Release。避免多个 - # matrix job 并发对同一 tag 创建 / finalize Release 触发 `already_exists` 竞态。仅 tag 触发。 + # Single publish point: once all build artifacts are ready, one job aggregates downloads + assembles the body + uploads to the Release in one shot. Avoids multiple + # matrix jobs concurrently creating / finalizing the Release for the same tag triggering an `already_exists` race. Tag-triggered only. release: needs: [gui, cli] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 # 取 CHANGELOG / RELEASE_NOTES(正文组装用);无需 LFS + - uses: actions/checkout@v4 # fetch CHANGELOG / RELEASE_NOTES (for body assembly); no LFS needed - - name: 下载全部构建产物 + - name: Download all build artifacts uses: actions/download-artifact@v4 with: path: dist - merge-multiple: true # 各 job 的产物合并平铺到 dist/(文件名互不相同、无冲突) + merge-multiple: true # merge each job's artifacts flat into dist/ (filenames are all distinct, no conflicts) - # 组装 Release 正文:把 CHANGELOG 里对应版本的段落注入 RELEASE_NOTES 的占位, - # 让 Release 页直接看到本版变更,而不是只给一个链接。找不到对应段落则回退用原说明。 - - name: 组装 Release 说明 + # Assemble the Release body: inject the CHANGELOG section for the matching version into the placeholder in RELEASE_NOTES, + # so the Release page shows this version's changes directly instead of just a link. If no matching section is found, fall back to the original notes. + - name: Assemble Release notes shell: bash run: | VERSION="${GITHUB_REF_NAME#v}" - # 抽取 `## [VERSION] ...` 到下一个 `## [` 之间的正文(index==1 做字面前缀匹配,避开正则元字符) + # Extract the body between `## [VERSION] ...` and the next `## [` (index==1 does a literal prefix match, avoiding regex metacharacters) awk -v ver="## [$VERSION]" 'index($0,ver)==1{f=1;next} f&&/^## \[/{exit} f{print}' CHANGELOG.md > changelog-section.md if [ -s changelog-section.md ]; then awk 'FNR==NR{a[++n]=$0;next} /%%CHANGELOG_SECTION%%/{for(i=1;i<=n;i++)print a[i];next} {print}' \ changelog-section.md .github/RELEASE_NOTES.md > RELEASE_BODY.md else - echo "::warning::CHANGELOG 未找到 [$VERSION] 段,Release 正文回退用 RELEASE_NOTES.md 原文" + echo "::warning::CHANGELOG has no [$VERSION] section, Release body falls back to the original RELEASE_NOTES.md" sed 's/%%CHANGELOG_SECTION%%//' .github/RELEASE_NOTES.md > RELEASE_BODY.md fi - - name: 上传到 Release(单点) + - name: Upload to Release (single point) uses: softprops/action-gh-release@v2 with: files: dist/* fail_on_unmatched_files: true - # alpha / 任何带 - 的预发布 tag(如 v0.1.0-alpha.1)→ 标为 prerelease,且不抢占 Latest + # alpha / any prerelease tag with a - (e.g. v0.1.0-alpha.1) → marked prerelease, and does not claim Latest prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ !contains(github.ref_name, '-') }} - # 正文 = RELEASE_NOTES(安装 / 首次打开 / 校验和)+ 注入的本版 CHANGELOG 段 + # body = RELEASE_NOTES (install / first open / checksums) + the injected CHANGELOG section for this version body_path: RELEASE_BODY.md diff --git a/.gitignore b/.gitignore index 407d416e..a5641a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,12 +18,12 @@ __pycache__/ # Electron / packaged binaries release/ -# 嵌入式 pr-agent 运行时(scripts/assemble-pragent-runtime.mjs 生成;几百 MB、 -# 平台特定的下载+pip 产物,不入库;见 docs/modules/04-pragent-runtime.md) +# Embedded pr-agent runtime (generated by scripts/assemble-pragent-runtime.mjs; hundreds of MB, +# platform-specific download + pip output, not committed; see docs/arch/02-agent/05-pragent-runtime.md) apps/desktop/vendor/ -# 第三方声明全文(tools/gen-third-party-notices.mjs 生成;近万行许可证全文,不入库—— -# 出包前生成,由 electron-builder 打入安装包 /THIRD-PARTY-NOTICES.md) +# Full third-party notices (generated by tools/gen-third-party-notices.mjs; ~10k lines of full license +# text, not committed — generated before packaging, bundled by electron-builder into the installer at /THIRD-PARTY-NOTICES.md) THIRD-PARTY-NOTICES.md *.exe *.dmg @@ -64,7 +64,7 @@ logs/ coverage/ .nyc_output/ -# Claude Code 本地状态 +# Claude Code local state .claude/ .nx/polygraph diff --git a/apps/desktop/build-resources/after-pack.cjs b/apps/desktop/build-resources/after-pack.cjs index 00e53fdd..262d0ead 100644 --- a/apps/desktop/build-resources/after-pack.cjs +++ b/apps/desktop/build-resources/after-pack.cjs @@ -1,16 +1,16 @@ -// electron-builder afterPack 钩子 —— macOS 免费发布路线的 ad-hoc 签名。 +// electron-builder afterPack hook — ad-hoc signing for the macOS free release route. // -// 背景:Apple Silicon(arm64) 上任何 Mach-O 必须带有效签名才能执行;未签名的 -// 嵌入式 python 解释器 / .dylib / .so 会在 spawn 时直接崩。没有 Apple Developer ID -// 时无法公证,但可以用 ad-hoc 身份(`codesign -s -`)免费签名让二进制能跑。 +// Background: on Apple Silicon(arm64) any Mach-O must carry a valid signature to execute; unsigned +// embedded python interpreter / .dylib / .so crash directly on spawn. Without an Apple Developer ID +// notarization is impossible, but an ad-hoc identity (`codesign -s -`) can sign for free so the binaries run. // -// 行为: -// - 仅在打 macOS 包时动作;win / linux 直接跳过。 -// - 若检测到真实签名凭据(env),跳过 —— 交回 electron-builder 走正式签名 + 公证。 -// - 否则对整个 .app 递归 ad-hoc 签名(含 Contents/Resources/pragent 下的嵌入式 python)。 +// Behavior: +// - Only acts when packaging macOS; win / linux skip directly. +// - If real signing credentials (env) are detected, skip — handing back to electron-builder for proper signing + notarization. +// - Otherwise recursively ad-hoc sign the whole .app (including the embedded python under Contents/Resources/pragent). // -// 注意:ad-hoc 签名只让二进制能运行,不去除 Gatekeeper 警告(仍需用户首次"仍要打开" -// 或走 Homebrew)。见 docs/mac-build.md。 +// Note: ad-hoc signing only lets the binaries run, it does not remove the Gatekeeper warning (users still need "Open anyway" on first launch +// or go via Homebrew). See docs/mac-build.md. const { execFileSync } = require('node:child_process'); const path = require('node:path'); @@ -19,7 +19,7 @@ const path = require('node:path'); exports.default = async function afterPack(context) { if (context.electronPlatformName !== 'darwin') return; - // 有真证书 / 公证凭据时不做 ad-hoc,让 electron-builder 接管正式签名 + 公证 + // With a real certificate / notarization credentials, skip ad-hoc and let electron-builder take over proper signing + notarization const hasRealIdentity = Boolean( process.env.CSC_LINK || process.env.CSC_NAME || @@ -27,18 +27,18 @@ exports.default = async function afterPack(context) { process.env.APPLE_ID, ); if (hasRealIdentity) { - console.log('[after-pack] 检测到 Apple 签名凭据,跳过 ad-hoc(走正式签名 + 公证)'); + console.log('[after-pack] Apple signing credentials detected, skipping ad-hoc (using proper signing + notarization)'); return; } const appName = `${context.packager.appInfo.productFilename}.app`; const appPath = path.join(context.appOutDir, appName); - console.log(`[after-pack] ad-hoc 递归签名(免费路线,不公证): ${appPath}`); + console.log(`[after-pack] ad-hoc recursive signing (free route, not notarized): ${appPath}`); - // --force 覆盖既有签名;--deep 递归签 bundle 内嵌套代码(含嵌入式 python 的 Mach-O)。 - // ad-hoc 身份为 "-"。若个别 .so 仍报签名无效,见 docs/mac-build.md §嵌入式 python 补签。 + // --force overwrites the existing signature; --deep recursively signs nested code inside the bundle (including the embedded python Mach-O). + // The ad-hoc identity is "-". If an individual .so still reports an invalid signature, see docs/mac-build.md §embedded python re-signing. execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], { stdio: 'inherit', }); - console.log('[after-pack] ad-hoc 签名完成'); + console.log('[after-pack] ad-hoc signing done'); }; diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index f9855092..8966069d 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -2,17 +2,17 @@ appId: com.huhamhire.code-meeseeks productName: Code Meeseeks copyright: Copyright © 2026 huhamhire -# npm workspaces hoist 下 electron-builder 无法从 apps/desktop/node_modules -# 推算出 electron 实际版本,必须显式声明(保持与 devDependencies 一致)。 +# Under npm workspaces hoisting, electron-builder cannot infer electron's actual version +# from apps/desktop/node_modules, so it must be declared explicitly (keep in sync with devDependencies). electronVersion: 42.3.0 -# macOS 免费路线:对打出的 .app 做 ad-hoc 递归签名(arm64 必需,含嵌入式 python)。 -# 有 Apple 签名凭据 env 时自动跳过,交回正式签名 + 公证。win/linux 无动作。 +# macOS free route: ad-hoc recursive signing of the built .app (required on arm64, includes embedded python). +# Automatically skipped when Apple signing credentials env is present, handing back to proper signing + notarization. No-op on win/linux. afterPack: build-resources/after-pack.cjs -# 不使用 electron 自动更新;声明 GitHub provider 仅为让 electron-builder 能算出更新通道 -# (computeChannelNames),消除 CI 上「无法从 .git/config 探测仓库」导致的 null 崩溃。 -# 实际发布由 workflow 的 softprops 上传,构建步骤用 --publish never 只生成本地元数据、不上传。 +# Not using electron auto-update; the GitHub provider is declared only so electron-builder can compute the update channel +# (computeChannelNames), eliminating the null crash on CI caused by "cannot detect repository from .git/config". +# Actual release is uploaded by the workflow's softprops; the build step uses --publish never to only generate local metadata, no upload. publish: provider: github owner: huhamhire @@ -20,11 +20,11 @@ publish: directories: output: release - # 指向已入库的 build-resources/:其中的 installer.nsh 会被 electron-builder **自动收录** - # (auto-include `${buildResources}/installer.nsh`,路径无歧义)。原默认 build/ 被 gitignore、未用。 + # Point at the checked-in build-resources/: its installer.nsh is **auto-included** by electron-builder + # (auto-include `${buildResources}/installer.nsh`, path is unambiguous). The original default build/ is gitignored and unused. buildResources: build-resources -# 入口 + 所有 bundle 产物。out/* 是 electron-vite build 的输出。 +# Entry point + all bundle artifacts. out/* is the output of electron-vite build. files: - out/**/* - package.json @@ -33,8 +33,8 @@ files: - '!**/*.test.{ts,tsx,js}' - '!**/*.{md,markdown}' -# Workspace 模式下,第三方运行时依赖被 hoist 到根 node_modules。 -# 让 electron-builder 也从那里抽 prod deps(pino / yaml / zod 等)。 +# In workspace mode, third-party runtime dependencies are hoisted to the root node_modules. +# Let electron-builder pull prod deps (pino / yaml / zod etc.) from there too. nodeGypRebuild: false npmRebuild: false @@ -44,28 +44,28 @@ asarUnpack: - '**/node_modules/pino-roll/**' - '**/node_modules/thread-stream/**' -# 嵌入式 pr-agent 运行时(见 ADR-0008):vendor/pragent → /pragent, -# main 的 resolveEmbeddedPython 打包态走 process.resourcesPath/pragent。extraResources -# 天然落在 asar 外(原生解释器 + .pyd/.dll 必须是真实文件,不能进 asar)。 -# 由构建机宿主平台 prepare:pragent 组装,与所构建的目标平台一致(初版 Windows x64)。 -# __pycache__ 排除以瘦身(首次启动会重新生成 .pyc)。 +# Embedded pr-agent runtime (see ADR-0008): vendor/pragent → /pragent, +# main's resolveEmbeddedPython uses process.resourcesPath/pragent when packaged. extraResources +# naturally land outside the asar (the native interpreter + .pyd/.dll must be real files, cannot go into the asar). +# Assembled by prepare:pragent on the build machine's host platform, matching the target platform being built (initial version Windows x64). +# __pycache__ is excluded to slim down (first launch regenerates .pyc). extraResources: - from: vendor/pragent to: pragent filter: - '**/*' - '!**/__pycache__/**' - # 第三方声明随包内置(落到 /THIRD-PARTY-NOTICES.md);由 tools/gen-third-party-notices.mjs - # 在 electron-builder 之前生成(见 dist/pack 脚本与 release workflow)。仓库不入库该文件。 + # Third-party notices bundled with the package (landing at /THIRD-PARTY-NOTICES.md); generated by tools/gen-third-party-notices.mjs + # before electron-builder (see the dist/pack scripts and release workflow). The repo does not check in this file. - from: ../../THIRD-PARTY-NOTICES.md to: THIRD-PARTY-NOTICES.md - # 启动闪屏 logo:assets 不进 asar / 不随 out 打包,单独 copy 到 /icon.png, - # 供 main 进程 createSplash 运行时读取并 base64 内联到 splash data URL。 + # Startup splash logo: assets do not go into the asar / are not bundled with out, so copied separately to /icon.png, + # for the main process createSplash to read at runtime and inline as base64 into the splash data URL. - from: ../../assets/icons/icon.png to: icon.png win: - # 图标源放在资源目录 assets/icons/(build/ 被 gitignore,不用它)。含 16/32/48/256。 + # Icon source lives in the assets/icons/ resource directory (build/ is gitignored, not used). Contains 16/32/48/256. icon: ../../assets/icons/icon.ico target: - target: nsis @@ -74,17 +74,17 @@ win: artifactName: code-meeseeks-${version}-win-${arch}.${ext} mac: - # mac 专用图标:深色圆角底板 + 留边 glyph(透明异形图标在 macOS 会被系统垫白底)。 - # 由 tools/icons/gen-mac-icon.py 从 icon.png 合成;给 ≥512 PNG,electron-builder 自动转 .icns。 + # mac-specific icon: dark rounded backing + padded glyph (transparent non-square icons get a white backing from the system on macOS). + # Composited from icon.png by tools/icons/gen-mac-icon.py; supply a ≥512 PNG and electron-builder auto-converts to .icns. icon: ../../assets/icons/icon-mac.png gatekeeperAssess: false - # 免费路线(无 Apple Developer ID,不公证):ad-hoc 递归签名由 afterPack 完成 - # (arm64 上 Mach-O 必须签名才能跑,含嵌入式 python)。有真证书 env 时 afterPack - # 自动跳过、交回 electron-builder 走正式签名 + 公证(届时再加 hardenedRuntime / - # entitlements / notarize,见 docs/mac-build.md + build-resources/entitlements.mac.plist) + # Free route (no Apple Developer ID, not notarized): ad-hoc recursive signing done by afterPack + # (on arm64 a Mach-O must be signed to run, includes embedded python). With real certificate env, afterPack + # skips automatically, handing back to electron-builder for proper signing + notarization (add hardenedRuntime / + # entitlements / notarize at that point, see docs/mac-build.md + build-resources/entitlements.mac.plist) target: - target: dmg - # 初版仅 arm64(见 ADR-0008);需要 Intel 时再加 x64 + # Initial version arm64 only (see ADR-0008); add x64 when Intel is needed arch: - arm64 category: public.app-category.developer-tools @@ -101,10 +101,10 @@ linux: nsis: oneClick: false - # per-machine 安装(所有用户 / Program Files)。electron-builder 据此定义 INSTALL_MODE_PER_ALL_USERS - # → 安装器清单 RequestExecutionLevel admin(installer.nsi:20-25)→ 双击即弹 UAC、提权运行, - # 避免 perMachine:false(asInvoker) 在已有 per-machine 安装时"按需提权失败→静默退出→打不开"。 - # 升级也变成单一提权实例,customInit 绕过旧卸载器更稳。安装后的应用本体仍 asInvoker、普通启动。 + # per-machine install (all users / Program Files). electron-builder accordingly defines INSTALL_MODE_PER_ALL_USERS + # → installer manifest RequestExecutionLevel admin (installer.nsi:20-25) → double-click prompts UAC and runs elevated, + # avoiding perMachine:false(asInvoker) "on-demand elevation fails → silent exit → won't open" when a per-machine install already exists. + # Upgrades also become a single elevated instance, and customInit bypassing the old uninstaller is more robust. The installed app itself stays asInvoker, normal launch. perMachine: true allowToChangeInstallationDirectory: true - # 自定义注入见 build-resources/installer.nsh —— 由 buildResources auto-include 自动收录,无需显式 include。 + # For custom injection see build-resources/installer.nsh — auto-included via buildResources auto-include, no explicit include needed. diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index b793a4b9..c1c8571f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -2,8 +2,8 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; -// Workspace 内部包源码是 .ts,Node 无法直接 import;让 Vite 把它们 bundle 进主进程/preload, -// 外部第三方依赖(electron / pino / yaml / zod ...)继续 externalize 让 Node 在运行时解析。 +// Workspace internal packages are .ts source that Node cannot import directly; let Vite bundle them into main/preload, +// while external third-party deps (electron / pino / yaml / zod ...) stay externalized for Node to resolve at runtime. const internalPackages = [ '@meebox/shared', '@meebox/ipc', @@ -40,7 +40,7 @@ export default defineConfig({ }, renderer: { root: resolve('src/renderer'), - // 渲染层引用仓库根 assets/(品牌图标等单一来源,避免拷贝重复二进制) + // Renderer references the repo root assets/ (single source for brand icons etc., avoiding duplicate binary copies) resolve: { alias: { '@assets': resolve('../../assets') }, }, diff --git a/apps/desktop/scripts/assemble-pragent-runtime.mjs b/apps/desktop/scripts/assemble-pragent-runtime.mjs index 7705c51f..ade01ef2 100644 --- a/apps/desktop/scripts/assemble-pragent-runtime.mjs +++ b/apps/desktop/scripts/assemble-pragent-runtime.mjs @@ -1,22 +1,22 @@ -// 组装 pr-agent 嵌入式运行时到 apps/desktop/vendor/pragent/。 +// Assemble the pr-agent embedded runtime into apps/desktop/vendor/pragent/. // -// 流程: -// 1. 读 pragent-runtime.json(pin 的 PBS tag + python 主次版本 + pr-agent 版本) -// 2. 按 tag + 主次版本 + 宿主平台三元组,从 GitHub release 解析 install_only 资产 -// 3. 下载 tar.gz + 其 .sha256 sidecar,校验完整性 -// 4. 清空 vendor/pragent → 解压(得到 vendor/pragent/python/...) -// 5. 用嵌入式解释器 pip install pr-agent==(装进它自己隔离的 site-packages) -// 6. 把 shim(sitecustomize.py 薄加载器 + meebox_pragent_shim 包)拷进 site-packages -// 7. 写 VERSION,做 `import pr_agent` 冒烟 +// Flow: +// 1. Read pragent-runtime.json (pinned PBS tag + python major.minor + pr-agent version) +// 2. Resolve the install_only asset from the GitHub release by tag + major.minor + host platform triple +// 3. Download tar.gz + its .sha256 sidecar, verify integrity +// 4. Clear vendor/pragent → extract (yields vendor/pragent/python/...) +// 5. pip install pr-agent== with the embedded interpreter (into its own isolated site-packages) +// 6. Copy the shim (sitecustomize.py thin loader + meebox_pragent_shim package) into site-packages +// 7. Write VERSION, run an `import pr_agent` smoke test // -// 设计:零系统二进制依赖(不依赖 curl / 系统 tar)。 -// - 网络走 node fetch;通过 undici ProxyAgent honor HTTP(S)_PROXY env(Node 自带 -// fetch 默认不读代理,内网/代理环境连 GitHub CDN 会超时——这里补上)。 -// - 解压走 node-tar(跨平台,不依赖系统 tar)。 -// 幂等:VERSION 与期望一致则跳过(除非 --force)。需要 Node 22+。 +// Design: zero system binary dependencies (no curl / system tar). +// - Networking via node fetch; honor HTTP(S)_PROXY env through undici ProxyAgent (Node's built-in +// fetch does not read the proxy by default, so intranet/proxy environments time out on the GitHub CDN — patched here). +// - Extraction via node-tar (cross-platform, no system tar). +// Idempotent: skip when VERSION matches the expected one (unless --force). Requires Node 22+. // -// 可选 env:GITHUB_TOKEN / GH_TOKEN(避开 API 限流,CI 推荐);HTTP(S)_PROXY / ALL_PROXY -// (自动用于所有请求);MEEBOX_PRAGENT_FORCE=1 等价 --force。 +// Optional env: GITHUB_TOKEN / GH_TOKEN (avoid API rate limiting, recommended in CI); HTTP(S)_PROXY / ALL_PROXY +// (applied to all requests automatically); MEEBOX_PRAGENT_FORCE=1 is equivalent to --force. import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; @@ -34,11 +34,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const APP_DIR = resolve(__dirname, '..'); // apps/desktop const VENDOR_DIR = join(APP_DIR, 'vendor', 'pragent'); const MANIFEST_PATH = join(__dirname, 'pragent-runtime.json'); -// shim 源在 pragent-shim/:薄加载器 sitecustomize.py + 领域拆分包 meebox_pragent_shim/。 +// Shim sources live in pragent-shim/: thin loader sitecustomize.py + domain-split package meebox_pragent_shim/. const SHIM_DIR = join(__dirname, 'pragent-shim'); const SHIM_PKG_NAME = 'meebox_pragent_shim'; const SHIM_LOADER = join(SHIM_DIR, 'sitecustomize.py'); -const SHIM_RUNTIME = join(SHIM_DIR, SHIM_PKG_NAME, 'runtime.py'); // _EXPECTED_PRAGENT_VERSION 所在 +const SHIM_RUNTIME = join(SHIM_DIR, SHIM_PKG_NAME, 'runtime.py'); // where _EXPECTED_PRAGENT_VERSION lives const UA = 'meebox-runtime-assembler'; const FORCE = process.argv.includes('--force') || process.env.MEEBOX_PRAGENT_FORCE === '1'; @@ -52,7 +52,7 @@ function fail(msg) { process.exit(1); } -// 取显式传入的代理:`--proxy ` 或 `--proxy=`。优先级高于环境变量。 +// Get an explicitly passed proxy: `--proxy ` or `--proxy=`. Takes priority over env vars. function getProxyArg() { const i = process.argv.indexOf('--proxy'); if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1]; @@ -60,8 +60,8 @@ function getProxyArg() { return eq ? eq.slice('--proxy='.length) : null; } -// 让 fetch 走代理(Node 22 fetch 无内置代理支持,需 undici ProxyAgent 路由)。 -// 来源优先级:--proxy 入参 > HTTPS_PROXY/HTTP_PROXY/ALL_PROXY env。都没有则直连。 +// Route fetch through a proxy (Node 22 fetch has no built-in proxy support, needs undici ProxyAgent routing). +// Source priority: --proxy arg > HTTPS_PROXY/HTTP_PROXY/ALL_PROXY env. Direct connection if none. function configureProxy() { const proxy = getProxyArg() || @@ -73,11 +73,11 @@ function configureProxy() { process.env.all_proxy; if (proxy) { setGlobalDispatcher(new ProxyAgent(proxy)); - log(`经代理 ${proxy}`); + log(`via proxy ${proxy}`); } } -/** 宿主平台 → PBS 三元组 + 解释器在归档内的相对路径段。 */ +/** Host platform → PBS triple + interpreter's relative path segments inside the archive. */ function hostTarget() { const { platform, arch } = process; if (platform === 'win32' && arch === 'x64') @@ -88,7 +88,7 @@ function hostTarget() { return { triple: 'x86_64-apple-darwin', pythonRel: ['python', 'bin', 'python3'] }; if (platform === 'linux' && arch === 'x64') return { triple: 'x86_64-unknown-linux-gnu', pythonRel: ['python', 'bin', 'python3'] }; - fail(`不支持的宿主平台 ${platform}/${arch}(初版仅 win x64;mac arm64 后续)`); + fail(`unsupported host platform ${platform}/${arch} (initial release win x64 only; mac arm64 later)`); } function ghHeaders() { @@ -107,17 +107,17 @@ function parseNextLink(linkHeader) { return null; } -/** 拉某 release 的全部资产(assets_url 分页,跟随 Link: rel=next)。 */ +/** Fetch all assets of a release (paginate assets_url, follow Link: rel=next). */ async function listReleaseAssets(repo, tag) { const relUrl = `https://api.github.com/repos/${repo}/releases/tags/${tag}`; const relRes = await fetch(relUrl, { headers: ghHeaders() }); - if (!relRes.ok) fail(`拉 release 失败 ${relUrl}: HTTP ${relRes.status}`); + if (!relRes.ok) fail(`failed to fetch release ${relUrl}: HTTP ${relRes.status}`); const release = await relRes.json(); const assets = []; let url = `${release.assets_url}?per_page=100`; while (url) { const res = await fetch(url, { headers: ghHeaders() }); - if (!res.ok) fail(`拉 assets 失败 ${url}: HTTP ${res.status}`); + if (!res.ok) fail(`failed to fetch assets ${url}: HTTP ${res.status}`); assets.push(...(await res.json())); url = parseNextLink(res.headers.get('link')); } @@ -126,13 +126,13 @@ async function listReleaseAssets(repo, tag) { async function downloadToFile(url, outPath) { const res = await fetch(url, { headers: { 'User-Agent': UA } }); - if (!res.ok || !res.body) fail(`下载失败 ${url}: HTTP ${res.status}`); + if (!res.ok || !res.body) fail(`download failed ${url}: HTTP ${res.status}`); await pipeline(Readable.fromWeb(res.body), createWriteStream(outPath)); } async function fetchText(url) { const res = await fetch(url, { headers: { 'User-Agent': UA } }); - if (!res.ok) fail(`拉取失败 ${url}: HTTP ${res.status}`); + if (!res.ok) fail(`fetch failed ${url}: HTTP ${res.status}`); return res.text(); } @@ -146,28 +146,28 @@ function sha256File(path) { }); } -/** 在嵌入式解释器里跑一条命令,失败即 fail(allowFail 时仅返回布尔)。 */ +/** Run one command in the embedded interpreter, fail on failure (return boolean only when allowFail). */ function runPython(pythonExe, args, { allowFail = false } = {}) { const r = spawnSync(pythonExe, args, { stdio: 'inherit' }); if (r.error) { if (allowFail) return false; - fail(`执行失败 ${pythonExe} ${args.join(' ')}: ${r.error.message}`); + fail(`execution failed ${pythonExe} ${args.join(' ')}: ${r.error.message}`); } - if (r.status !== 0 && !allowFail) fail(`非零退出 (${r.status}) ${pythonExe} ${args.join(' ')}`); + if (r.status !== 0 && !allowFail) fail(`non-zero exit (${r.status}) ${pythonExe} ${args.join(' ')}`); return r.status === 0; } function pythonStdout(pythonExe, code) { const r = spawnSync(pythonExe, ['-c', code], { encoding: 'utf8' }); - if (r.status !== 0) fail(`python -c 失败: ${r.stderr || r.error?.message || ''}`); + if (r.status !== 0) fail(`python -c failed: ${r.stderr || r.error?.message || ''}`); return r.stdout.trim(); } /** - * 把 shim 拷进嵌入式解释器的 site-packages(CPython 启动经 site 自动 import sitecustomize): - * 薄加载器 sitecustomize.py + 领域拆分包 meebox_pragent_shim/。很轻量,故即便幂等跳过整体 - * 重建也会重跑一次——本地改了 shim 跑一次 prepare:pragent 就生效,无需 --force 全量重建。 - * 先清旧包目录再整体拷,避免改名/删文件后残留陈旧模块。返回 site-packages 路径。 + * Copy the shim into the embedded interpreter's site-packages (CPython auto-imports sitecustomize via site at startup): + * thin loader sitecustomize.py + domain-split package meebox_pragent_shim/. It is very lightweight, so it reruns even when + * the idempotent path skips the whole rebuild — editing the shim locally then running prepare:pragent once takes effect, no --force full rebuild needed. + * Clear the old package directory before copying the whole thing, to avoid stale modules left behind after renames/deletions. Returns the site-packages path. */ async function syncShim(pythonExe) { const sitePackages = pythonStdout( @@ -185,16 +185,16 @@ async function syncShim(pythonExe) { } /** - * 在组装期就把空 `.secrets.toml` 占位写进 pr_agent/settings(_prod)/,烤进 vendor。 - * pr-agent 启动时找不到该文件会每次打两条 WARNING;我们走 env 传密钥不用 secrets.toml。 + * Write an empty `.secrets.toml` placeholder into pr_agent/settings(_prod)/ at assemble time, baked into vendor. + * pr-agent prints two WARNINGs every time it cannot find this file at startup; we pass secrets via env and do not use secrets.toml. * - * 为何不靠执行期补(原 ipc.ts ensureEmbeddedSecrets):装到 `C:\Program Files\…` - * 这类只读目录时,运行期写 site-packages 会因权限失败 → 占位建不出来 → 告警照旧。 - * 组装期写入则随包分发、运行期只读也无所谓。跟 shim 一样在「跳过重建」快路径也补, - * 重跑 prepare:pragent 即可修好旧 vendor,无需 --force 全量重建。 + * Why not patch at runtime (the former ipc.ts ensureEmbeddedSecrets): when installed into a read-only directory like `C:\Program Files\…`, + * writing site-packages at runtime fails on permissions → the placeholder cannot be created → the warnings persist. + * Writing at assemble time ships with the package, so a read-only runtime does not matter. Like the shim, it is also filled on the "skip rebuild" fast path, + * so rerunning prepare:pragent fixes an old vendor without a --force full rebuild. */ async function ensureSecretsPlaceholders(sitePackages) { - const body = '# meebox 占位空文件:抑制 pr-agent 缺失 .secrets.toml 的启动告警\n'; + const body = '# meebox placeholder empty file: suppress the pr-agent startup warning about a missing .secrets.toml\n'; for (const sub of ['settings', 'settings_prod']) { const dir = join(sitePackages, 'pr_agent', sub); await mkdir(dir, { recursive: true }); @@ -202,11 +202,11 @@ async function ensureSecretsPlaceholders(sitePackages) { } } -// ── 运行时瘦身(B):删运行时不需要的目录/文件,减少安装包小文件数(Windows 升级时 -// 删旧+写新海量小文件极慢、被 Defender 逐个扫,会拖到安装器误判「应用无法关闭」)。 -// 取保守通用集:纯粹运行期用不到、删了不影响 pr-agent / shim。准确性由 smokeTest 兜底。 -// 目录名(任意层级整删):stdlib 测试套件 / 字节码缓存 / GUI(tkinter,turtledemo) / -// 交互式(idlelib) / 历史迁移(lib2to3) / 运行期不用的 pip 引导(ensurepip) / 文档数据(pydoc_data)。 +// ── Runtime slimming (B): delete directories/files not needed at runtime, to reduce the installer's small-file count (on Windows upgrades, +// deleting old + writing new masses of small files is extremely slow and scanned one-by-one by Defender, dragging the installer into a false "app cannot be closed"). +// Take a conservative general set: purely unused at runtime, safe to delete without affecting pr-agent / shim. Accuracy is backstopped by smokeTest. +// Directory names (deleted wholesale at any level): stdlib test suites / bytecode caches / GUI(tkinter,turtledemo) / +// interactive(idlelib) / historical migration(lib2to3) / pip bootstrap not used at runtime(ensurepip) / doc data(pydoc_data). const SLIM_DIR_NAMES = new Set([ '__pycache__', 'test', @@ -218,12 +218,12 @@ const SLIM_DIR_NAMES = new Set([ 'ensurepip', 'pydoc_data', ]); -// 文件扩展名:字节码(随源码运行期可再生) + 类型存根(仅类型检查用)。 +// File extensions: bytecode (regenerable at runtime from source) + type stubs (used only for type checking). const SLIM_FILE_EXTS = new Set(['.pyc', '.pyo', '.pyi']); /** - * 递归瘦身 root:整删 SLIM_DIR_NAMES 目录、删 SLIM_FILE_EXTS 文件。幂等(已删则跳过), - * 故全量构建与快路径都可调。返回删除统计。 + * Recursively slim root: delete SLIM_DIR_NAMES directories wholesale, delete SLIM_FILE_EXTS files. Idempotent (skip if already deleted), + * so callable from both full builds and the fast path. Returns deletion stats. */ async function slimRuntime(root) { let dirsRemoved = 0; @@ -254,27 +254,27 @@ async function slimRuntime(root) { } } await walk(root); - log(`运行时瘦身:删除 ${dirsRemoved} 个目录 + ${filesRemoved} 个文件`); + log(`runtime slimming: removed ${dirsRemoved} directories + ${filesRemoved} files`); } -// 注:曾尝试删未用 provider SDK(botocore/azure/grpc…)瘦身,但 smokeTest 证明不可行—— -// pr-agent 的 git_providers/__init__ **启动即 eager 导入全部 provider**(CodeCommit→boto3、 -// AzureDevOps→azure),删了 `import pr_agent` 直接崩。故这些 SDK 必须保留,不做 provider 裁剪。 +// Note: an attempt to slim by deleting unused provider SDKs (botocore/azure/grpc…) proved infeasible per smokeTest — +// pr-agent's git_providers/__init__ **eagerly imports all providers at startup** (CodeCommit→boto3, +// AzureDevOps→azure), so deleting them crashes `import pr_agent` outright. These SDKs must be kept; no provider trimming. /** - * 构建期冒烟(CI 安全网):用嵌入式解释器端到端验证瘦身后运行时仍完好——pr-agent 可导入、 - * shim 补丁链路在位、pr-agent 实际依赖的 stdlib C 扩展/纯 py 模块都在。任一项失败即 fail() - * 让构建红,**过度裁剪在 CI 直接挡下、不会出包**。 + * Build-time smoke test (CI safety net): use the embedded interpreter to verify end-to-end that the slimmed runtime is still intact — pr-agent imports, + * the shim patch chain is in place, and the stdlib C extensions / pure-py modules pr-agent actually depends on are all present. Any failure calls fail() + * to turn the build red, so **over-trimming is blocked directly in CI and never ships**. */ function smokeTest(pythonExe) { - // (0) 拆分铁律:单独 import meebox_pragent_shim 不应把 pr_agent 拉进 sys.modules(顶层禁 eager - // import pr_agent,否则拖慢每次 python 启动)。fresh 解释器里验。 + // (0) Split rule: importing meebox_pragent_shim alone must not pull pr_agent into sys.modules (no eager + // import pr_agent at the top level, else it slows every python startup). Verified in a fresh interpreter. const lazy = pythonStdout( pythonExe, - 'import sys, meebox_pragent_shim; assert "pr_agent" not in sys.modules, "shim 顶层 eager 加载了 pr_agent"; print("LAZY_OK")', + 'import sys, meebox_pragent_shim; assert "pr_agent" not in sys.modules, "shim eager-loaded pr_agent at the top level"; print("LAZY_OK")', ); - if (!lazy.includes('LAZY_OK')) fail(`冒烟未通过(shim 惰性加载,输出:${lazy.slice(0, 200)})`); - // shim 生效校验:get_pr_labels 被 sitecustomize 的补丁打成返回 [](未打补丁会抛 NotImplementedError)。 + if (!lazy.includes('LAZY_OK')) fail(`smoke test failed (shim lazy loading, output: ${lazy.slice(0, 200)})`); + // Shim effectiveness check: get_pr_labels is patched by sitecustomize to return [] (unpatched it throws NotImplementedError). const code = [ 'import os', "os.environ.setdefault('OPENAI_API_KEY', 'sk-smoke-test')", @@ -282,19 +282,19 @@ function smokeTest(pythonExe) { 'from pr_agent.algo.utils import load_yaml', 'import pr_agent.git_providers.local_git_provider as lgp', 'inst = object.__new__(lgp.LocalGitProvider)', - 'assert lgp.LocalGitProvider.get_pr_labels(inst) == [], "shim get_pr_labels 未生效"', - // pr-agent 实际用到的关键 stdlib(含 C 扩展):删 stdlib / 误删依赖在此暴露。 + 'assert lgp.LocalGitProvider.get_pr_labels(inst) == [], "shim get_pr_labels not in effect"', + // Key stdlib pr-agent actually uses (incl. C extensions): deleting stdlib / accidentally deleting deps surfaces here. 'import ssl, json, asyncio, hashlib, sqlite3, ctypes, lzma, bz2, zlib, decimal, socket, importlib.metadata', - // litellm 实际 completion 路径(mock_response,不走网络)——验证「删 lazy provider SDK」后核心 - // 评审链路不破:若误删了 litellm 共享路径需要的依赖,这里会 ImportError 失败。 + // litellm's real completion path (mock_response, no network) — verifies the core review chain is not broken after + // "deleting lazy provider SDKs": if a dependency needed by litellm's shared path was deleted, this fails with ImportError. 'import litellm', "r = litellm.completion(model='gpt-3.5-turbo', messages=[{'role':'user','content':'hi'}], mock_response='MEEBOX_PONG')", - "assert 'MEEBOX_PONG' in str(r), 'litellm mock completion 异常'", + "assert 'MEEBOX_PONG' in str(r), 'litellm mock completion failed'", 'print("MEEBOX_SMOKE_OK")', ].join('\n'); const out = pythonStdout(pythonExe, code); - if (!out.includes('MEEBOX_SMOKE_OK')) fail(`冒烟未通过(输出:${out.slice(0, 300)})`); - log('冒烟 OK:pr_agent 可导入 + shim 补丁生效 + 关键 stdlib + litellm completion 路径完好'); + if (!out.includes('MEEBOX_SMOKE_OK')) fail(`smoke test failed (output: ${out.slice(0, 300)})`); + log('smoke test OK: pr_agent importable + shim patch active + key stdlib + litellm completion path intact'); } async function main() { @@ -305,76 +305,76 @@ async function main() { const { triple, pythonRel } = hostTarget(); const pythonExe = join(VENDOR_DIR, ...pythonRel); - // 守卫:shim 的 monkeypatch 依赖 pr-agent 特定版本的内部实现,runtime.py 里用 - // _EXPECTED_PRAGENT_VERSION 做运行期版本守卫。这里在构建期强制它与 manifest pin 的版本 - // 一致——升级 pr-agent 时必须同步两处 + 重新验证 patch,否则直接 fail 不让出包。 + // Guard: the shim's monkeypatch depends on a specific pr-agent version's internals, and runtime.py uses + // _EXPECTED_PRAGENT_VERSION as a runtime version guard. Here we enforce at build time that it matches the manifest-pinned version — + // upgrading pr-agent must sync both places + re-verify the patch, otherwise fail outright and block shipping. const shimSrc = await readFile(SHIM_RUNTIME, 'utf8'); const shimVer = /_EXPECTED_PRAGENT_VERSION\s*=\s*["']([^"']+)["']/.exec(shimSrc)?.[1]; - if (!shimVer) fail(`meebox_pragent_shim/runtime.py 未找到 _EXPECTED_PRAGENT_VERSION 常量`); + if (!shimVer) fail(`meebox_pragent_shim/runtime.py: _EXPECTED_PRAGENT_VERSION constant not found`); if (shimVer !== prAgentVersion) fail( - `shim 版本(${shimVer}) ≠ manifest pr-agent(${prAgentVersion});升级 pr-agent 时同步 ` + - `runtime.py 的 _EXPECTED_PRAGENT_VERSION 并重新验证 monkeypatch`, + `shim version(${shimVer}) ≠ manifest pr-agent(${prAgentVersion}); when upgrading pr-agent, sync ` + + `runtime.py's _EXPECTED_PRAGENT_VERSION and re-verify the monkeypatch`, ); const versionKey = `pbs:${tag} py:${mm} triple:${triple} pr-agent:${prAgentVersion}`; const versionFile = join(VENDOR_DIR, 'VERSION'); - // 幂等:VERSION 命中且解释器在位 → 跳过 + // Idempotent: VERSION hit and interpreter present → skip if (!FORCE && existsSync(versionFile) && existsSync(pythonExe)) { const prev = JSON.parse(await readFile(versionFile, 'utf8')); if (prev.key === versionKey) { - // 整体跳过,但始终重新同步 shim + 补 .secrets.toml 占位:本地改了 sitecustomize.py - // 或修了占位逻辑后跑一次 prepare:pragent 即生效,无需 --force 全量重建(重下 - // CPython + 重装 pr-agent)。 + // Skip overall, but always re-sync the shim + fill the .secrets.toml placeholder: editing sitecustomize.py locally + // or fixing the placeholder logic takes effect after running prepare:pragent once, no --force full rebuild needed (re-download + // CPython + reinstall pr-agent). const sp = await syncShim(pythonExe); await ensureSecretsPlaceholders(sp); - // 瘦身幂等:已删则跳过,故快路径也跑——已组装的旧 vendor 跑一次 prepare:pragent 即变瘦。 + // Slimming is idempotent: skip if already deleted, so the fast path runs it too — an already-assembled old vendor slims after one prepare:pragent. await slimRuntime(VENDOR_DIR); smokeTest(pythonExe); - log(`已就绪,跳过重建(${versionKey});已重新同步 shim + 瘦身 + 冒烟 → ${sp}。--force 可强制全量重建。`); + log(`ready, skipping rebuild (${versionKey}); re-synced shim + slimmed + smoke tested → ${sp}. Use --force to force a full rebuild.`); return; } - log(`VERSION 不匹配(旧: ${prev.key}),重建。`); + log(`VERSION mismatch (old: ${prev.key}), rebuilding.`); } - // 1+2. 解析资产 - log(`解析 release ${repo}@${tag} 的 ${triple} ${variant} (py ${mm}) 资产…`); + // 1+2. Resolve the asset + log(`resolving ${triple} ${variant} (py ${mm}) asset from release ${repo}@${tag}…`); const assets = await listReleaseAssets(repo, tag); const mmEsc = mm.replace(/\./g, '\\.'); const assetRe = new RegExp(`^cpython-${mmEsc}\\.\\d+\\+${tag}-${triple}-${variant}\\.tar\\.gz$`); const asset = assets.find((a) => assetRe.test(a.name)); - if (!asset) fail(`release ${tag} 里找不到匹配 ${assetRe} 的资产`); + if (!asset) fail(`no asset matching ${assetRe} found in release ${tag}`); const shaAsset = assets.find((a) => a.name === `${asset.name}.sha256`); - log(`命中资产 ${asset.name}`); + log(`matched asset ${asset.name}`); - // 3. 下载到临时文件 + 校验 + // 3. Download to a temp file + verify const tarPath = join(tmpdir(), `meebox-${asset.name}`); - log('下载归档…'); + log('downloading archive…'); await downloadToFile(asset.browser_download_url, tarPath); const actualSha = await sha256File(tarPath); if (shaAsset) { const sidecar = await fetchText(shaAsset.browser_download_url); const expected = (sidecar.trim().match(/[a-f0-9]{64}/i) ?? [])[0]?.toLowerCase(); - if (!expected) fail(`sidecar 里没解析出 sha256: ${sidecar.slice(0, 80)}`); - if (expected !== actualSha) fail(`sha256 不匹配!期望 ${expected} 实际 ${actualSha}`); - log(`sha256 校验通过 (${actualSha.slice(0, 12)}…)`); + if (!expected) fail(`no sha256 parsed from sidecar: ${sidecar.slice(0, 80)}`); + if (expected !== actualSha) fail(`sha256 mismatch! expected ${expected} actual ${actualSha}`); + log(`sha256 verified (${actualSha.slice(0, 12)}…)`); } else { - log(`WARN: 无 .sha256 sidecar,仅记录实际值 ${actualSha.slice(0, 12)}…(未校验)`); + log(`WARN: no .sha256 sidecar, recording actual value only ${actualSha.slice(0, 12)}… (unverified)`); } - // 4. 清空 + 解压(node-tar,不依赖系统 tar) - log(`清空 ${VENDOR_DIR} 并解压…`); + // 4. Clear + extract (node-tar, no system tar dependency) + log(`clearing ${VENDOR_DIR} and extracting…`); await rm(VENDOR_DIR, { recursive: true, force: true }); await mkdir(VENDOR_DIR, { recursive: true }); await tarExtract({ file: tarPath, cwd: VENDOR_DIR }); await rm(tarPath, { force: true }); - if (!existsSync(pythonExe)) fail(`解压后找不到解释器 ${pythonExe}`); + if (!existsSync(pythonExe)) fail(`interpreter not found after extraction ${pythonExe}`); - // 5. pip install pr-agent(装进嵌入式解释器自己的 site-packages) - log('确保 pip…'); + // 5. pip install pr-agent (into the embedded interpreter's own site-packages) + log('ensuring pip…'); runPython(pythonExe, ['-m', 'ensurepip', '--upgrade'], { allowFail: true }); - log(`pip install pr-agent==${prAgentVersion}(依赖较多,耗时数分钟)…`); + log(`pip install pr-agent==${prAgentVersion} (many dependencies, takes several minutes)…`); runPython(pythonExe, [ '-m', 'pip', @@ -384,21 +384,21 @@ async function main() { `pr-agent==${prAgentVersion}`, ]); - // 6. 注入 shim(薄加载器 sitecustomize.py + meebox_pragent_shim 包) + // 6. Inject the shim (thin loader sitecustomize.py + meebox_pragent_shim package) const sitePackages = await syncShim(pythonExe); - log(`已注入 shim(sitecustomize.py + meebox_pragent_shim/)→ ${sitePackages}`); - // 7. 组装期补空 .secrets.toml 占位,烤进 vendor(只读安装目录运行期也无需再写) + log(`injected shim (sitecustomize.py + meebox_pragent_shim/) → ${sitePackages}`); + // 7. Fill the empty .secrets.toml placeholder at assemble time, baked into vendor (no runtime write needed even in a read-only install dir) await ensureSecretsPlaceholders(sitePackages); - log('已写入 pr_agent/settings(_prod)/.secrets.toml 空占位'); + log('wrote empty pr_agent/settings(_prod)/.secrets.toml placeholder'); - // 7. 瘦身(B)+ 冒烟(CI 安全网)+ 写 VERSION + // 7. Slim (B) + smoke test (CI safety net) + write VERSION await slimRuntime(VENDOR_DIR); smokeTest(pythonExe); await writeFile( versionFile, `${JSON.stringify({ key: versionKey, asset: asset.name, sha256: actualSha, builtOn: `${process.platform}/${process.arch}` }, null, 2)}\n`, ); - log(`完成 → ${VENDOR_DIR}`); + log(`done → ${VENDOR_DIR}`); } main().catch((e) => fail(e instanceof Error ? (e.stack ?? e.message) : String(e))); diff --git a/apps/desktop/scripts/pragent-shim/.gitignore b/apps/desktop/scripts/pragent-shim/.gitignore index a58874e7..2f4d5320 100644 --- a/apps/desktop/scripts/pragent-shim/.gitignore +++ b/apps/desktop/scripts/pragent-shim/.gitignore @@ -1,3 +1,3 @@ -# Python 编译产物(运行/冒烟时生成,不入库) +# Python bytecode (generated at run / smoke time, not committed) __pycache__/ *.pyc diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py index a0220dc9..d3e86794 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py @@ -1,11 +1,13 @@ -"""meebox 嵌入式运行时 monkeypatch shim(按领域拆分)。 +"""meebox embedded runtime monkeypatch shim (split by domain). -入口 apply() 由薄 sitecustomize.py 调用,注册全部惰性 post-import hook:仅当目标 pr_agent -模块真正被 import(= 真实 run)时才打补丁。**绝不在此 eager import pr_agent**——本包的所有 -模块对 pr_agent 的 import 都在 patch 函数体内(惰性),故 import 本包不会触发 pr_agent 加载。 +Entry point apply() is called by the thin sitecustomize.py, registering all lazy post-import hooks: +patches are only applied when the target pr_agent module is actually imported (= a real run). +**Never eager import pr_agent here** -- every module in this package imports pr_agent inside the +patch function body (lazily), so importing this package does not trigger pr_agent load. -所有对 pr-agent 行为的改造集中在本包,上游源码保持原封。每个补丁用 try/except 包裹(见 -runtime._register_post_import),打不上则静默降级,绝不让 shim 异常阻断流程。 +All modifications to pr-agent behavior are concentrated in this package; upstream source stays +untouched. Each patch is wrapped in try/except (see runtime._register_post_import): if it can't be +applied it silently degrades, never letting a shim exception block the flow. """ from .patches.describe_assessment import patch as _patch_describe_assessment from .patches.litellm_handler import patch as _patch_litellm_handler @@ -15,23 +17,24 @@ def apply() -> None: - # local_git_provider 两个补丁合并在一个 patch_fn 里(同模块注册多个 finder 会互相遮蔽, - # 只有 meta_path[0] 那个生效):二进制安全 get_diff_files + get_line_link anchor。 + # local_git_provider two patches merged into one patch_fn (registering multiple finders for the + # same module shadows each other; only the meta_path[0] one takes effect): binary-safe + # get_diff_files + get_line_link anchor. _register_post_import( "pr_agent.git_providers.local_git_provider", _patch_local_git_provider, ) - # litellm handler:CLI 模式分发 + Anthropic 去 temperature + 包 _get_completion 采集 token usage。 + # litellm handler: CLI mode dispatch + Anthropic temperature removal + wrap _get_completion to collect token usage. _register_post_import( "pr_agent.algo.ai_handlers.litellm_ai_handler", _patch_litellm_handler, ) - # load_yaml 健壮化:解析失败时剥 anchor marker / 重排多行块标量后重试,避免 review 崩。 + # load_yaml hardening: on parse failure, strip anchor marker / rearrange multi-line block scalars and retry, to avoid review crashing. _register_post_import( "pr_agent.algo.utils", _patch_load_yaml, ) - # /describe 思路建议:往 describe prompt 注入 assessment 字段,产出「替代方案 + 倾向性建议」段。 + # /describe approach suggestion: inject an assessment field into the describe prompt, producing an "alternatives + opinionated recommendation" section. _register_post_import( "pr_agent.tools.pr_description", _patch_describe_assessment, diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py index 934ada6c..39ac4fa7 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py @@ -1,20 +1,21 @@ -"""编排器「独立 LLM 对话通道」的运行入口(见 docs/arch/02-agent/01-agent.md §3 + packages/pr-agent-bridge)。 +"""Run entry for the orchestrator's "standalone LLM chat channel" (see docs/arch/02-agent/01-agent.md §3 + packages/pr-agent-bridge). -由嵌入式运行时以 `python -m meebox_pragent_shim.chat` 启动,按 provider 分两条路: +Launched by the embedded runtime as `python -m meebox_pragent_shim.chat`, splitting into two paths by provider: - - **CLI 模式**(MEEBOX_CLI_MODE 置位,本机 claude / codex):直接调 `cli.run_cli_chat`,**不 import - pr_agent / litellm**。CLI 路径的真实调用本就绕过 litellm(见 cli/install.py),此处避免每次 chat 子进程 - 为拿一个用不到的 LiteLLMAIHandler 而白白付整套 pr_agent + litellm import 开销——编排自有步骤(路由 / - judge / summary)每流程调多次,累计可观。 + - **CLI mode** (MEEBOX_CLI_MODE set, local claude / codex): calls `cli.run_cli_chat` directly, **without importing + pr_agent / litellm**. The CLI path's real calls already bypass litellm (see cli/install.py); this avoids each chat subprocess + paying the full pr_agent + litellm import cost just to obtain an unused LiteLLMAIHandler -- orchestration has its own steps (routing / + judge / summary) invoked multiple times per flow, which adds up considerably. - - **API 模式**(anthropic / openai / deepseek …):litellm 即 HTTP 客户端、无法绕开,复用 pr-agent - **已被本 shim 补丁**的 `LiteLLMAIHandler.chat_completion`——provider 路由、Anthropic 去 temperature、 - 提示缓存、token usage 哨兵全部继承,无需在此重复实现。 + - **API mode** (anthropic / openai / deepseek ...): litellm is the HTTP client and cannot be bypassed, so reuse pr-agent's + `LiteLLMAIHandler.chat_completion` **already patched by this shim** -- provider routing, Anthropic temperature removal, + prompt caching, and the token usage sentinel are all inherited, no need to reimplement here. -约定:stdin 收一段 JSON `{"system": ..., "user": ..., "temperature"?: ..., "max_output_tokens"?: ...}`, -回复正文写 stdout,token 用量经 `@@MEEBOX_USAGE@@` 哨兵打到 stderr(主进程与 pr-agent run 同一套累加, -见 ipc.ts)。max_output_tokens 封顶输出(轻量路由判读用),经 env 中转给 litellm_handler 补丁注入 litellm -max_tokens——仅嵌入式 litellm 路径生效,CLI provider 忽略(其算力档由 MEEBOX_CLI_REASONING 控制)。 +Convention: stdin receives a JSON blob `{"system": ..., "user": ..., "temperature"?: ..., "max_output_tokens"?: ...}`, +the reply body is written to stdout, and token usage is emitted to stderr via the `@@MEEBOX_USAGE@@` sentinel (the main process uses +the same accumulation as a pr-agent run, see ipc.ts). max_output_tokens caps output (for lightweight routing decisions), relayed via env +to the litellm_handler patch which injects litellm max_tokens -- only effective on the embedded litellm path; CLI providers ignore it +(their reasoning tier is controlled by MEEBOX_CLI_REASONING). """ import asyncio import json @@ -36,22 +37,22 @@ def _read_payload() -> dict: async def _run(payload: dict) -> str: - # CLI 模式短路:直接调本机 CLI,绕过 litellm,且不 import pr_agent——省去整套 import 开销。 - # model / temperature / max_output_tokens 在 CLI 路径用不到(命令与算力档由 spec + MEEBOX_CLI_* - # env 决定),忽略即可。 + # CLI mode short-circuit: call the local CLI directly, bypass litellm, and don't import pr_agent -- saving the full import cost. + # model / temperature / max_output_tokens are unused on the CLI path (command and reasoning tier are decided by spec + MEEBOX_CLI_* + # env), so just ignore them. if os.environ.get("MEEBOX_CLI_MODE"): from .cli.install import run_cli_chat bin_name = (os.environ.get("MEEBOX_CLI_BIN") or "claude").strip() or "claude" return await run_cli_chat(bin_name, payload["system"], payload["user"]) - # 输出封顶:每次 chat 独立子进程,故置环境变量即「本次调用」级别——litellm_handler 补丁里 - # 的 _get_completion 包装读它注入 litellm max_tokens(见 patches/litellm_handler)。 + # Output cap: each chat is its own subprocess, so setting the env var is "this call" scoped -- the _get_completion + # wrapper in the litellm_handler patch reads it to inject litellm max_tokens (see patches/litellm_handler). mot = payload.get("max_output_tokens") if isinstance(mot, int) and mot > 0: os.environ["MEEBOX_CHAT_MAX_TOKENS"] = str(mot) - # 惰性 import:触发 shim 注册的 post-import 补丁(_get_completion usage 包装 / 提示缓存 / 去 temperature)。 + # Lazy import: triggers the post-import patches registered by the shim (_get_completion usage wrap / prompt caching / temperature removal). from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.config_loader import get_settings @@ -61,7 +62,7 @@ async def _run(payload: dict) -> str: if payload["temperature"] is not None: kwargs["temperature"] = payload["temperature"] result = await handler.chat_completion(**kwargs) - # chat_completion 返回 (resp_text, finish_reason) + # chat_completion returns (resp_text, finish_reason) if isinstance(result, tuple): return result[0] or "" return result or "" diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py index ffc3a0db..de101cd4 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py @@ -1,2 +1,2 @@ -"""本机 CLI provider:把评审请求转交本机已安装并授权的命令行工具代为调用模型,绕过 litellm。 -各命令的差异(argv / 输出解析 / 需剥离的计费 env)集中在 specs.py,按命令名取用。""" +"""Local CLI provider: delegate review requests to a locally installed and authorized command-line tool to invoke the model on our behalf, bypassing litellm. +Per-command differences (argv / output parsing / billing env to strip) are centralized in specs.py, looked up by command name.""" diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py index 613011af..8d4c3e09 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py @@ -1,11 +1,11 @@ -"""CLI 模式:调本机 CLI 子进程跑一轮对话,完全绕过 litellm / 直连 API。 - -两个入口共用同一份子进程逻辑 `run_cli_chat`: - - `_install_cli_chat_completion`:把 LiteLLMAIHandler.chat_completion 整体替换为调 CLI 的版本, - 由 patches.litellm_handler 在 MEEBOX_CLI_MODE 置位时调用——服务 **pr-agent 工具 run**(/describe - /review /ask 经 `python -m pr_agent.cli`,其内部 LLM 调用必经 chat_completion)。 - - `run_cli_chat`:编排 **chat 通道**(`python -m meebox_pragent_shim.chat`)在 CLI 模式下直接调它, - 无需 import pr_agent / litellm(CLI 路径根本不用 litellm),省去每次 chat 子进程的整套 import 开销。 +"""CLI mode: invoke a local CLI subprocess to run one round of conversation, fully bypassing litellm / the direct API. + +Both entry points share the same subprocess logic `run_cli_chat`: + - `_install_cli_chat_completion`: replaces LiteLLMAIHandler.chat_completion wholesale with a CLI-invoking version, + called by patches.litellm_handler when MEEBOX_CLI_MODE is set — serves the **pr-agent tool run** (/describe + /review /ask via `python -m pr_agent.cli`, whose internal LLM calls all go through chat_completion). + - `run_cli_chat`: the orchestration **chat channel** (`python -m meebox_pragent_shim.chat`) calls it directly in CLI mode, + with no need to import pr_agent / litellm (the CLI path does not use litellm at all), saving the full import overhead of every chat subprocess. """ import os import sys @@ -16,8 +16,8 @@ def _resolve_cli_exe(bin_name): - """用 shutil.which 解析命令真实路径。Windows 据 PATHEXT 命中 .cmd/.bat(不能被 CreateProcess - 直接拉起,须经 cmd /c)。返回 (exe_path_or_None, needs_cmd_wrapper)。""" + """Resolve the command's real path via shutil.which. On Windows, PATHEXT may match .cmd/.bat (which cannot be + launched directly by CreateProcess and must go through cmd /c). Returns (exe_path_or_None, needs_cmd_wrapper).""" import shutil exe = shutil.which(bin_name) @@ -28,21 +28,21 @@ def _resolve_cli_exe(bin_name): async def run_cli_chat(bin_name, system, user) -> str: - """调本机 CLI 子进程跑一轮 system+user 对话,返回回复正文(usage 经哨兵打 stderr)。 - - pr-agent 只依赖 chat_completion 返回 (text, finish_reason) 这个稳定契约(base_ai_handler 定义), - 故 CLI 接管与 pr-agent 具体版本无关,**不受版本守卫限制**(区别于依赖内部实现的其它 patch)。本函数 - 自包含、不 import pr_agent / litellm,编排 chat 通道在 CLI 模式可直接调用以省去整套 import 开销。 - - 各命令差异(argv flags / 输出解析 / 需剥离的计费 env)集中在 _CLI_SPECS,按命令名取用: - - prompt 经 **stdin** 喂入:review prompt 含完整 diff(数十 KB),走 argv 会撞命令行长度上限; - system / user 拼成一段(CLI 单轮无独立 system 槽)。 - - cwd 默认落到中性临时目录:避免吃到被评审仓库的上下文(CLAUDE.md / AGENTS.md 等)污染输出。 - 例外:主进程仅对 /ask 经 MEEBOX_CLI_WORKDIR 下发(已净化的)worktree 路径,让自由问答能读到 - 完整文件;describe/review 不下发该 env、维持中性临时目录。净化在主进程侧做(清空仓库自带指令文件)。 - - 子进程继承父 env(PATH / HOME / 代理变量),故能找到命令、复用其登录态、出站自动走代理。 - - **凭据隔离**:剥掉对应计费 key(claude: ANTHROPIC_*;codex: OPENAI_API_KEY / CODEX_API_KEY), - 让 CLI 使用其自身登录会话,而非环境里残留的 API key。模型与额度由该 CLI 账户与用户授权决定。 + """Invoke a local CLI subprocess to run one round of system+user conversation, returning the reply body (usage is emitted to stderr via a sentinel). + + pr-agent only depends on the stable contract that chat_completion returns (text, finish_reason) (defined by base_ai_handler), + so the CLI takeover is independent of the specific pr-agent version and **not subject to the version guard** (unlike other patches that depend on internal implementation). This function + is self-contained and does not import pr_agent / litellm; the orchestration chat channel can call it directly in CLI mode to save the full import overhead. + + Per-command differences (argv flags / output parsing / billing env to strip) are centralized in _CLI_SPECS, looked up by command name: + - The prompt is fed via **stdin**: the review prompt contains the full diff (tens of KB), and passing it via argv would hit the command-line length limit; + system / user are concatenated into one segment (a single CLI round has no separate system slot). + - cwd defaults to a neutral temp directory: to avoid picking up context from the repo under review (CLAUDE.md / AGENTS.md etc.) that would pollute the output. + Exception: the main process only passes a (sanitized) worktree path via MEEBOX_CLI_WORKDIR for /ask, so free-form Q&A can read + the full files; describe/review do not pass this env and keep the neutral temp directory. Sanitization is done on the main-process side (clearing the repo's own instruction files). + - The subprocess inherits the parent env (PATH / HOME / proxy variables), so it can find the command, reuse its login state, and route outbound traffic through the proxy automatically. + - **Credential isolation**: strip the corresponding billing key (claude: ANTHROPIC_*; codex: OPENAI_API_KEY / CODEX_API_KEY), + so the CLI uses its own login session rather than an API key lingering in the environment. The model and quota are determined by that CLI account and the user's authorization. """ import asyncio import tempfile @@ -51,18 +51,18 @@ async def run_cli_chat(bin_name, system, user) -> str: spec = _CLI_SPECS.get(name) if spec is None: raise RuntimeError( - f"不支持的本地 CLI 命令 '{bin_name}'(当前已适配 claude / codex)。" + f"unsupported local CLI command '{bin_name}' (currently adapted: claude / codex)." ) exe, needs_cmd = _resolve_cli_exe(bin_name) - # 命令前缀(cmd 包装 + exe);exe 解析失败为 None。 + # Command prefix (cmd wrapper + exe); None if exe resolution failed. cmd_prefix = (["cmd", "/c", exe] if needs_cmd else [exe]) if exe else None if cmd_prefix is None: raise RuntimeError( - f"找不到本地 CLI 命令 '{bin_name}':请确认已安装、已登录,且 '{bin_name}' 在 PATH 中。" + f"local CLI command '{bin_name}' not found: please confirm it is installed, logged in, and that '{bin_name}' is on PATH." ) - # 低算力档:仅 Agent 编排通道经 MEEBOX_CLI_REASONING=low/minimal 开启;把 low_effort_flags - # 插到尾部 `-`(stdin 占位)之前、保持 `-` 在末位;无尾部 `-` 则直接追加。 + # Low-effort tier: only the Agent orchestration channel enables it via MEEBOX_CLI_REASONING=low/minimal; insert low_effort_flags + # before the trailing `-` (stdin placeholder), keeping `-` last; if there is no trailing `-`, just append. flags = list(spec["flags"]) if os.environ.get("MEEBOX_CLI_REASONING", "").strip().lower() in ("low", "minimal"): extra = list(spec.get("low_effort_flags") or []) @@ -70,11 +70,11 @@ async def run_cli_chat(bin_name, system, user) -> str: flags = flags[:-1] + extra + ["-"] if flags and flags[-1] == "-" else flags + extra argv = cmd_prefix + flags - # CLI 单轮无独立 system 槽:system+user 拼一段。先剥除缓存断点标记(仅 Anthropic litellm 路径用于 - # 分块缓存;CLI 不缓存、标记不得进入 prompt)。 + # A single CLI round has no separate system slot: concatenate system+user into one segment. First strip cache-break markers (used only by the Anthropic litellm path for + # chunked caching; the CLI does not cache, and the markers must not enter the prompt). system = strip_cache_break(system) if system else system prompt = f"{system}\n\n\n{user}" if system else user - # 基于 os.environ 拷贝再剔除计费 key——其余(PATH/HOME/代理变量等)原样保留。 + # Copy from os.environ then remove the billing keys — everything else (PATH/HOME/proxy variables, etc.) is kept as-is. child_env = {k: v for k, v in os.environ.items() if k not in spec["strip_env"]} try: proc = await asyncio.create_subprocess_exec( @@ -86,20 +86,20 @@ async def run_cli_chat(bin_name, system, user) -> str: env=child_env, ) except Exception as exc: # noqa: BLE001 - raise RuntimeError(f"启动 CLI '{bin_name}' 失败: {exc}") from exc + raise RuntimeError(f"failed to start CLI '{bin_name}': {exc}") from exc out, err = await proc.communicate(prompt.encode("utf-8")) if proc.returncode != 0: raise RuntimeError( - f"CLI '{bin_name}' 退出码 {proc.returncode}: " + f"CLI '{bin_name}' exit code {proc.returncode}: " f"{(err or b'').decode('utf-8', 'replace')[:500]}" ) text, usage = spec["parser"]((out or b"").decode("utf-8", "replace")) if usage: - # prompt_tokens ≈ 输入侧总规模,output_tokens ≈ completion(input/output_tokens 两家同名)。 - # 缓存字段两家约定不同: - # - Anthropic(claude):input_tokens **不含**缓存,cache_read/创建需累加进总量; - # cache_read 用 cache_read_input_tokens。 - # - OpenAI(codex):input_tokens **已含**缓存,cached_input_tokens 仅作命中量、不再计入总量。 + # prompt_tokens ≈ total input-side size, output_tokens ≈ completion (input/output_tokens share the same names across both). + # The cache fields differ in convention between the two: + # - Anthropic(claude): input_tokens **excludes** cache, so cache_read/creation must be added into the total; + # cache_read uses cache_read_input_tokens. + # - OpenAI(codex): input_tokens **already includes** cache, and cached_input_tokens is only the hit count, not counted into the total again. prompt_tokens = usage.get("input_tokens") for k in ("cache_read_input_tokens", "cache_creation_input_tokens"): v = usage.get(k) @@ -107,7 +107,7 @@ async def run_cli_chat(bin_name, system, user) -> str: prompt_tokens = (prompt_tokens or 0) + v cache_read = usage.get("cache_read_input_tokens") if not isinstance(cache_read, int): - cache_read = usage.get("cached_input_tokens") # codex/OpenAI 风格 + cache_read = usage.get("cached_input_tokens") # codex/OpenAI style turns = usage.get("num_turns") _emit_usage_tokens( prompt_tokens, @@ -119,9 +119,9 @@ async def run_cli_chat(bin_name, system, user) -> str: def _install_cli_chat_completion(handler_cls, bin_name) -> None: - """把 chat_completion 换成调本机 CLI 子进程的版本(委托 run_cli_chat),服务 pr-agent 工具 run。 - chat_completion 的 model / temperature / img_path 在 CLI 路径里用不到(命令与算力档由 spec + env 决定), - 仅为满足 base_ai_handler 的方法签名而保留。""" + """Replace chat_completion with a version that invokes a local CLI subprocess (delegating to run_cli_chat), serving the pr-agent tool run. + chat_completion's model / temperature / img_path are unused on the CLI path (the command and effort tier are determined by spec + env); + they are kept only to satisfy base_ai_handler's method signature.""" async def chat_completion(self, model, system, user, temperature=0.2, img_path=None): text = await run_cli_chat(bin_name, system, user) diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py index 13d89d12..5dc7b27c 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py @@ -1,15 +1,15 @@ -"""本机 CLI 命令输出解析:把各 CLI 的 stdout 解析成 (text, usage_dict_or_None)。 -usage 统一用 input_tokens / output_tokens 字段名(两家恰好一致),供 install 的 token 采集复用。""" +"""Local CLI command output parsing: parse each CLI's stdout into (text, usage_dict_or_None). +usage uniformly uses the input_tokens / output_tokens field names (which happen to match across both), reused by install's token collection.""" def _parse_claude_output(stdout): - """解析 `claude -p --output-format json` 的 stdout,返回 (text, usage_dict_or_None)。 - 成功形如 {"result": "...", "num_turns": N, "usage": {"input_tokens":..,"output_tokens":.., - "cache_read_input_tokens":..}, "is_error": false}。非 JSON / 缺字段退化为「整段 stdout 当文本、 - usage=None」;仅 is_error=True 时抛错。 + """Parse the stdout of `claude -p --output-format json`, returning (text, usage_dict_or_None). + On success it looks like {"result": "...", "num_turns": N, "usage": {"input_tokens":..,"output_tokens":.., + "cache_read_input_tokens":..}, "is_error": false}. Non-JSON / missing fields degrade to "the whole stdout as text, + usage=None"; only raises when is_error=True. - claude -p 是 agentic 多轮:顶层 num_turns 为本次会话内部的模型轮次(可远大于 1),把它并入 - usage dict 的 num_turns 字段一并上抛(usage 同字段名供采集层统一读取,见 install.py)。""" + claude -p is agentic multi-turn: the top-level num_turns is the number of model turns within this session (which can be far greater than 1); merge it into + the usage dict's num_turns field and surface it together (usage uses the same field name for the collection layer to read uniformly, see install.py).""" import json s = (stdout or "").strip() @@ -17,12 +17,12 @@ def _parse_claude_output(stdout): return "", None try: obj = json.loads(s) - except Exception: # noqa: BLE001 - 非 JSON → 原样当文本 + except Exception: # noqa: BLE001 - non-JSON → treat as text as-is return s, None if not isinstance(obj, dict): return s, None if obj.get("is_error"): - raise RuntimeError(f"claude CLI 返回错误: {str(obj.get('result') or obj)[:500]}") + raise RuntimeError(f"claude CLI returned an error: {str(obj.get('result') or obj)[:500]}") text = obj.get("result") if not isinstance(text, str): text = s @@ -36,11 +36,11 @@ def _parse_claude_output(stdout): def _parse_codex_output(stdout): - """解析 `codex exec --json` 的 JSONL 事件流,返回 (text, usage_dict_or_None): - - type==item.completed 且 item.type==agent_message → item.text 为模型回复,取最后一条; - - type==turn.completed → usage {input_tokens, output_tokens} 为 token,并计一轮。 - turn.completed 出现次数作模型轮次 num_turns(并入 usage dict,与 claude 路径同字段名)。 - 逐行容错:非 JSON 行跳过、事件缺字段不致命;text 缺失退到空串(让上层 load_yaml 兜底)。""" + """Parse the JSONL event stream of `codex exec --json`, returning (text, usage_dict_or_None): + - type==item.completed and item.type==agent_message → item.text is the model reply, take the last one; + - type==turn.completed → usage {input_tokens, output_tokens} are the tokens, and count one turn. + The number of turn.completed occurrences serves as the model turn count num_turns (merged into the usage dict, same field name as the claude path). + Line-by-line tolerant: non-JSON lines are skipped, missing event fields are not fatal; missing text falls back to an empty string (let the upper-layer load_yaml handle the fallback).""" import json text = None @@ -52,7 +52,7 @@ def _parse_codex_output(stdout): continue try: ev = json.loads(line) - except Exception: # noqa: BLE001 - 非 JSON 行(日志等)跳过 + except Exception: # noqa: BLE001 - skip non-JSON lines (logs, etc.) continue if not isinstance(ev, dict): continue @@ -62,7 +62,7 @@ def _parse_codex_output(stdout): if isinstance(item, dict) and item.get("type") == "agent_message": txt = item.get("text") if isinstance(txt, str): - text = txt # 取最后一条 agent_message 作最终回复 + text = txt # take the last agent_message as the final reply elif etype == "turn.completed": turns += 1 u = ev.get("usage") diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py index d178c1ec..8145fd61 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py @@ -1,28 +1,28 @@ -"""已适配的本机 CLI 命令规格表:argv flags(prompt 一律走 stdin)+ 输出解析器 + 需剥离的 -计费 env。新增命令在此登记一套即可;renderer 侧白名单校验须同步(见 LlmProfileForm.validateProfile)。""" +"""Spec table for adapted local CLI commands: argv flags (the prompt always goes via stdin) + output parser + billing env to strip. +Registering one entry here is enough for a new command; the renderer-side whitelist validation must stay in sync (see LlmProfileForm.validateProfile).""" from .parsers import _parse_claude_output, _parse_codex_output -# `low_effort_flags`:低算力档要追加的 argv(仅 Agent 编排通道经 MEEBOX_CLI_REASONING 开启, -# 见 install.py)。含尾部 `-`(stdin)的命令会把这些 flags 插到 `-` 之前,保持 `-` 在末位。 +# `low_effort_flags`: argv to append for the low-effort tier (only enabled by the Agent orchestration channel via MEEBOX_CLI_REASONING, +# see install.py). Commands with a trailing `-` (stdin) insert these flags before the `-`, keeping `-` last. _CLI_SPECS = { - # claude:-p 单轮非交互 + JSON(一段含结果与 usage);默认不传 --model,用本机默认模型/登录态。 - # 低算力档:--model haiku(最快最省,适合编排通道的路由 / 判读 / 收尾 / 对话),与 /review 走默认 - # 模型形成差异化;haiku 别名自动解析到当前账户可用的最新 haiku。 + # claude: -p single-round non-interactive + JSON (one segment containing the result and usage); by default does not pass --model, using the local default model / login state. + # Low-effort tier: --model haiku (fastest and cheapest, suited to the orchestration channel's routing / interpretation / wrap-up / conversation), differentiated from /review which uses the default + # model; the haiku alias automatically resolves to the latest haiku available to the current account. "claude": { "flags": ["-p", "--output-format", "json"], "low_effort_flags": ["--model", "haiku"], "parser": _parse_claude_output, "strip_env": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"), }, - # codex:exec 非交互 + --json(JSONL 事件流);末位 `-` 让 stdin 作完整 prompt; - # --skip-git-repo-check 容许临时目录运行,--sandbox read-only 只读不改文件。 - # 默认禁用 web_search / image_gen:评审与编排在只读临时目录里跑,这两个工具用不到, - # 关掉既收敛工具面、又省 ~3K tokens(工具定义不再随每次请求下发)。键值: - # web_search 是字符串枚举(disabled / cached / live),用 `-c web_search=disabled`; - # image_gen 是 feature flag,用 `-c features.image_generation=false`(等价 --disable image_generation)。 - # 低算力档:-c model_reasoning_effort=low(codex 默认推理较重,编排通道无需,调低提速)。 - # 不用 minimal:gpt-5.x-codex 不支持 minimal(仅 none/low/medium/high/xhigh,传 minimal 报 400), - # 且 minimal 还与 web_search / image_gen 互斥;low 普遍受支持、与工具兼容,作低算力档更稳。 + # codex: exec non-interactive + --json (JSONL event stream); the trailing `-` makes stdin the full prompt; + # --skip-git-repo-check allows running in a temp directory, --sandbox read-only is read-only and does not modify files. + # Disable web_search / image_gen by default: review and orchestration run in a read-only temp directory where these two tools are unused, + # turning them off both narrows the tool surface and saves ~3K tokens (tool definitions are no longer sent with each request). Keys: + # web_search is a string enum (disabled / cached / live), use `-c web_search=disabled`; + # image_gen is a feature flag, use `-c features.image_generation=false` (equivalent to --disable image_generation). + # Low-effort tier: -c model_reasoning_effort=low (codex reasons heavily by default, the orchestration channel does not need it, lowering it speeds things up). + # Not using minimal: gpt-5.x-codex does not support minimal (only none/low/medium/high/xhigh, passing minimal returns a 400), + # and minimal is also mutually exclusive with web_search / image_gen; low is widely supported and tool-compatible, making it more reliable for the low-effort tier. "codex": { "flags": [ "exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py index 0b8edc00..318cb08d 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py @@ -1,2 +1,3 @@ -"""对 pr-agent 的无侵入 monkeypatch,按目标模块分文件。每个模块导出一个 `patch(module)`, -由包根 apply() 经惰性 post-import hook 注册。pr_agent 的 import 一律在 patch 函数体内(惰性)。""" +"""Non-invasive monkeypatch for pr-agent, split into one file per target module. Each module exports a +`patch(module)`, registered by the package root apply() via a lazy post-import hook. All pr_agent imports live +inside the patch function body (lazy).""" diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py index 6cdcee0b..72771e17 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py @@ -1,17 +1,19 @@ -"""describe 增强(受版本守卫):往 /describe 的 prompt schema 注入一个 assessment 字段, -让社区版 /describe 产出「思路建议(替代实现方案 + 倾向性建议)」段——对齐 Qodo Merge 的 -High-Level Assessment(社区版原生无此字段)。 +"""describe enhancement (version-guarded): inject an assessment field into the /describe prompt schema, +making the community-edition /describe produce a "thinking suggestions (alternative implementation approaches + +opinionated recommendation)" section — mirroring Qodo Merge's High-Level Assessment (the community edition has +no such field natively). -无需改渲染:pr_description._prepare_pr_answer 对未知 key 走通用 `### **Key**` 分支, -assessment 会自动渲染成 `### **Assessment**` 段进 description.md,由 app 的 parse-output -按表头映射为 sectionKey='assessment'。 +No rendering change needed: pr_description._prepare_pr_answer routes unknown keys through the generic +`### **Key**` branch, so assessment auto-renders into a `### **Assessment**` section in description.md, mapped by +the app's parse-output to sectionKey='assessment' by header. -注入方式:运行期改写 get_settings().pr_description_prompt.system(dynaconf set)。锚点是 -schema 里 title 字段尾 + 示例输出 title 之后;锚点缺失则跳过(版本漂移安全降级)。 +Injection method: rewrite get_settings().pr_description_prompt.system at runtime (dynaconf set). The anchors are +the end of the title field in the schema + right after the title in the example output; if an anchor is missing, +skip (safe degradation on version drift). """ from ..runtime import _EXPECTED_PRAGENT_VERSION, _debug, _pragent_version -# 紧跟 PRDescription schema 的 title 字段之后插入(锚定 title 字段行尾的唯一子串) +# Insert right after the title field of the PRDescription schema (anchored to a unique substring at the end of the title field line) _SCHEMA_ANCHOR = 'that captures the PR\'s main theme")' _SCHEMA_FIELD = ( '\n assessment: str = Field(description="A high-level assessment in GFM markdown, mirroring ' @@ -26,7 +28,7 @@ 'Be objective and specific to this PR. Leave empty for trivial changes.")' ) -# 紧跟示例输出的 title 之后插入(best-effort,缺失不致命) +# Insert right after the title in the example output (best-effort; missing is not fatal) _EXAMPLE_ANCHOR = "title: |\n ...\n" _EXAMPLE_FIELD = "assessment: |\n ...\n" @@ -42,12 +44,12 @@ def patch(module) -> None: settings = get_settings() prompt = settings.pr_description_prompt.system except Exception as exc: # noqa: BLE001 - _debug(f"describe assessment: 读取 prompt 失败(跳过): {exc}") + _debug(f"describe assessment: failed to read prompt (skipped): {exc}") return if not isinstance(prompt, str) or "assessment:" in prompt or "assessment: str" in prompt: - return # 已注入 / 形态异常 → 幂等跳过 + return # already injected / abnormal shape → idempotent skip if _SCHEMA_ANCHOR not in prompt: - _debug("describe assessment: schema 锚点未命中(版本漂移),跳过") + _debug("describe assessment: schema anchor not matched (version drift), skipping") return new = prompt.replace(_SCHEMA_ANCHOR, _SCHEMA_ANCHOR + _SCHEMA_FIELD, 1) if _EXAMPLE_ANCHOR in new: @@ -55,6 +57,6 @@ def patch(module) -> None: try: settings.set("pr_description_prompt.system", new) except Exception as exc: # noqa: BLE001 - _debug(f"describe assessment: 写回 prompt 失败(跳过): {exc}") + _debug(f"describe assessment: failed to write back prompt (skipped): {exc}") return _debug("describe assessment field injected into /describe prompt") diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py index 7d057015..6f318036 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py @@ -1,8 +1,8 @@ -"""litellm handler 补丁(合并在一个 patch_fn,同模块注册多 finder 会互相遮蔽): - (0) CLI 模式(MEEBOX_CLI_MODE 置位):换 chat_completion 直接调本机 CLI,绕过 litellm,随后 - return。该分支在版本守卫之前,不受 pr-agent 版本限制。 - (1) Anthropic 新型号去 temperature(仅 pin 版本)。 - (2) 包 _get_completion inline 采集真实 token usage(仅 pin 版本)。 +"""litellm handler patch (merged into one patch_fn; registering multiple finders on the same module would shadow each other): + (0) CLI mode (MEEBOX_CLI_MODE set): swap chat_completion to call the local CLI directly, bypassing litellm, then + return. This branch runs before the version guard and is not bound to the pr-agent version. + (1) Drop temperature for new Anthropic models (pinned version only). + (2) Wrap _get_completion to inline-collect real token usage (pinned version only). """ import os @@ -15,15 +15,15 @@ ) from ..usage import _emit_usage -# Anthropic 提示缓存最小可缓存粒度约 1k token;稳定前缀低于此不标缓存(含极小的判读 system)。 +# Anthropic prompt caching has a minimum cacheable granularity of ~1k tokens; a stable prefix below this is not marked for caching (including a tiny classification system prompt). _CACHE_MIN_CHARS = 4000 -# 全局稳定前缀用 1h 扩展 TTL:跨所有 PR/运行在 1h 内命中,写入 2× 由大量命中摊薄;需带 beta 头。 +# Use the 1h extended TTL for the global stable prefix: hits across all PRs/runs within 1h, the 2x write cost amortized by many hits; requires the beta header. _CACHE_TTL = "1h" _CACHE_BETA_FLAG = "extended-cache-ttl-2025-04-11" def _add_cache_beta_header(kwargs: dict) -> None: - """合入 1h 缓存所需 anthropic-beta 头(不覆盖既有标志)。""" + """Merge in the anthropic-beta header required for 1h caching (without overwriting existing flags).""" headers = kwargs.get("extra_headers") if not isinstance(headers, dict): headers = {} @@ -36,18 +36,18 @@ def _add_cache_beta_header(kwargs: dict) -> None: def _apply_system_prompt_cache(kwargs: dict) -> None: - """为 Anthropic 给 system 的稳定前缀标 cache_control(1h 扩展 TTL);并在任何情况下剥除 CACHE_BREAK 标记。 - Anthropic 提示缓存按**前缀**、**服务端**生效(不依赖暖会话),跨所有 PR/运行在 1h 内命中(写入 2× 由命中摊薄)。 - 覆盖两类 Anthropic 调用: - - 1) 编排 chat 通道(MEEBOX_CHAT_CACHE 置位、system 含 assembleSystemContext 插入的 CACHE_BREAK):按断点把 - 全局稳定前缀(SOUL/AGENTS/工具/记忆/用户)单独标缓存、PR/运行相关尾部保持纯文本。 - 2) pr-agent 工具 run(/review /describe /improve /ask,**无** CACHE_BREAK):system 即 pr-agent 的指令 + - 输出格式(约 12k 字符,仅随配置/语言/规则变、跨 PR 稳定;可变的 diff 在 user 侧),整段标缓存 → 同配置下 - 跨运行 1h 内命中。 - - 非 Anthropic(OpenAI/DeepSeek 等):自带自动前缀缓存、无需显式标,仅剥除 CACHE_BREAK 标记拼回纯文本;前缀 - 过小(< _CACHE_MIN_CHARS,如精简判读 system)也不标。 + """Mark cache_control (1h extended TTL) on the stable prefix given to Anthropic as system; and strip the CACHE_BREAK marker in all cases. + Anthropic prompt caching works by **prefix**, **server-side** (no warm session needed), hitting across all PRs/runs within 1h (2x write amortized by hits). + Covers two kinds of Anthropic calls: + + 1) Orchestrated chat channel (MEEBOX_CHAT_CACHE set, system contains the CACHE_BREAK inserted by assembleSystemContext): split at the breakpoint, + marking the global stable prefix (SOUL/AGENTS/tools/memory/user) for caching on its own, keeping the PR/run-related tail as plain text. + 2) pr-agent tool run (/review /describe /improve /ask, **no** CACHE_BREAK): system is pr-agent's instructions + + output format (~12k chars, varies only with config/language/rules, stable across PRs; the variable diff is on the user side), mark the whole thing for caching → under the same config, + hits across runs within 1h. + + Non-Anthropic (OpenAI/DeepSeek etc.): come with automatic prefix caching, no explicit marking needed, just strip the CACHE_BREAK marker and stitch back to plain text; a prefix + too small (< _CACHE_MIN_CHARS, e.g. a slim classification system prompt) is not marked either. """ msgs = kwargs.get("messages") if not isinstance(msgs, list): @@ -59,7 +59,7 @@ def _apply_system_prompt_cache(kwargs: dict) -> None: continue stable, variable = split_cache_break(m["content"]) if stable is not None: - # 含 CACHE_BREAK(编排 chat):稳定前缀标缓存、尾部纯文本 + # has CACHE_BREAK (orchestrated chat): mark stable prefix for caching, tail plain text if chat_cache_on and is_anthropic and len(stable) >= _CACHE_MIN_CHARS: m["content"] = [ { @@ -71,10 +71,10 @@ def _apply_system_prompt_cache(kwargs: dict) -> None: ] _add_cache_beta_header(kwargs) else: - # 非 anthropic / 未开缓存 / 前缀过小:去标记拼回纯文本(自动前缀缓存仍可命中)。 + # non-anthropic / caching off / prefix too small: strip marker and stitch back to plain text (automatic prefix caching can still hit). m["content"] = f"{stable}\n\n---\n\n{variable}" return - # 无 CACHE_BREAK(pr-agent 工具 run):Anthropic 把整段稳定 system 标缓存(diff 在 user 侧、不进缓存)。 + # no CACHE_BREAK (pr-agent tool run): Anthropic marks the whole stable system for caching (diff is on the user side, not cached). if is_anthropic and len(m["content"]) >= _CACHE_MIN_CHARS: m["content"] = [ { @@ -88,31 +88,31 @@ def _apply_system_prompt_cache(kwargs: dict) -> None: def patch(module) -> None: - """(1) 新版 Anthropic 原厂模型(claude-opus-4-8 等)弃用 temperature 参数,但 pr-agent 默认 - 仍发 temperature=0.2 → Anthropic API 直接报 "temperature is deprecated for this model", - review/describe 全失败。 - - pr-agent 只对 NO_SUPPORT_TEMPERATURE_MODELS 里**精确命中**的型号不发 temperature,该列表 - 硬编码且只列了 OpenAI o系列/gpt-5 等,不含任何新 Claude(上游更新滞后)。custom_reasoning_model - 虽也能去 temperature 但会把 system 并进 user(劣化 Claude 的 system prompt),不用。 - - 这里把模块全局 NO_SUPPORT_TEMPERATURE_MODELS 换成"额外认所有 anthropic/* 前缀模型"的智能 - 容器:凡走 anthropic 原厂的模型一律不发 temperature。LiteLLMAIHandler.__init__ 里 - `self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS` 取的是模块全局名,故重绑 - 全局即对之后创建的 handler 生效;只动成员判定、不碰 system/user 合并。""" - # 抑制 litellm 往 **stdout** 打的「Provider List: …」等装饰性提示(ANSI 红字)。编排 chat 通道以子进程 - # stdout 作模型回复:litellm 在 cost/token 计量里对未进本地 model_cost 表的新模型(如 claude-opus-4-8) - # 调 get_llm_provider 失败时会先 print 该提示再抛错(错误被上游吞掉、不影响最终结果),但 print 已污染 - # stdout、漏进评审总结。置 suppress_debug_info=True 关掉这些 print(真实 usage 由我们自己的 hook 采集, - # 不依赖这些输出)。全局生效、与 pr-agent 版本无关,故放在版本守卫与 CLI 分支之前。 + """(1) New first-party Anthropic models (claude-opus-4-8 etc.) deprecate the temperature parameter, but pr-agent + by default still sends temperature=0.2 → the Anthropic API directly reports "temperature is deprecated for this model", + failing all review/describe. + + pr-agent only omits temperature for models that **exactly match** an entry in NO_SUPPORT_TEMPERATURE_MODELS, a list that is + hardcoded and lists only OpenAI o-series/gpt-5 etc., without any new Claude (upstream updates lag). custom_reasoning_model + could also drop temperature but merges system into user (degrading Claude's system prompt), so it's not used. + + Here we replace the module-global NO_SUPPORT_TEMPERATURE_MODELS with a smart container that "additionally recognizes all + anthropic/* prefixed models": any model going through first-party anthropic never sends temperature. In LiteLLMAIHandler.__init__, + `self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS` reads the module-global name, so rebinding + the global takes effect for handlers created afterward; only the membership test is touched, not the system/user merge.""" + # Suppress the decorative hints (ANSI red text) like "Provider List: …" that litellm prints to **stdout**. The orchestrated chat channel uses the subprocess + # stdout as the model reply: in cost/token accounting, when litellm calls get_llm_provider and fails for a new model not in the local model_cost table (e.g. claude-opus-4-8), + # it first prints that hint then raises (the error is swallowed upstream, not affecting the final result), but the print has already polluted + # stdout and leaks into the review summary. Set suppress_debug_info=True to turn off these prints (real usage is collected by our own hook, + # not dependent on this output). Globally effective, independent of the pr-agent version, so placed before the version guard and CLI branch. try: import litellm litellm.suppress_debug_info = True - except Exception: # noqa: BLE001 - litellm 未就绪等,纯装饰性抑制失败不致命 + except Exception: # noqa: BLE001 - litellm not ready etc.; failure of purely decorative suppression is not fatal pass - # (0) CLI 模式:换 chat_completion 直接调本机 CLI,绕过 litellm。放在版本守卫之前, - # 因为它只依赖 base_ai_handler 的稳定契约,跟 pr-agent 内部实现无关。装好即 return。 + # (0) CLI mode: swap chat_completion to call the local CLI directly, bypassing litellm. Placed before the version guard, + # because it only depends on the stable contract of base_ai_handler, unrelated to pr-agent internals. Return once installed. if os.environ.get("MEEBOX_CLI_MODE"): handler_cls = getattr(module, "LiteLLMAIHandler", None) if handler_cls is not None: @@ -122,7 +122,7 @@ def patch(module) -> None: installed = _pragent_version() if installed != _EXPECTED_PRAGENT_VERSION: - # 版本不符:local_git_provider 补丁已 _warn 过总体降级,这里静默跳过避免重复噪音 + # version mismatch: the local_git_provider patch has already _warn'd about the overall degradation, so silently skip here to avoid duplicate noise _debug( f"skip no-temperature patch: pr-agent {installed} != {_EXPECTED_PRAGENT_VERSION}" ) @@ -134,23 +134,23 @@ class _NoTempModels(list): def __contains__(self, model) -> bool: if list.__contains__(self, model): return True - # 我们的 normalizeModel 给 anthropic provider 一律补 anthropic/ 前缀 + # our normalizeModel always prepends the anthropic/ prefix for the anthropic provider return (model or "").lower().startswith("anthropic/") module.NO_SUPPORT_TEMPERATURE_MODELS = _NoTempModels(orig) - # (2) 真实 token usage 采集:包 LiteLLMAIHandler._get_completion,从其返回的 - # (content, finish_reason, response) 里取 response.usage,inline 打哨兵到 stderr。 - # 不用 litellm 的 callback —— 那是后台 logging worker 异步触发,CLI 退出过快会丢; - # 这里 inline 在 pr-agent 的 await 链里,必在进程退出前执行,可靠。 + # (2) Real token usage collection: wrap LiteLLMAIHandler._get_completion, take response.usage from its returned + # (content, finish_reason, response), and inline-print a sentinel to stderr. + # Don't use litellm's callback —— that fires asynchronously on a background logging worker, and is lost if the CLI exits too fast; + # here it's inline in pr-agent's await chain, guaranteed to run before process exit, reliable. handler_cls = getattr(module, "LiteLLMAIHandler", None) if handler_cls is not None and hasattr(handler_cls, "_get_completion"): _orig_get_completion = handler_cls._get_completion async def _get_completion_with_usage(self, **kwargs): - # 输出封顶(编排器 chat 通道经 MEEBOX_CHAT_MAX_TOKENS 设;pr-agent 工具 run 的 env 不含 - # 该项,故 /describe /review 不受限)。"thinking" 在场(Claude 扩展思考)时不覆盖其 max_tokens - # (否则会低于 thinking budget 报错);已有 max_tokens 也不覆盖。 + # Output cap (set by the orchestrator chat channel via MEEBOX_CHAT_MAX_TOKENS; the pr-agent tool run's env does not include + # this, so /describe /review are uncapped). When "thinking" is present (Claude extended thinking), don't override its max_tokens + # (otherwise it would fall below the thinking budget and error); also don't override an already-set max_tokens. mt = os.environ.get("MEEBOX_CHAT_MAX_TOKENS") if mt and "thinking" not in kwargs and "max_tokens" not in kwargs: try: diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py index 47c59786..cd5322c9 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py @@ -1,16 +1,16 @@ -"""load_yaml 健壮化补丁(受版本守卫):解析失败时剥 anchor marker / 重排多行块标量后重试, -避免模型偶发破格 YAML 让整个 /review 崩。""" +"""load_yaml robustness patch (version-guarded): on parse failure, strip the anchor marker / reflow multi-line block scalars and retry, +to keep an occasional malformed YAML from the model from crashing the whole /review.""" from ..runtime import _EXPECTED_PRAGENT_VERSION, _debug, _pragent_version def _reflow_unindented_multiline(text): - """修复模型最常见的破 YAML 写法:把多行自由文本值(中文 issue_content / issue_header 等) - 写成「key: 内联首行」+ 续行**顶格(第 1 列)**,没有用块标量 `|`。YAML 把续行当成新 key → - `could not find expected ':'`,pr-agent 自带 fallback 也救不回(其首个 fallback 只对固定 key - 列表补 `|`,且不重排续行缩进)。 + """Fix the model's most common malformed-YAML pattern: a multi-line free-text value (Chinese issue_content / issue_header etc.) + written as "key: inline first line" + continuation lines **flush left (column 1)**, without a block scalar `|`. YAML treats the + continuation as a new key → `could not find expected ':'`, and pr-agent's own fallback can't recover it (its first fallback only + appends `|` for a fixed key list, and doesn't reflow continuation indentation). - 这里把「key: 内联值 + 紧随其后的非 key / 非 list-item 行」整体重排成 `key: |-` 块标量并统一 - 缩进,使值完整保留为多行字符串。仅在原始解析失败后调用;任何不匹配都原样保留(无回归)。""" + Here we reflow "key: inline value + the immediately following non-key / non-list-item lines" as a whole into a `key: |-` block scalar + with uniform indentation, preserving the value intact as a multi-line string. Called only after the original parse fails; any non-match is kept as-is (no regression).""" import re key_re = re.compile(r"^(\s*)(-\s+)?([A-Za-z_][A-Za-z0-9_ ]*):(.*)$") @@ -26,7 +26,7 @@ def _reflow_unindented_multiline(text): continue indent, dash, key, rest = m.group(1), (m.group(2) or ""), m.group(3), m.group(4) rest_s = rest.strip() - # 收集续行:直到遇到下一个 key 行 / list-item / 文件结束 + # Collect continuation lines: until the next key line / list-item / end of file j = i + 1 cont = [] while j < n: @@ -37,7 +37,7 @@ def _reflow_unindented_multiline(text): j += 1 is_block = rest_s in ("|", "|-", "|2", ">", ">-") or rest_s.endswith(("|", "|-")) if any(c.strip() for c in cont) and not is_block: - ci = " " * (len(indent) + len(dash) + 2) # 块标量内容须比 key 列更深 + ci = " " * (len(indent) + len(dash) + 2) # block scalar content must be deeper than the key column out.append(f"{indent}{dash}{key}: |-") if rest_s: out.append(ci + rest_s) @@ -51,18 +51,18 @@ def _reflow_unindented_multiline(text): def patch(module) -> None: - """pr-agent 把 LLM 输出按 YAML 解析(pr_agent.algo.utils.load_yaml)。模型偶发产出破格 YAML → - safe_load + pr-agent 自带 fallback 全失败 → load_yaml 返回 None → pr_reviewer 迭代 None 崩 - (argument of type 'NoneType' is not iterable),整个 review 失败。两类高频破格: - 1. 我们注入的 anchor `[file: ...]` marker 独占一行落在 mapping 上下文,`[` 被当 flow 序列起始; - 2. 多行自由文本值(中文 issue_content 等)续行顶格、未用块标量 `|`(见 _reflow_unindented_multiline)。 + """pr-agent parses the LLM output as YAML (pr_agent.algo.utils.load_yaml). The model occasionally produces malformed YAML → + safe_load + pr-agent's own fallback all fail → load_yaml returns None → pr_reviewer iterating None crashes + (argument of type 'NoneType' is not iterable), failing the whole review. Two high-frequency malformations: + 1. our injected anchor `[file: ...]` marker occupies a line on its own in a mapping context, and `[` is taken as the start of a flow sequence; + 2. a multi-line free-text value (Chinese issue_content etc.) with continuation lines flush left, without a block scalar `|` (see _reflow_unindented_multiline). - 包一层 load_yaml:原逻辑成功就原样返回(不影响正常路径 + 行号兜底);失败时依次尝试 - 「剥 marker」「重排多行块标量」「两者叠加」,每个候选都交回原 load_yaml(含其自身 try_fix_yaml)。 - 全部失败才返回 None。inline marker / 合法 YAML 不受影响。 + Wrap load_yaml: if the original logic succeeds, return as-is (doesn't affect the normal path + line-number fallback); on failure, try in order + "strip marker", "reflow multi-line block scalar", "both combined", handing each candidate back to the original load_yaml (including its own try_fix_yaml). + Only return None if all fail. Inline markers / valid YAML are unaffected. - pr_reviewer 用 `from pr_agent.algo.utils import load_yaml`,utils 先于 tools 被 import,本 patch - 在 utils exec 完后替换 module.load_yaml,故 tools 后续 import 拿到的是包装版。""" + pr_reviewer uses `from pr_agent.algo.utils import load_yaml`; utils is imported before tools, and this patch + replaces module.load_yaml after utils finishes exec, so tools' later import gets the wrapped version.""" installed = _pragent_version() if installed != _EXPECTED_PRAGENT_VERSION: _debug(f"skip load_yaml patch: pr-agent {installed} != {_EXPECTED_PRAGENT_VERSION}") @@ -70,27 +70,27 @@ def patch(module) -> None: import re as _re - # loguru 全局单例(pr_agent.log 用的同一个)。用于在「首探+修复」阶段临时压制 - # pr_agent.algo.utils 的失败日志;import 失败则降级为不压制(不致命)。 + # loguru global singleton (the same one pr_agent.log uses). Used to temporarily suppress + # pr_agent.algo.utils's failure logs during the "first probe + repair" phase; if import fails, degrade to no suppression (not fatal). try: from loguru import logger as _loguru_logger except Exception: # noqa: BLE001 _loguru_logger = None orig_load_yaml = module.load_yaml - # 整行仅为 `[file: ...]`(含缩进/path-only 形式),吃掉行尾换行 + # whole line is only `[file: ...]` (including indented / path-only forms), consuming the trailing newline marker_line_re = _re.compile(r"(?m)^[ \t]*\[file:[^\]\n]*\][ \t]*$\n?") - # 激进兜底:`[file:` 起、吃到 `]` 或行尾(闭合 `]` 可选),出现在**任意位置**都抹掉。 - # 统一覆盖:独占整行、行内(`issue text [file:...]`)、值位、以及模型截断漏 `]` 的未闭合 - # marker。marker_line_re(仅独占整行的闭合形式)漏掉的破格全归它收。 + # aggressive fallback: from `[file:`, consuming up to `]` or line end (closing `]` optional), removed wherever it appears **anywhere**. + # Uniformly covers: whole-line-only, inline (`issue text [file:...]`), value position, and unclosed + # markers where the model truncated and dropped `]`. Malformations missed by marker_line_re (only the whole-line-only closed form) all fall to it. marker_any_re = _re.compile(r"\[file:[^\]\n]*\]?") def _repair_candidates(response_text): - """生成修复候选,按代价从小到大;每个交回原 load_yaml(其内部还会再跑 try_fix_yaml)。 - marker 仅是行号兜底(anchor 主源是 get_line_link 的 meebox:/// 链接),recovery 路径剥掉 - 它不影响主锚点,优先保证整个 /review 不因一条破格 marker 整体失败。""" + """Generate repair candidates, from cheapest to most costly; each handed back to the original load_yaml (which internally reruns try_fix_yaml). + The marker is only a line-number fallback (the anchor's main source is the meebox:/// link from get_line_link); the recovery path stripping + it doesn't affect the main anchor, prioritizing keeping the whole /review from failing entirely over one malformed marker.""" stripped = marker_line_re.sub("", response_text) - # 更激进:任意位置 / 未闭合 marker 全清(marker_line_re 只清独占整行的闭合形式) + # more aggressive: clear markers anywhere / unclosed (marker_line_re only clears the whole-line-only closed form) aggressive = marker_any_re.sub("", response_text) attempts = [] if stripped != response_text: @@ -110,10 +110,10 @@ def _repair_candidates(response_text): return attempts def load_yaml(response_text, *args, **kwargs): - # 「首探 + 修复」阶段静默:orig 对带 marker 的原文必然解析失败并打 WARNING+ERROR,但这些 - # 破格我们随后能修复,那两条日志是误导噪音(让用户以为 /review 挂了)。故临时压制 - # pr_agent.algo.utils 的日志;仅当所有修复都失败,才放**原始报错**出来(真失败应可见)。 - # 本 shim 跑在单次 review 的独立 python 子进程、无并发,disable/enable 全局开关安全。 + # "first probe + repair" phase silence: orig will inevitably fail to parse the original text with a marker and log WARNING+ERROR, but these + # malformations we can repair afterward, so those two logs are misleading noise (making the user think /review crashed). So temporarily suppress + # pr_agent.algo.utils's logs; only when all repairs fail do we let the **original error** through (a real failure should be visible). + # This shim runs in an isolated python subprocess for a single review, with no concurrency, so the global disable/enable switch is safe. if _loguru_logger is not None: _loguru_logger.disable("pr_agent.algo.utils") try: @@ -124,7 +124,7 @@ def load_yaml(response_text, *args, **kwargs): for cand in _repair_candidates(response_text): try: d = orig_load_yaml(cand, *args, **kwargs) - except Exception: # noqa: BLE001 - 修复尝试失败不致命,继续下一个 + except Exception: # noqa: BLE001 - a repair attempt failing is not fatal, continue to the next d = None if d: _debug("load_yaml recovered via meebox repair") @@ -132,7 +132,7 @@ def load_yaml(response_text, *args, **kwargs): finally: if _loguru_logger is not None: _loguru_logger.enable("pr_agent.algo.utils") - # 全部修复失败 → 日志已恢复,重跑一次 orig 让真实报错可见,并返回其结果(None/空) + # all repairs failed → logs restored, rerun orig once to make the real error visible, and return its result (None/empty) return orig_load_yaml(response_text, *args, **kwargs) module.load_yaml = load_yaml diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py index 541fb196..809ca1c5 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py @@ -1,18 +1,18 @@ -"""LocalGitProvider 补丁(受版本守卫):二进制安全 get_diff_files + get_line_link anchor。""" +"""LocalGitProvider patch (version-guarded): binary-safe get_diff_files + get_line_link anchor.""" from ..runtime import _EXPECTED_PRAGENT_VERSION, _pragent_version, _warn def patch(module) -> None: - """LocalGitProvider.get_diff_files 对每个 diff 文件无脑 .decode('utf-8'),遇到二进制 - 文件(图片 / 编译产物 / UTF-16 等,如 0xff 开头)抛 UnicodeDecodeError 崩掉整个 review。 - 换成二进制安全版:解码失败的文件跳过(review 不处理二进制),其余逻辑与上游一致。""" - # 版本守卫:只对 pin 的 pr-agent 版本打补丁;不符则整组跳过(含 get_line_link)。 + """LocalGitProvider.get_diff_files blindly .decode('utf-8') on every diff file, and on a binary + file (images / build artifacts / UTF-16 etc., e.g. starting with 0xff) throws UnicodeDecodeError, crashing the whole review. + Replace with a binary-safe version: files that fail to decode are skipped (review doesn't handle binaries), the rest of the logic identical to upstream.""" + # version guard: only patch the pinned pr-agent version; on mismatch skip the whole group (including get_line_link). installed = _pragent_version() if installed != _EXPECTED_PRAGENT_VERSION: _warn( - f"pr-agent {installed} 与 meebox 补丁适配的 {_EXPECTED_PRAGENT_VERSION} 不符," - "已跳过补丁(/review 行号定位、二进制安全 diff 失效)。如为有意升级,请同步 " - "runtime.py 的 _EXPECTED_PRAGENT_VERSION + pragent-runtime.json 并重新验证。" + f"pr-agent {installed} does not match the {_EXPECTED_PRAGENT_VERSION} that the meebox patch is adapted for; " + "patches skipped (/review line-number anchoring and binary-safe diff disabled). If this is an intentional upgrade, sync " + "runtime.py's _EXPECTED_PRAGENT_VERSION + pragent-runtime.json and re-verify." ) return from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo @@ -38,7 +38,7 @@ def get_diff_files(self): ) patch_str = diff_item.diff.decode("utf-8") except (UnicodeDecodeError, ValueError): - # 二进制文件无法 utf-8 解码 → 跳过该文件 + # binary file can't be utf-8 decoded → skip this file continue edit_type = EDIT_TYPE.MODIFIED if diff_item.new_file: @@ -52,10 +52,10 @@ def get_diff_files(self): original_file_content_str, new_file_content_str, patch_str, - # 被删除文件 b_path 为 None → FilePatchInfo.filename=None,下游 - # set_file_languages / extract_relevant_lines_str 的 filename.rsplit/strip - # 会崩,且一崩会中断整次 review 的行号片段抽取(连未删文件的 finding 也丢 - # 代码片段)。回退用 a_path 保证 filename 永不为 None。 + # a deleted file's b_path is None → FilePatchInfo.filename=None, and downstream + # set_file_languages / extract_relevant_lines_str's filename.rsplit/strip + # would crash, and one crash interrupts the whole review's line-snippet extraction (even findings for non-deleted files lose + # code snippets). Fall back to a_path to guarantee filename is never None. diff_item.b_path or diff_item.a_path, edit_type=edit_type, old_filename=None @@ -68,23 +68,23 @@ def get_diff_files(self): module.LocalGitProvider.get_diff_files = get_diff_files - # _prepare_repo: 上游在 repo.is_dirty() 时抛「repository is not in a clean state」。我们对 CLI 模式 - # /ask 的 worktree 会按需净化——截断仓库自带的 agent 指令文件(CLAUDE.md / AGENTS.md / .cursor 规则 - # 等,防 CLI 子进程自动加载污染回答);若这些文件被仓库纳入版本管理,净化即让工作区变「脏」,触发该守卫 - # → 整个 /ask 在取 git provider 阶段就崩、不写 review.md。而 diff 取自分支提交(head.commit vs - # merge-base,见 get_diff_files),与工作区是否脏无关,故脏检查对这套「一次性受控 worktree」是误报。 - # 只保留必需的「目标分支存在」校验,去掉脏检查。 + # _prepare_repo: upstream throws "repository is not in a clean state" when repo.is_dirty(). For CLI-mode + # /ask worktrees we sanitize as needed — truncating the repo's own agent instruction files (CLAUDE.md / AGENTS.md / .cursor rules + # etc., to prevent the CLI subprocess from auto-loading and polluting the answer); if these files are tracked by the repo, sanitizing makes the working tree "dirty", tripping this guard + # → the whole /ask crashes at the git-provider acquisition stage, never writing review.md. But the diff comes from branch commits (head.commit vs + # merge-base, see get_diff_files), independent of whether the working tree is dirty, so the dirty check is a false positive for this "one-shot controlled worktree" setup. + # Keep only the required "target branch exists" check, drop the dirty check. def _prepare_repo(self): if self.target_branch_name not in self.repo.heads: raise KeyError(f"Branch: {self.target_branch_name} does not exist") module.LocalGitProvider._prepare_repo = _prepare_repo - # get_line_link: 基类默认 `return ''`,LocalGitProvider 未实现 → /review 的 - # key_issues 渲染(convert_to_markdown_v2)走"无 link + 非 GFM"分支,把 - # relevant_file/start_line/end_line 抹掉(见 ROADMAP M5 anchor 根因)。补成 - # meebox:///#L-L,使其走 [**header**](link) 分支, - # parse-output 据链接取结构化 anchor(与真实 provider 同源,不依赖模型自报 marker)。 + # get_line_link: the base class defaults to `return ''`, and LocalGitProvider doesn't implement it → /review's + # key_issues rendering (convert_to_markdown_v2) takes the "no link + non-GFM" branch, dropping + # relevant_file/start_line/end_line (see ROADMAP M5 anchor root cause). Fill in as + # meebox:///#L-L, so it takes the [**header**](link) branch, + # and parse-output derives the structured anchor from the link (same source as real providers, not dependent on the model self-reporting a marker). from urllib.parse import quote def get_line_link(self, relevant_file, relevant_line_start, relevant_line_end=None): @@ -99,11 +99,11 @@ def get_line_link(self, relevant_file, relevant_line_start, relevant_line_end=No module.LocalGitProvider.get_line_link = get_line_link - # 统一启用 GFM:LocalGitProvider 默认对 'gfm_markdown' 报 False,导致 /describe 的 - # enable_pr_diagram(configuration.toml 默认开)被 `enable and is_supported(gfm_markdown)` - # 门控关掉、不产出 mermaid 架构图;/review 等也走非 GFM 简化分支。这里让 gfm_markdown - # 返回 True,使 describe 按需输出 mermaid 图、各工具走 GFM 富 markdown(details / 表格 / - # mermaid)。格式兼容由应用端 markdown 解析(rehype + mermaid 渲染)处理。其余能力保持原状。 + # uniformly enable GFM: LocalGitProvider defaults to False for 'gfm_markdown', causing /describe's + # enable_pr_diagram (on by default in configuration.toml) to be gated off by `enable and is_supported(gfm_markdown)`, + # not producing the mermaid architecture diagram; /review etc. also take the non-GFM simplified branch. Here we make gfm_markdown + # return True, so describe outputs mermaid diagrams as needed and each tool takes GFM rich markdown (details / tables / + # mermaid). Format compatibility is handled by the app-side markdown parsing (rehype + mermaid rendering). Other capabilities stay as-is. _orig_is_supported = module.LocalGitProvider.is_supported def is_supported(self, capability): @@ -113,11 +113,11 @@ def is_supported(self, capability): module.LocalGitProvider.is_supported = is_supported - # get_pr_labels: 基类未实现,LocalGitProvider 直接抛 NotImplementedError('Getting labels - # is not implemented for the local git provider')。/review 跑完会调 set_review_labels → - # get_pr_labels(update=True) 读现有标签做 merge,本地仓库无"标签"概念,异常被 pr_reviewer - # catch 后打成 ERROR 噪音(review 结果不受影响)。本地无远端标签,返回空列表即可: - # set_review_labels 据此走 publish_labels(LocalGitProvider 本就是 no-op),全程静默。 + # get_pr_labels: the base class doesn't implement it, and LocalGitProvider directly throws NotImplementedError('Getting labels + # is not implemented for the local git provider'). After /review runs it calls set_review_labels → + # get_pr_labels(update=True) reads existing labels to merge; a local repo has no "label" concept, so the exception is caught by pr_reviewer + # and logged as ERROR noise (the review result is unaffected). A local repo has no remote labels, so returning an empty list suffices: + # set_review_labels then takes publish_labels (which LocalGitProvider is already a no-op for), silent throughout. def get_pr_labels(self, update=False): return [] diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py index 7c4bb755..967b27ad 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py @@ -1,30 +1,32 @@ -"""嵌入式运行时 monkeypatch 的基础设施:惰性 post-import hook 注册、版本守卫、日志。 +"""Infrastructure for the embedded runtime monkeypatch: lazy post-import hook registration, version guard, logging. -绝不在本模块(或其加载链)eager import pr_agent —— sitecustomize 在每次 python 启动都会 -加载本包,eager import 会拖慢每次调用、甚至在 pr-agent 尚未装好时报错。各 patch 对 pr_agent -的 import 一律放在 patch 函数体内(惰性),仅当目标模块真正被 import 时才执行。 +Never eager import pr_agent in this module (or its load chain) -- sitecustomize loads this package on every +python startup, so an eager import would slow every call, or even error when pr-agent isn't installed yet. Each patch's +import of pr_agent always sits inside the patch function body (lazily), executing only when the target module is actually imported. """ import importlib.abc import importlib.util import os import sys -# 本 shim 的 monkeypatch 依赖 pr-agent **特定版本**的内部实现(get_line_link 渲染分支、 -# get_diff_files 解码逻辑、load_yaml 等)。升级 pr-agent 可能让 patch 失配甚至误伤,故只对 -# 下面 pin 的版本生效;版本不符即跳过相关 patch(安全降级,宁可少打补丁也不乱打)。 -# 升级 pr-agent 时:同步此常量 + scripts/pragent-runtime.json 的 prAgent.version -# (assemble 脚本会校验两者一致,并据本文件抽取该常量),并重新验证 patch 行为。 +# This shim's monkeypatch depends on **a specific version** of pr-agent's internals (get_line_link render +# branch, get_diff_files decode logic, load_yaml, etc.). Upgrading pr-agent may make a patch mismatch or even +# misfire, so it only takes effect for the version pinned below; on version mismatch the relevant patch is +# skipped (safe degradation, preferring to apply fewer patches over applying wrong ones). +# When upgrading pr-agent: sync this constant + prAgent.version in scripts/pragent-runtime.json +# (the assemble script verifies the two match and extracts this constant from this file), and re-verify patch behavior. _EXPECTED_PRAGENT_VERSION = "0.36.0" -# 系统上下文「缓存断点」标记:assembleSystemContext(TS, packages/agent/src/assemble.ts)在**全局稳定 -# 前缀**(SOUL/AGENTS/工具目录/记忆/用户档)与 **PR/运行相关尾部** 之间插入此串(连同两侧 --- 分隔)。 -# shim 据此把稳定前缀单独标 Anthropic 提示缓存(1h),尾部保持纯文本;消费端(litellm 分块 / CLI 拼接) -# 分割或剥除后,标记**绝不**进入发给模型的 prompt。两处常量须逐字一致。 +# System context "cache break" marker: assembleSystemContext (TS, packages/agent/src/assemble.ts) inserts this string +# (along with the --- separators on both sides) between the **globally stable prefix** (SOUL/AGENTS/tool directory/memory/user +# profile) and the **PR/run-related tail**. Based on it, the shim marks the stable prefix alone with Anthropic prompt caching (1h), +# keeping the tail as plain text; after the consumers (litellm chunking / CLI concatenation) split or strip it, the marker **never** +# enters the prompt sent to the model. The two constants must match verbatim. CACHE_BREAK = "\n\n---\n\n[[MEEBOX:CACHE_BREAK]]\n\n---\n\n" def split_cache_break(system): - """按缓存断点切分 system → (stable_prefix, variable_tail)。无断点返回 (None, system)。""" + """Split system by cache break → (stable_prefix, variable_tail). Returns (None, system) if no break.""" stable, sep, variable = system.partition(CACHE_BREAK) if not sep: return None, system @@ -32,7 +34,7 @@ def split_cache_break(system): def strip_cache_break(system): - """剥除缓存断点标记(不分块的消费端用,如 CLI prompt 拼接),塌成单个 --- 分隔。""" + """Strip the cache break marker (for non-chunking consumers, e.g. CLI prompt concatenation), collapsing into a single --- separator.""" return system.replace(CACHE_BREAK, "\n\n---\n\n") @@ -42,30 +44,30 @@ def _debug(msg) -> None: def _warn(msg) -> None: - """始终输出到 stderr(不受 MEEBOX_SHIM_DEBUG 控制)。用于版本不符等"补丁静默失效"的降级 - 场景,必须让用户/日志看见。stderr 不影响 parse-output(它只解析 stdout)。""" + """Always writes to stderr (not gated by MEEBOX_SHIM_DEBUG). Used for degradation scenarios where "a patch + silently fails", such as version mismatch, which must be visible to the user/logs. stderr doesn't affect parse-output (it only parses stdout).""" print(f"[meebox] WARNING: {msg}", file=sys.stderr) def _pragent_version(): - """读已安装 pr-agent 版本(仅读 dist 元数据,不 import pr_agent)。拿不到返回 None。""" + """Read the installed pr-agent version (only reads dist metadata, doesn't import pr_agent). Returns None if unavailable.""" try: from importlib.metadata import version return version("pr-agent") - except Exception: # noqa: BLE001 - 未安装 / 元数据缺失(pip 装包途中等) + except Exception: # noqa: BLE001 - not installed / metadata missing (mid pip install, etc.) return None def _register_post_import(module_name, patch_fn) -> None: - """注册一个 meta_path finder:当 module_name 被 import 后立即执行 patch_fn(module)。 - 不在此处 import 该模块,保持 python 启动/探测/pip 轻量。""" + """Register a meta_path finder: run patch_fn(module) immediately after module_name is imported. + Doesn't import the module here, keeping python startup/probing/pip lightweight.""" class _Finder(importlib.abc.MetaPathFinder): def find_spec(self, fullname, path=None, target=None): if fullname != module_name: return None - # 临时摘掉自己,借默认机制拿到真实 spec,再包一层 loader 在 exec 后 patch + # Temporarily remove self, use the default mechanism to get the real spec, then wrap a loader to patch after exec sys.meta_path.remove(self) try: spec = importlib.util.find_spec(fullname) diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py index 9f8de2ab..bccf7e6b 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py @@ -1,13 +1,13 @@ -"""真实 token usage 采集:以哨兵行 `@@MEEBOX_USAGE@@ {json}` 打到 stderr,主进程 onLine -据此累加(见 apps/desktop/src/main/ipc.ts)。只取 token、不取 cost。全程容错。""" +"""Real token usage collection: emitted to stderr as a sentinel line `@@MEEBOX_USAGE@@ {json}`, which the main process onLine +accumulates from (see apps/desktop/src/main/ipc.ts). Takes only tokens, not cost. Fault-tolerant throughout.""" import sys from .runtime import _debug def _emit_usage(response) -> None: - """从 litellm response 读**真实 usage**(API 返回,非预估)。供 litellm handler 的 - _get_completion 包装调用。""" + """Read **real usage** from the litellm response (API-returned, not estimated). Called by the litellm handler's + _get_completion wrapper.""" try: usage = getattr(response, "usage", None) if usage is None and isinstance(response, dict): @@ -26,9 +26,9 @@ def _g(key): "total_tokens": _g("total_tokens"), } if rec["prompt_tokens"] is None and rec["completion_tokens"] is None: - return # 没有任何可用数字(如流式 MockResponse)→ 不打 - # 提示缓存读取量:Anthropic 走 cache_read_input_tokens;OpenAI 兼容走 - # prompt_tokens_details.cached_tokens。两路尽力采集、缺失则不带(UI 据有无决定是否展示)。 + return # no usable numbers at all (e.g. streaming MockResponse) → don't emit + # Prompt cache read amount: Anthropic uses cache_read_input_tokens; OpenAI-compatible uses + # prompt_tokens_details.cached_tokens. Best-effort collection on both paths, omitted if missing (UI decides whether to display based on presence). cache_read = _g("cache_read_input_tokens") if not isinstance(cache_read, int): details = _g("prompt_tokens_details") @@ -48,8 +48,8 @@ def _g(key): def _emit_usage_tokens( prompt_tokens, completion_tokens, cache_read_tokens=None, turns=None ) -> None: - """CLI 模式下从 CLI 返回的 JSON usage 直接构造哨兵(与 _emit_usage 同格式,主进程同一套 - 累加逻辑)。两个 token 数都为 None 则不打;cache_read / turns 仅在有值时附带。""" + """In CLI mode, construct the sentinel directly from the CLI-returned JSON usage (same format as _emit_usage, same + accumulation logic in the main process). Doesn't emit if both token counts are None; cache_read / turns are attached only when present.""" try: if prompt_tokens is None and completion_tokens is None: return diff --git a/apps/desktop/scripts/pragent-shim/sitecustomize.py b/apps/desktop/scripts/pragent-shim/sitecustomize.py index cdbd1fc5..13d0a8ae 100644 --- a/apps/desktop/scripts/pragent-shim/sitecustomize.py +++ b/apps/desktop/scripts/pragent-shim/sitecustomize.py @@ -1,18 +1,18 @@ -# meebox 嵌入式运行时 monkeypatch shim —— 薄加载器。 +# meebox embedded runtime monkeypatch shim -- thin loader. # -# CPython 启动时经 `site` 自动 import 本模块(名为 sitecustomize,无需 PYTHONPATH/挂载)。 -# 真正的补丁实现按领域拆在同目录的 `meebox_pragent_shim` 包里(patches/ 与 cli/)。 -# 本文件只负责:调用 apply() 注册全部惰性 post-import hook,并整体兜底——shim 绝不能让 -# 解释器/agent 崩。**绝不在此 eager import pr_agent**(本文件每次 python 启动都会跑: -# 探测 --version / find_spec / pip 装包等,eager import 会拖慢甚至在 pr-agent 未装好时报错)。 +# CPython auto-imports this module via `site` on startup (named sitecustomize, no PYTHONPATH/mount needed). +# The actual patch implementations are split by domain into the `meebox_pragent_shim` package in the same directory (patches/ and cli/). +# This file is only responsible for: calling apply() to register all lazy post-import hooks, and providing an overall fallback -- the shim +# must never crash the interpreter/agent. **Never eager import pr_agent here** (this file runs on every python startup: +# probing --version / find_spec / pip install, etc.; an eager import would slow it down or even error when pr-agent isn't installed yet). # -# 由 assemble-pragent-runtime.mjs 把本文件 + meebox_pragent_shim/ 整体拷进 site-packages。 -# 详见 docs/arch/02-agent/05-pragent-runtime.md。 +# assemble-pragent-runtime.mjs copies this file + meebox_pragent_shim/ wholesale into site-packages. +# See docs/arch/02-agent/05-pragent-runtime.md for details. try: from meebox_pragent_shim import apply apply() -except Exception as exc: # noqa: BLE001 - shim 绝不能让解释器/agent 崩 +except Exception as exc: # noqa: BLE001 - the shim must never crash the interpreter/agent try: import os import sys