diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48b48249..a181bb2a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,22 +43,50 @@ jobs: - os: ubuntu-latest platform: linux arch: x64 + variant: modern + variant_suffix: "-modern" rustflags: "-C target-cpu=x86-64-v3" + - os: ubuntu-latest + platform: linux + arch: x64 + variant: baseline + variant_suffix: "-baseline" + rustflags: "-C target-cpu=x86-64-v2" - os: ubuntu-latest platform: linux arch: arm64 + variant: "" + variant_suffix: "" target: aarch64-unknown-linux-gnu - os: macos-15-intel platform: darwin arch: x64 + variant: modern + variant_suffix: "-modern" rustflags: "-C target-cpu=x86-64-v3" + - os: macos-15-intel + platform: darwin + arch: x64 + variant: baseline + variant_suffix: "-baseline" + rustflags: "-C target-cpu=x86-64-v2" - os: macos-14 platform: darwin arch: arm64 + variant: "" + variant_suffix: "" - os: windows-latest platform: win32 arch: x64 + variant: modern + variant_suffix: "-modern" rustflags: "-C target-cpu=x86-64-v3" + - os: windows-latest + platform: win32 + arch: x64 + variant: baseline + variant_suffix: "-baseline" + rustflags: "-C target-cpu=x86-64-v2" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -83,14 +111,15 @@ jobs: CROSS_TARGET: ${{ matrix.target }} TARGET_PLATFORM: ${{ matrix.platform }} TARGET_ARCH: ${{ matrix.arch }} + TARGET_VARIANT: ${{ matrix.variant }} CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc RUSTFLAGS: ${{ matrix.rustflags }} run: bun --cwd=packages/natives run build:native - name: Upload native addon uses: actions/upload-artifact@v4 with: - name: pi-natives-${{ matrix.platform }}-${{ matrix.arch }} - path: packages/natives/native/pi_natives.${{ matrix.platform }}-${{ matrix.arch }}.node + name: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.variant_suffix }} + path: packages/natives/native/pi_natives.${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.variant_suffix }}.node if-no-files-found: error test: @@ -160,8 +189,8 @@ jobs: echo "Native addons downloaded:" ls -la packages/natives/native/ echo "" - echo "Expected platforms: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64" - for plat in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + echo "Expected addons: linux-x64-modern, linux-x64-baseline, linux-arm64, darwin-x64-modern, darwin-x64-baseline, darwin-arm64, win32-x64-modern, win32-x64-baseline" + for plat in linux-x64-modern linux-x64-baseline linux-arm64 darwin-x64-modern darwin-x64-baseline darwin-arm64 win32-x64-modern win32-x64-baseline; do if [ -f "packages/natives/native/pi_natives.${plat}.node" ]; then echo "✓ pi_natives.${plat}.node" else diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 80cf77b2d..a33955cf3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added Brave web search provider as an alternative search option with recency filtering support @@ -11,6 +10,7 @@ ### Changed +- Updated CLI update mechanism to support multiple native addon variants per platform, enabling fallback to baseline versions when modern variants are unavailable - Updated web search provider priority order to include Brave (Exa → Brave → Jina → Perplexity → Anthropic → Gemini → Codex → Z.AI) - Extended recency filter support to Brave provider alongside Perplexity - Changed GitHub issue comment fetching to use paginated API requests with 100 comments per page instead of single request with 50-comment limit diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 81cf47ffb..86d60a2fd 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -80,7 +80,7 @@ async function getLatestRelease(): Promise { return { tag, version, - assets: [makeAsset(getBinaryName()), makeAsset(getNativeAddonName())], + assets: [makeAsset(getBinaryName()), ...getNativeAddonNames().map(makeAsset)], }; } @@ -143,13 +143,11 @@ function getBinaryName(): string { } /** - * Get the appropriate native addon name for this platform. - * Uses process.platform directly (linux, darwin, win32). + * Get native addon names for this platform, ordered by preference. */ -function getNativeAddonName(): string { +function getNativeAddonNames(): string[] { const platform = process.platform; const arch = process.arch; - if (!["linux", "darwin", "win32"].includes(platform)) { throw new Error(`Unsupported platform: ${platform}`); } @@ -157,7 +155,12 @@ function getNativeAddonName(): string { throw new Error(`Unsupported architecture: ${arch}`); } - return `pi_natives.${platform}-${arch}.node`; + const baseName = `pi_natives.${platform}-${arch}.node`; + if (arch !== "x64") { + return [baseName]; + } + + return [`pi_natives.${platform}-${arch}-modern.node`, `pi_natives.${platform}-${arch}-baseline.node`]; } /** @@ -197,50 +200,58 @@ async function updateViaBun(expectedVersion: string): Promise { */ async function updateViaBinary(release: ReleaseInfo): Promise { const binaryName = getBinaryName(); - const nativeAddonName = getNativeAddonName(); - + const nativeAddonNames = getNativeAddonNames(); const asset = release.assets.find(a => a.name === binaryName); - const nativeAsset = release.assets.find(a => a.name === nativeAddonName); - if (!asset) { throw new Error(`No binary found for ${binaryName}`); } - if (!nativeAsset) { - throw new Error(`No native addon found for ${nativeAddonName}`); - } - const execPath = process.execPath; const execDir = path.dirname(execPath); const tempPath = `${execPath}.new`; const backupPath = `${execPath}.bak`; - const nativePath = path.join(execDir, nativeAddonName); - const nativeTempPath = `${nativePath}.new`; + const nativeDownloads: Array<{ name: string; tempPath: string; finalPath: string }> = []; + + const downloadNativeAsset = async (name: string, required: boolean): Promise => { + const nativeAsset = release.assets.find(assetEntry => assetEntry.name === name); + if (!nativeAsset) { + if (required) throw new Error(`No native addon found for ${name}`); + return false; + } + + console.log(chalk.dim(`Downloading ${name}…`)); + try { + const nativeResponse = await fetch(nativeAsset.url, { redirect: "follow" }); + if (!nativeResponse.ok || !nativeResponse.body) { + if (required) throw new Error(`Native addon download failed for ${name}: ${nativeResponse.statusText}`); + return false; + } + + const nativeFinalPath = path.join(execDir, name); + const nativeTempPath = `${nativeFinalPath}.new`; + const nativeFileStream = fs.createWriteStream(nativeTempPath, { mode: 0o755 }); + await pipeline(nativeResponse.body, nativeFileStream); + nativeDownloads.push({ name, tempPath: nativeTempPath, finalPath: nativeFinalPath }); + return true; + } catch (err) { + if (required) throw err; + return false; + } + }; console.log(chalk.dim(`Downloading ${binaryName}…`)); - // Download to temp file + // Download binary to temp file const response = await fetch(asset.url, { redirect: "follow" }); if (!response.ok || !response.body) { throw new Error(`Download failed: ${response.statusText}`); } - const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 }); await pipeline(response.body, fileStream); - - // Download native addon - console.log(chalk.dim(`Downloading ${nativeAddonName}…`)); - - const nativeResponse = await fetch(nativeAsset.url, { redirect: "follow" }); - if (!nativeResponse.ok || !nativeResponse.body) { - throw new Error(`Native addon download failed: ${nativeResponse.statusText}`); + for (const nativeAddonName of nativeAddonNames) { + await downloadNativeAsset(nativeAddonName, true); } - - const nativeFileStream = fs.createWriteStream(nativeTempPath, { mode: 0o755 }); - await pipeline(nativeResponse.body, nativeFileStream); - // Replace current binary console.log(chalk.dim("Installing update...")); - try { try { await fs.promises.unlink(backupPath); @@ -251,10 +262,11 @@ async function updateViaBinary(release: ReleaseInfo): Promise { await fs.promises.rename(tempPath, execPath); await fs.promises.unlink(backupPath); - // Replace native addon (no backup needed, just overwrite) - await fs.promises.rename(nativeTempPath, nativePath); - + for (const nativeDownload of nativeDownloads) { + await fs.promises.rename(nativeDownload.tempPath, nativeDownload.finalPath); + } console.log(chalk.green(`\n${theme.status.success} Updated to ${release.version}`)); + console.log(chalk.dim(`Installed ${nativeDownloads.length} native addon file(s)`)); console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`)); } catch (err) { if (fs.existsSync(backupPath) && !fs.existsSync(execPath)) { @@ -263,8 +275,10 @@ async function updateViaBinary(release: ReleaseInfo): Promise { if (fs.existsSync(tempPath)) { await fs.promises.unlink(tempPath); } - if (fs.existsSync(nativeTempPath)) { - await fs.promises.unlink(nativeTempPath); + for (const nativeDownload of nativeDownloads) { + if (fs.existsSync(nativeDownload.tempPath)) { + await fs.promises.unlink(nativeDownload.tempPath); + } } throw err; } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 1f1e3a4e6..07d10f624 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,23 @@ # Changelog ## [Unreleased] +### Added + +- Added support for x64 CPU variant selection with `TARGET_VARIANT` environment variable (modern/baseline) during build to optimize for specific ISA levels +- Added automatic AVX2 detection on Linux, macOS, and Windows to select optimal native addon variant at runtime +- Added `PI_NATIVE_VARIANT` environment variable to override CPU variant selection at runtime +- Added support for multiple native addon variants per platform (modern with AVX2, baseline without AVX2) for improved performance portability + +### Changed + +- Changed native addon filename scheme to include CPU variant suffix for x64 builds (e.g., `pi_natives.linux-x64-modern.node`) +- Changed embedded addon structure to support multiple variant files per platform instead of single file +- Changed native addon loader to automatically select appropriate variant based on CPU capabilities or explicit override +- Changed build output to include variant information in console messages + +### Removed + +- Removed fallback untagged `pi_natives.node` binary creation for native builds; platform-tagged variants are now required ### Fixed diff --git a/packages/natives/README.md b/packages/natives/README.md index 168f23794..0132bbb98 100644 --- a/packages/natives/README.md +++ b/packages/natives/README.md @@ -52,8 +52,9 @@ crates/pi-natives/ # Rust source (workspace member) src/image.rs # Image processing (photon-rs) Cargo.toml # Rust dependencies native/ # Native addon binaries - pi_natives.-.node - pi_natives.node + pi_natives.--modern.node # x64 modern ISA (AVX2) + pi_natives.--baseline.node # x64 baseline ISA + pi_natives.-.node # non-x64 build artifact src/ # TypeScript wrappers native.ts # Native addon loader index.ts # Public API diff --git a/packages/natives/scripts/build-native.ts b/packages/natives/scripts/build-native.ts index 14a6c352b..f960ce383 100644 --- a/packages/natives/scripts/build-native.ts +++ b/packages/natives/scripts/build-native.ts @@ -1,4 +1,5 @@ import { $ } from "bun"; +import * as fsSync from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -10,14 +11,99 @@ const isDev = process.argv.includes("--dev"); const crossTarget = Bun.env.CROSS_TARGET; const targetPlatform = Bun.env.TARGET_PLATFORM || process.platform; const targetArch = Bun.env.TARGET_ARCH || process.arch; +const configuredVariantRaw = Bun.env.TARGET_VARIANT; const isCrossCompile = Boolean(crossTarget) || targetPlatform !== process.platform || targetArch !== process.arch; -// Default to native CPU optimization for local builds; CI overrides via RUSTFLAGS env +type X64Variant = "modern" | "baseline"; + +let configuredVariant: X64Variant | undefined; +if (configuredVariantRaw) { + if (targetArch !== "x64") { + throw new Error(`TARGET_VARIANT is only supported for x64 builds, got ${targetPlatform}-${targetArch}.`); + } + if (configuredVariantRaw !== "modern" && configuredVariantRaw !== "baseline") { + throw new Error(`Unsupported TARGET_VARIANT: ${configuredVariantRaw}. Expected "modern" or "baseline".`); + } + configuredVariant = configuredVariantRaw; +} + +const textDecoder = new TextDecoder(); + +function decodeOutput(output: string | ArrayBufferView | ArrayBuffer | null | undefined): string { + if (!output) return ""; + if (typeof output === "string") return output; + if (ArrayBuffer.isView(output)) { + return textDecoder.decode(new Uint8Array(output.buffer, output.byteOffset, output.byteLength)); + } + return textDecoder.decode(new Uint8Array(output)); +} + +function runCommand(command: string, args: string[]): string | null { + try { + const result = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) return null; + return decodeOutput(result.stdout).trim(); + } catch { + return null; + } +} + +function detectHostAvx2Support(): boolean { + if (process.arch !== "x64") return false; + + if (process.platform === "linux") { + try { + const cpuInfo = fsSync.readFileSync("/proc/cpuinfo", "utf8"); + return /\bavx2\b/i.test(cpuInfo); + } catch { + return false; + } + } + + if (process.platform === "darwin") { + const leaf7 = runCommand("sysctl", ["-n", "machdep.cpu.leaf7_features"]); + if (leaf7 && /\bAVX2\b/i.test(leaf7)) return true; + const features = runCommand("sysctl", ["-n", "machdep.cpu.features"]); + return Boolean(features && /\bAVX2\b/i.test(features)); + } + + if (process.platform === "win32") { + const output = runCommand("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[System.Runtime.Intrinsics.X86.Avx2]::IsSupported", + ]); + return output?.toLowerCase() === "true"; + } + + return false; +} + +function resolveEffectiveVariant(): X64Variant | null { + if (targetArch !== "x64") return null; + if (configuredVariant) return configuredVariant; + if (isCrossCompile) { + throw new Error("x64 cross-builds require TARGET_VARIANT=modern or TARGET_VARIANT=baseline."); + } + return detectHostAvx2Support() ? "modern" : "baseline"; +} + +const effectiveVariant = resolveEffectiveVariant(); +const variantSuffix = effectiveVariant ? `-${effectiveVariant}` : ""; + +// Default to native CPU optimization for local builds; explicit variants use fixed ISA targets. if (!isCrossCompile && !Bun.env.RUSTFLAGS) { - Bun.env.RUSTFLAGS = "-C target-cpu=native"; + if (effectiveVariant === "modern") { + Bun.env.RUSTFLAGS = "-C target-cpu=x86-64-v3"; + } else if (effectiveVariant === "baseline") { + Bun.env.RUSTFLAGS = "-C target-cpu=x86-64-v2"; + } else { + Bun.env.RUSTFLAGS = "-C target-cpu=native"; + } } async function cleanupStaleTemps(dir: string): Promise { @@ -41,7 +127,7 @@ async function installBinary(src: string, dest: string): Promise { try { // Atomic rename - works even if dest is loaded on Linux/macOS (old inode stays valid) await fs.rename(tempPath, dest); - } catch (renameErr) { + } catch { // On Windows, loaded DLLs cannot be overwritten via rename // Try delete-then-rename as fallback try { @@ -64,20 +150,18 @@ async function installBinary(src: string, dest: string): Promise { } } - - const cargoArgs = ["build"]; if (!isDev) cargoArgs.push("--release"); if (crossTarget) cargoArgs.push("--target", crossTarget); -console.log(`Building pi-natives for ${targetPlatform}-${targetArch}${isDev ? " (debug)" : ""}…`); +console.log(`Building pi-natives for ${targetPlatform}-${targetArch}${variantSuffix}${isDev ? " (debug)" : ""}…`); const buildResult = await $`cargo ${cargoArgs}`.cwd(rustDir).nothrow(); if (buildResult.exitCode !== 0) { const stderr = typeof buildResult.stderr === "string" ? buildResult.stderr : buildResult.stderr?.length - ? new TextDecoder().decode(buildResult.stderr) + ? textDecoder.decode(buildResult.stderr) : ""; throw new Error(`cargo build --release failed${stderr ? `:\n${stderr}` : ""}`); } @@ -89,7 +173,7 @@ const targetRoots = [ path.join(rustDir, "target"), ].filter((v): v is string => Boolean(v)); -const profileDirs = targetRoots.flatMap((root) => { +const profileDirs = targetRoots.flatMap(root => { if (crossTarget) { return [path.join(root, crossTarget, profile), path.join(root, profile)]; } @@ -117,21 +201,16 @@ await fs.mkdir(nativeDir, { recursive: true }); await cleanupStaleTemps(nativeDir); if (!sourcePath) { - const checked = profileDirs.map((d) => ` - ${d}`).join("\n"); + const checked = profileDirs.map(d => ` - ${d}`).join("\n"); throw new Error(`Built library not found. Checked:\n${checked}`); } - console.log(`Found: ${sourcePath}`); -const taggedPath = isDev ? path.join(nativeDir, `pi_natives.dev.node`) : path.join(nativeDir, `pi_natives.${targetPlatform}-${targetArch}.node`); +const taggedPath = + isDev + ? path.join(nativeDir, "pi_natives.dev.node") + : path.join(nativeDir, `pi_natives.${targetPlatform}-${targetArch}${variantSuffix}.node`); console.log(`Installing: ${taggedPath}`); await installBinary(sourcePath, taggedPath); -// Only create fallback for native (non-cross) builds to avoid overwriting with wrong-platform binaries -if (!isCrossCompile && !isDev) { - const fallbackPath = path.join(nativeDir, "pi_natives.node"); - console.log(`Installing: ${fallbackPath}`); - await installBinary(sourcePath, fallbackPath); -} - console.log("Build complete."); diff --git a/packages/natives/scripts/embed-native.ts b/packages/natives/scripts/embed-native.ts index a16c1bad1..fd7025abc 100644 --- a/packages/natives/scripts/embed-native.ts +++ b/packages/natives/scripts/embed-native.ts @@ -1,24 +1,83 @@ +import * as fs from "node:fs/promises"; import * as path from "node:path"; - const reset = process.argv.includes("--reset"); - const outputPath = path.join(import.meta.dir, "../src/embedded-addon.ts"); const packageJsonPath = path.join(import.meta.dir, "../package.json"); +const nativeDir = path.join(import.meta.dir, "../native"); -const stubContent = "export interface EmbeddedAddon {\n\tplatform: string;\n\tversion: string;\n\tfilePath: string;\n}\n\nexport const embeddedAddon: EmbeddedAddon | null = null;\n"; +const stubContent = `export type EmbeddedAddonVariant = "modern" | "baseline" | "default"; +export interface EmbeddedAddonFile { + variant: EmbeddedAddonVariant; + filename: string; + filePath: string; +} + +export interface EmbeddedAddon { + platformTag: string; + version: string; + files: EmbeddedAddonFile[]; +} + +export const embeddedAddon: EmbeddedAddon | null = null; +`; if (reset) { await Bun.write(outputPath, stubContent); process.exit(0); } +interface CandidateAddon { + variant: "modern" | "baseline" | "default"; + filename: string; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; + throw err; + } +} const targetPlatform = Bun.env.TARGET_PLATFORM || process.platform; const targetArch = Bun.env.TARGET_ARCH || process.arch; const platformTag = `${targetPlatform}-${targetArch}`; -const addonFilename = `pi_natives.${platformTag}.node`; +const candidates: CandidateAddon[] = + targetArch === "x64" + ? [ + { variant: "modern", filename: `pi_natives.${platformTag}-modern.node` }, + { variant: "baseline", filename: `pi_natives.${platformTag}-baseline.node` }, + ] + : [{ variant: "default", filename: `pi_natives.${platformTag}.node` }]; +const available: CandidateAddon[] = []; +for (const candidate of candidates) { + const candidatePath = path.join(nativeDir, candidate.filename); + if (await fileExists(candidatePath)) { + available.push(candidate); + } +} + +if (available.length !== candidates.length) { + const expected = candidates.map(candidate => ` - ${candidate.filename}`).join("\n"); + const found = available.map(candidate => ` - ${candidate.filename}`).join("\n"); + throw new Error( + `Incomplete native addons for ${platformTag}. Expected:\n${expected}\nFound:\n${found || " (none)"}`, + ); +} const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string }; +const imports = available + .map((candidate, index) => + `import addonPath${index} from ${JSON.stringify("../native/" + candidate.filename)} with { type: \"file\" };`, + ) + .join("\n"); +const files = available + .map( + (candidate, index) => + `\t{ variant: ${JSON.stringify(candidate.variant)}, filename: ${JSON.stringify(candidate.filename)}, filePath: addonPath${index} },`, + ) + .join("\n"); -const content = `import addonPath from ${JSON.stringify("../native/" + addonFilename)} with { type: \"file\" };\n\nexport interface EmbeddedAddon {\n\tplatform: string;\n\tversion: string;\n\tfilePath: string;\n}\n\nexport const embeddedAddon: EmbeddedAddon | null = {\n\tplatform: ${JSON.stringify(platformTag)},\n\tversion: ${JSON.stringify(packageJson.version)},\n\tfilePath: addonPath,\n};\n`; - -await Bun.write(outputPath, content); +const content = `${imports}\n\nexport type EmbeddedAddonVariant = \"modern\" | \"baseline\" | \"default\";\n\nexport interface EmbeddedAddonFile {\n\tvariant: EmbeddedAddonVariant;\n\tfilename: string;\n\tfilePath: string;\n}\n\nexport interface EmbeddedAddon {\n\tplatformTag: string;\n\tversion: string;\n\tfiles: EmbeddedAddonFile[];\n}\n\nexport const embeddedAddon: EmbeddedAddon | null = {\n\tplatformTag: ${JSON.stringify(platformTag)},\n\tversion: ${JSON.stringify(packageJson.version)},\n\tfiles: [\n${files}\n\t],\n};\n`; +await Bun.write(outputPath, content); \ No newline at end of file diff --git a/packages/natives/src/embedded-addon.ts b/packages/natives/src/embedded-addon.ts index 7235b2b95..b2efc4c48 100644 --- a/packages/natives/src/embedded-addon.ts +++ b/packages/natives/src/embedded-addon.ts @@ -1,7 +1,15 @@ -export interface EmbeddedAddon { - platform: string; - version: string; +export type EmbeddedAddonVariant = "modern" | "baseline" | "default"; + +export interface EmbeddedAddonFile { + variant: EmbeddedAddonVariant; + filename: string; filePath: string; } +export interface EmbeddedAddon { + platformTag: string; + version: string; + files: EmbeddedAddonFile[]; +} + export const embeddedAddon: EmbeddedAddon | null = null; diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index 5c97053cb..f30742135 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -3,19 +3,15 @@ * * Each module extends NativeBindings via declaration merging in its types.ts. */ - import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { $env } from "@oh-my-pi/pi-utils"; import { getNativesDir } from "@oh-my-pi/pi-utils/dirs"; - import packageJson from "../package.json"; import type { NativeBindings } from "./bindings"; import { embeddedAddon } from "./embedded-addon"; - -// Import types to trigger declaration merging import "./clipboard/types"; import "./glob/types"; import "./grep/types"; @@ -32,50 +28,137 @@ import "./work/types"; export type { NativeBindings, TsFunc } from "./bindings"; +type CpuVariant = "modern" | "baseline"; const require = createRequire(import.meta.url); +const textDecoder = new TextDecoder(); const platformTag = `${process.platform}-${process.arch}`; -const addonFilename = `pi_natives.${platformTag}.node`; const packageVersion = (packageJson as { version: string }).version; const nativeDir = path.join(import.meta.dir, "..", "native"); const execDir = path.dirname(process.execPath); const versionedDir = path.join(getNativesDir(), packageVersion); -const versionedAddonPath = path.join(versionedDir, addonFilename); -const legacyUserDataDir = +const userDataDir = process.platform === "win32" ? path.join(Bun.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "omp") : path.join(os.homedir(), ".local", "bin"); -const downloadUrl = `https://github.com/can1357/oh-my-pi/releases/latest/download/${addonFilename}`; const isCompiledBinary = Bun.env.PI_COMPILED || import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); - const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]; +const variantOverride = getVariantOverride(); +const selectedVariant = resolveCpuVariant(variantOverride); +const addonFilenames = getAddonFilenames(platformTag, selectedVariant); +const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : platformTag; + const debugCandidates = [path.join(nativeDir, "pi_natives.dev.node"), path.join(execDir, "pi_natives.dev.node")]; - -const baseReleaseCandidates = [ - // Platform-tagged builds (preferred - always correct platform) - path.join(nativeDir, addonFilename), - path.join(execDir, addonFilename), - // Fallback untagged (only created for native builds, not cross-compilation) - path.join(nativeDir, "pi_natives.node"), - path.join(execDir, "pi_natives.node"), -]; - -const compiledCandidates = [ - versionedAddonPath, - path.join(legacyUserDataDir, addonFilename), - path.join(legacyUserDataDir, "pi_natives.node"), -]; - +const baseReleaseCandidates = addonFilenames.flatMap(filename => [ + path.join(nativeDir, filename), + path.join(execDir, filename), +]); +const compiledCandidates = addonFilenames.flatMap(filename => [ + path.join(versionedDir, filename), + path.join(userDataDir, filename), +]); const releaseCandidates = isCompiledBinary ? [...compiledCandidates, ...baseReleaseCandidates] : baseReleaseCandidates; const candidates = $env.PI_DEV ? [...debugCandidates, ...releaseCandidates] : releaseCandidates; +const dedupedCandidates = [...new Set(candidates)]; +function decodeOutput(output: string | ArrayBufferView | ArrayBuffer | null | undefined): string { + if (!output) return ""; + if (typeof output === "string") return output; + if (ArrayBuffer.isView(output)) + return textDecoder.decode(new Uint8Array(output.buffer, output.byteOffset, output.byteLength)); + return textDecoder.decode(new Uint8Array(output)); +} + +function runCommand(command: string, args: string[]): string | null { + try { + const result = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) return null; + return decodeOutput(result.stdout).trim(); + } catch { + return null; + } +} + +function getVariantOverride(): CpuVariant | null { + const value = Bun.env.PI_NATIVE_VARIANT; + if (!value) return null; + if (value === "modern" || value === "baseline") return value; + return null; +} + +function detectAvx2Support(): boolean { + if (process.arch !== "x64") return false; + + if (process.platform === "linux") { + try { + const cpuInfo = fs.readFileSync("/proc/cpuinfo", "utf8"); + return /\bavx2\b/i.test(cpuInfo); + } catch { + return false; + } + } + + if (process.platform === "darwin") { + const leaf7 = runCommand("sysctl", ["-n", "machdep.cpu.leaf7_features"]); + if (leaf7 && /\bAVX2\b/i.test(leaf7)) return true; + const features = runCommand("sysctl", ["-n", "machdep.cpu.features"]); + return Boolean(features && /\bAVX2\b/i.test(features)); + } + + if (process.platform === "win32") { + const output = runCommand("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[System.Runtime.Intrinsics.X86.Avx2]::IsSupported", + ]); + return output?.toLowerCase() === "true"; + } + + return false; +} + +function resolveCpuVariant(override: CpuVariant | null): CpuVariant | null { + if (process.arch !== "x64") return null; + if (override) return override; + return detectAvx2Support() ? "modern" : "baseline"; +} + +function getAddonFilenames(tag: string, variant: CpuVariant | null): string[] { + const defaultFilename = `pi_natives.${tag}.node`; + if (process.arch !== "x64" || !variant) return [defaultFilename]; + const baselineFilename = `pi_natives.${tag}-baseline.node`; + const modernFilename = `pi_natives.${tag}-modern.node`; + if (variant === "modern") { + return [modernFilename, baselineFilename]; + } + return [baselineFilename]; +} + +function selectEmbeddedAddonFile(): { filename: string; filePath: string } | null { + if (!embeddedAddon) return null; + const defaultFile = embeddedAddon.files.find(file => file.variant === "default") ?? null; + if (process.arch !== "x64") return defaultFile ?? embeddedAddon.files[0] ?? null; + if (selectedVariant === "modern") { + return ( + embeddedAddon.files.find(file => file.variant === "modern") ?? + embeddedAddon.files.find(file => file.variant === "baseline") ?? + null + ); + } + return embeddedAddon.files.find(file => file.variant === "baseline") ?? null; +} function maybeExtractEmbeddedAddon(errors: string[]): string | null { if (!isCompiledBinary || !embeddedAddon) return null; - if (embeddedAddon.platform !== platformTag || embeddedAddon.version !== packageVersion) return null; + if (embeddedAddon.platformTag !== platformTag || embeddedAddon.version !== packageVersion) return null; + + const selectedEmbeddedFile = selectEmbeddedAddonFile(); + if (!selectedEmbeddedFile) return null; + const targetPath = path.join(versionedDir, selectedEmbeddedFile.filename); try { fs.mkdirSync(versionedDir, { recursive: true }); @@ -85,26 +168,24 @@ function maybeExtractEmbeddedAddon(errors: string[]): string | null { return null; } - if (fs.existsSync(versionedAddonPath)) { - return versionedAddonPath; + if (fs.existsSync(targetPath)) { + return targetPath; } try { - const buffer = fs.readFileSync(embeddedAddon.filePath); - fs.writeFileSync(versionedAddonPath, buffer); - return versionedAddonPath; + const buffer = fs.readFileSync(selectedEmbeddedFile.filePath); + fs.writeFileSync(targetPath, buffer); + return targetPath; } catch (err) { const message = err instanceof Error ? err.message : String(err); - errors.push(`embedded addon write: ${message}`); + errors.push(`embedded addon write (${selectedEmbeddedFile.filename}): ${message}`); return null; } } - function loadNative(): NativeBindings { const errors: string[] = []; const embeddedCandidate = maybeExtractEmbeddedAddon(errors); - const runtimeCandidates = embeddedCandidate ? [embeddedCandidate, ...candidates] : candidates; - + const runtimeCandidates = embeddedCandidate ? [embeddedCandidate, ...dedupedCandidates] : dedupedCandidates; for (const candidate of runtimeCandidates) { try { const bindings = require(candidate) as NativeBindings; @@ -121,7 +202,6 @@ function loadNative(): NativeBindings { errors.push(`${candidate}: ${message}`); } } - // Check if this is an unsupported platform if (!SUPPORTED_PLATFORMS.includes(platformTag)) { throw new Error( @@ -130,26 +210,29 @@ function loadNative(): NativeBindings { "If you need support for this platform, please open an issue.", ); } - const details = errors.map(error => `- ${error}`).join("\n"); let helpMessage: string; if (isCompiledBinary) { + const expectedPaths = addonFilenames.map(filename => ` ${path.join(versionedDir, filename)}`).join("\n"); + const downloadHints = addonFilenames + .map(filename => { + const downloadUrl = `https://github.com/can1357/oh-my-pi/releases/latest/download/${filename}`; + const targetPath = path.join(versionedDir, filename); + return ` curl -fsSL "${downloadUrl}" -o "${targetPath}"`; + }) + .join("\n"); helpMessage = - `The compiled binary should extract the native addon to:\n` + - ` ${versionedAddonPath}\n\n` + - `If it is missing, delete ${versionedDir} and re-run, or download manually:\n` + - ` curl -fsSL "${downloadUrl}" -o "${versionedAddonPath}"`; + `The compiled binary should extract one of:\n${expectedPaths}\n\n` + + `If missing, delete ${versionedDir} and re-run, or download manually:\n${downloadHints}`; } else { helpMessage = "If installed via npm/bun, try reinstalling: bun install @oh-my-pi/pi-natives\n" + - "If developing locally, build with: bun --cwd=packages/natives run build:native"; + "If developing locally, build with: bun --cwd=packages/natives run build:native\n" + + "Optional x64 variants: TARGET_VARIANT=baseline|modern bun --cwd=packages/natives run build:native"; } - throw new Error( - `Failed to load pi_natives native addon for ${platformTag}.\n\n` + `Tried:\n${details}\n\n${helpMessage}`, - ); + throw new Error(`Failed to load pi_natives native addon for ${addonLabel}.\n\nTried:\n${details}\n\n${helpMessage}`); } - function validateNative(bindings: NativeBindings, source: string): void { const missing: string[] = []; const checkFn = (name: keyof NativeBindings) => { @@ -157,7 +240,6 @@ function validateNative(bindings: NativeBindings, source: string): void { missing.push(name); } }; - checkFn("copyToClipboard"); checkFn("readImageFromClipboard"); checkFn("glob"); @@ -171,7 +253,6 @@ function validateNative(bindings: NativeBindings, source: string): void { checkFn("getSupportedLanguages"); checkFn("truncateToWidth"); checkFn("sanitizeText"); - checkFn("wrapTextWithAnsi"); checkFn("sliceWithWidth"); checkFn("extractSegments"); @@ -189,7 +270,6 @@ function validateNative(bindings: NativeBindings, source: string): void { checkFn("getSystemInfo"); checkFn("getWorkProfile"); checkFn("invalidateFsScanCache"); - if (missing.length) { throw new Error( `Native addon missing exports (${source}). Missing: ${missing.join(", ")}. ` + @@ -197,5 +277,4 @@ function validateNative(bindings: NativeBindings, source: string): void { ); } } - export const native = loadNative(); diff --git a/scripts/install-tests/binary.dockerfile b/scripts/install-tests/binary.dockerfile index afa99735b..eed9a7dab 100644 --- a/scripts/install-tests/binary.dockerfile +++ b/scripts/install-tests/binary.dockerfile @@ -23,7 +23,8 @@ RUN cd packages/coding-agent && bun run build:binary # Install binary and native addon to PATH RUN mkdir -p /root/.local/bin && \ cp packages/coding-agent/dist/omp /root/.local/bin/ && \ - cp packages/natives/native/pi_natives.linux-x64.node /root/.local/bin/ + cp packages/natives/native/pi_natives.linux-x64-modern.node /root/.local/bin/ && \ + cp packages/natives/native/pi_natives.linux-x64-baseline.node /root/.local/bin/ ENV PATH="/root/.local/bin:$PATH" # Verify diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b1c1cf71f..c069e0567 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -20,7 +20,7 @@ $Repo = "can1357/oh-my-pi" $Package = "@oh-my-pi/pi-coding-agent" $InstallDir = if ($env:PI_INSTALL_DIR) { $env:PI_INSTALL_DIR } else { "$env:LOCALAPPDATA\omp" } $BinaryName = "omp-windows-x64.exe" -$NativeAddonName = "pi_natives.win32-x64.node" +$NativeAddonNames = @("pi_natives.win32-x64-modern.node", "pi_natives.win32-x64-baseline.node") $MinimumBunVersion = "1.3.7" function Test-BunInstalled { @@ -263,15 +263,18 @@ function Install-Binary { $OutPath = Join-Path $InstallDir "omp.exe" Invoke-WebRequest -Uri $BinaryUrl -OutFile $OutPath - # Download native addon - $NativeUrl = "https://github.com/$Repo/releases/download/$Latest/$NativeAddonName" - Write-Host "Downloading $NativeAddonName..." - $NativeOutPath = Join-Path $InstallDir $NativeAddonName - Invoke-WebRequest -Uri $NativeUrl -OutFile $NativeOutPath - + # Download native addons + $downloadedNative = 0 + foreach ($nativeAddonName in $NativeAddonNames) { + $nativeUrl = "https://github.com/$Repo/releases/download/$Latest/$nativeAddonName" + Write-Host "Downloading $nativeAddonName..." + $nativeOutPath = Join-Path $InstallDir $nativeAddonName + Invoke-WebRequest -Uri $nativeUrl -OutFile $nativeOutPath + $downloadedNative += 1 + } Write-Host "" Write-Host "✓ Installed omp to $OutPath" -ForegroundColor Green - Write-Host "✓ Installed native addon to $NativeOutPath" -ForegroundColor Green + Write-Host "✓ Installed $downloadedNative native addon file(s) to $InstallDir" -ForegroundColor Green # Add to PATH if not already there $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") diff --git a/scripts/install.sh b/scripts/install.sh index a120bbfc9..44d11a5ec 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -202,45 +202,33 @@ install_binary() { esac BINARY="omp-${PLATFORM}-${ARCH}" - NATIVE_ADDON="pi_natives.${PLATFORM}-${ARCH}.node" - - # Get release tag - if [ -n "$REF" ]; then - echo "Fetching release $REF..." - if RELEASE_JSON=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/tags/${REF}"); then - LATEST=$(echo "$RELEASE_JSON" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') - else - echo "Release tag not found: $REF" - echo "For branch/commit installs, use --source with --ref." - exit 1 - fi - else - echo "Fetching latest release..." - RELEASE_JSON=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest") - LATEST=$(echo "$RELEASE_JSON" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') - fi - if [ -z "$LATEST" ]; then - echo "Failed to fetch release tag" - exit 1 - fi - echo "Using version: $LATEST" - - mkdir -p "$INSTALL_DIR" - # Download binary BINARY_URL="https://github.com/${REPO}/releases/download/${LATEST}/${BINARY}" echo "Downloading ${BINARY}..." curl -fsSL "$BINARY_URL" -o "${INSTALL_DIR}/omp" chmod +x "${INSTALL_DIR}/omp" - - # Download native addon - NATIVE_URL="https://github.com/${REPO}/releases/download/${LATEST}/${NATIVE_ADDON}" - echo "Downloading ${NATIVE_ADDON}..." - curl -fsSL "$NATIVE_URL" -o "${INSTALL_DIR}/${NATIVE_ADDON}" - + downloaded_native=0 + if [ "$ARCH" = "x64" ]; then + for variant in modern baseline; do + NATIVE_ADDON="pi_natives.${PLATFORM}-${ARCH}-${variant}.node" + NATIVE_URL="https://github.com/${REPO}/releases/download/${LATEST}/${NATIVE_ADDON}" + echo "Downloading ${NATIVE_ADDON}..." + curl -fsSL "$NATIVE_URL" -o "${INSTALL_DIR}/${NATIVE_ADDON}" || { + echo "Failed to download ${NATIVE_ADDON}" + exit 1 + } + downloaded_native=$((downloaded_native + 1)) + done + else + NATIVE_ADDON="pi_natives.${PLATFORM}-${ARCH}.node" + NATIVE_URL="https://github.com/${REPO}/releases/download/${LATEST}/${NATIVE_ADDON}" + echo "Downloading ${NATIVE_ADDON}..." + curl -fsSL "$NATIVE_URL" -o "${INSTALL_DIR}/${NATIVE_ADDON}" + downloaded_native=1 + fi echo "" echo "✓ Installed omp to ${INSTALL_DIR}/omp" - echo "✓ Installed native addon to ${INSTALL_DIR}/${NATIVE_ADDON}" + echo "✓ Installed ${downloaded_native} native addon file(s) to ${INSTALL_DIR}" # Check if in PATH case ":$PATH:" in