chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:44:08 +08:00
commit 983960e2dd
1244 changed files with 281996 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs'
import { LEON_FILE_PATH } from '@/constants'
import { Telemetry } from '@/telemetry'
import { createSetupStatus } from './setup-status'
export default async () => {
const status = createSetupStatus('Checking instance ID...').start()
try {
const { instanceID, birthDate } = await Telemetry.postInstall()
if (!fs.existsSync(LEON_FILE_PATH)) {
await fs.promises.writeFile(
LEON_FILE_PATH,
JSON.stringify(
{
instanceID,
birthDate
},
null,
2
)
)
status.succeed('Instance ID: ready')
} else {
status.succeed('Instance ID: ready')
}
} catch (e) {
status.warn(`Failed to create the instance ID: ${e}`)
}
}
+82
View File
@@ -0,0 +1,82 @@
import fs from 'node:fs'
import path from 'node:path'
import {
LLM_MANIFEST_PATH,
PYTORCH_MANIFEST_PATH,
PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH,
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
PYTHON_TCP_SERVER_TTS_MODEL_PATH
} from '@/constants'
import {
ASR_MODEL_FILES,
TTS_BERT_BASE_MODEL_FILES
} from '@/core/voice/voice-resource-state'
function readManifest(manifestPath) {
if (!fs.existsSync(manifestPath)) {
return null
}
try {
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
} catch {
return null
}
}
function hasManifest(manifestPath) {
return readManifest(manifestPath) !== null
}
function hasAllFiles(directoryPath, fileNames) {
return fileNames.every((fileName) =>
fs.existsSync(path.join(directoryPath, fileName))
)
}
/**
* Inspect whether Leon's default local LLM has already been installed.
*/
export function inspectLocalAISetupState() {
const manifest = readManifest(LLM_MANIFEST_PATH)
const defaultInstalledLLMPath = manifest?.defaultInstalledLLMPath
if (
typeof defaultInstalledLLMPath !== 'string' ||
defaultInstalledLLMPath.trim() === ''
) {
return {
isInstalled: false,
label: ''
}
}
const modelPath = path.resolve(defaultInstalledLLMPath)
return {
isInstalled: fs.existsSync(modelPath),
label:
manifest?.name && manifest?.version
? `${manifest.name} (${manifest.version})`
: path.basename(modelPath)
}
}
/**
* Inspect whether voice mode has already been installed at least once.
* Version-specific updates are handled by the voice setup pass.
*/
export function inspectVoiceSetupState() {
const hasVoiceModels =
hasAllFiles(
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
TTS_BERT_BASE_MODEL_FILES
) &&
fs.existsSync(PYTHON_TCP_SERVER_TTS_MODEL_PATH) &&
hasAllFiles(PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH, ASR_MODEL_FILES)
return {
isInstalled: hasManifest(PYTORCH_MANIFEST_PATH) && hasVoiceModels
}
}
+54
View File
@@ -0,0 +1,54 @@
import { CPUArchitectures } from '@/types'
import { SystemHelper } from '@/helpers/system-helper'
/**
* Inspect whether this machine is a good fit for Leon's local AI setup.
*/
export default async function inspectLocalAICapability() {
let llama = null
try {
const { getLlama, LlamaLogLevel } = await Function(
'return import("node-llama-cpp")'
)()
llama = await getLlama({
logLevel: LlamaLogLevel.disabled
})
} catch {
llama = null
}
const [hasGPU, gpuDeviceNames, graphicsComputeAPI, totalVRAM, canSupportLLM] =
await Promise.all([
SystemHelper.hasGPU(llama || undefined, { allowCoreImport: false }),
SystemHelper.getGPUDeviceNames(llama || undefined, {
allowCoreImport: false
}),
SystemHelper.getGraphicsComputeAPI(llama || undefined, {
allowCoreImport: false
}),
SystemHelper.getTotalVRAM(llama || undefined, {
allowCoreImport: false
}),
SystemHelper.canSupportLocalLLM(llama || undefined, {
allowCoreImport: false
})
])
const isLinuxARM64 =
SystemHelper.isLinux() &&
SystemHelper.getInformation().cpuArchitecture === CPUArchitectures.ARM64
const canInstallLocalAI =
(!isLinuxARM64 || (hasGPU && graphicsComputeAPI === 'cuda')) &&
canSupportLLM
return {
hasGPU,
gpuDeviceNames,
graphicsComputeAPI,
totalVRAM,
canInstallLocalAI
}
}
+79
View File
@@ -0,0 +1,79 @@
import { spawn } from 'node:child_process'
import { PNPM_RUNTIME_BIN_PATH } from '@/constants'
import { RuntimeHelper } from '@/helpers/runtime-helper'
import { setupConsola } from './setup-ui'
const POST_SETUP_ACTIONS = [
{
label: 'Start me now',
value: 'start-me-now'
},
{
label: 'Finish',
value: 'finish'
}
]
async function runLeonStartCommand() {
const startCommand = process.platform === 'win32'
? {
command: 'cmd.exe',
args: ['/c', PNPM_RUNTIME_BIN_PATH, 'start']
}
: {
command: PNPM_RUNTIME_BIN_PATH,
args: ['start']
}
await new Promise((resolve, reject) => {
const child = spawn(startCommand.command, startCommand.args, {
cwd: process.cwd(),
env: {
...RuntimeHelper.getManagedNodeEnvironment(process.env),
LEON_OPEN_BROWSER: 'true'
},
stdio: 'inherit',
windowsHide: true
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
if ((code ?? 0) !== 0) {
reject(new Error(`Leon exited with code ${code ?? 0}`))
return
}
resolve(undefined)
})
})
}
export default async function postSetup() {
if (
process.env['IS_DOCKER'] === 'true' ||
!process.stdin.isTTY ||
!process.stdout.isTTY
) {
return
}
const nextAction = await setupConsola.prompt('What do you want to do next?', {
type: 'select',
options: POST_SETUP_ACTIONS,
initialValue: POST_SETUP_ACTIONS[1].value,
cancel: 'default'
})
if (nextAction !== 'start-me-now') {
return
}
await runLeonStartCommand()
}
+1
View File
@@ -0,0 +1 @@
console.info('\x1b[36m➡ %s\x1b[0m', 'I\'m installing myself...')
+98
View File
@@ -0,0 +1,98 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawn } from 'node:child_process'
const SETUP_ENTRY_PATH = path.join('scripts', 'setup', 'setup.js')
const TSX_ENTRY_PATH = path.join(
process.cwd(),
'node_modules',
'tsx',
'dist',
'cli.mjs'
)
const TTY_PATH = '/dev/tty'
const WINDOWS_CONSOLE_IN_PATH = 'CONIN$'
const WINDOWS_CONSOLE_OUT_PATH = 'CONOUT$'
// pnpm 11 reads project config from pnpm-workspace.yaml. That also makes root
// lifecycle scripts run through pnpm's workspace runner, which does not expose
// a TTY to the child process. Leon setup needs a TTY to ask setup questions, so
// this wrapper reattaches the real setup process to the user's terminal.
function openWindowsConsoleStdio() {
try {
const stdin = fs.openSync(WINDOWS_CONSOLE_IN_PATH, 'r')
const stdout = fs.openSync(WINDOWS_CONSOLE_OUT_PATH, 'w')
return [stdin, stdout, stdout]
} catch {
return null
}
}
// Use the controlling terminal when available. In CI, Docker, or other
// non-interactive contexts there may be no terminal, so setup falls back to the
// inherited stdio and keeps its existing non-interactive behavior.
function getTTYStdio() {
if (process.platform === 'win32') {
return openWindowsConsoleStdio()
}
if (!fs.existsSync(TTY_PATH)) {
return null
}
try {
const tty = fs.openSync(TTY_PATH, 'r+')
return [tty, tty, tty]
} catch {
return null
}
}
function closeTTYStdio(stdio) {
if (stdio === null) {
return
}
for (const descriptor of new Set(stdio)) {
fs.closeSync(descriptor)
}
}
async function runSetup() {
const ttyStdio = getTTYStdio()
try {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [TSX_ENTRY_PATH, SETUP_ENTRY_PATH], {
cwd: process.cwd(),
env: process.env,
stdio: ttyStdio || 'inherit',
windowsHide: false
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
if ((code ?? 0) !== 0) {
reject(new Error(`Setup exited with code ${code ?? 0}`))
return
}
resolve()
})
})
} finally {
closeTTYStdio(ttyStdio)
}
}
runSetup().catch((error) => {
console.error(error)
process.exit(1)
})
+25
View File
@@ -0,0 +1,25 @@
import fs from 'node:fs'
import { path as ffprobePath } from '@ffprobe-installer/ffprobe'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
export default async () => {
const status = createSetupStatus('Checking ffprobe permissions...').start()
try {
if (SystemHelper.isWindows()) {
status.succeed('ffprobe: ready')
return
}
await fs.promises.chmod(ffprobePath, 0o755)
status.succeed('ffprobe: ready')
} catch (e) {
status.warn(`Failed to set ffprobe permissions: ${e}`)
}
}
+41
View File
@@ -0,0 +1,41 @@
function isPlainObject(value) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
)
}
/**
* Merge default settings into existing settings without overwriting user values.
*/
export function mergeMissingSettings(defaultSettings, existingSettings) {
if (!isPlainObject(defaultSettings)) {
return isPlainObject(existingSettings) ? existingSettings : {}
}
if (!isPlainObject(existingSettings)) {
return defaultSettings
}
const mergedSettings = { ...existingSettings }
for (const [key, defaultValue] of Object.entries(defaultSettings)) {
if (!Object.hasOwn(existingSettings, key)) {
mergedSettings[key] = defaultValue
continue
}
if (
isPlainObject(defaultValue) &&
isPlainObject(existingSettings[key])
) {
mergedSettings[key] = mergeMissingSettings(
defaultValue,
existingSettings[key]
)
}
}
return mergedSettings
}
+75
View File
@@ -0,0 +1,75 @@
const SETUP_BANNER = `
██╗ ███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██║ ██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
██║ █████╗ ██║ ██║██╔██╗ ██║ ███████║██║
██║ ██╔══╝ ██║ ██║██║╚██╗██║ ██╔══██║██║
███████╗███████╗╚██████╔╝██║ ╚████║ ██║ ██║██║
╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝
`.trim()
const GRADIENT_STOPS = [
[28, 117, 219],
[237, 41, 122]
]
function getGradientColor(progress) {
const scaledProgress = Math.min(
GRADIENT_STOPS.length - 1,
Math.max(0, progress) * (GRADIENT_STOPS.length - 1)
)
const startIndex = Math.floor(scaledProgress)
const endIndex = Math.min(GRADIENT_STOPS.length - 1, startIndex + 1)
const localProgress = scaledProgress - startIndex
const startColor = GRADIENT_STOPS[startIndex]
const endColor = GRADIENT_STOPS[endIndex]
return startColor.map((channel, index) =>
Math.round(channel + (endColor[index] - channel) * localProgress)
)
}
function colorizeCharacter(character, progress) {
if (character === ' ') {
return character
}
const [red, green, blue] = getGradientColor(progress)
return `\x1b[38;2;${red};${green};${blue}m${character}\x1b[0m`
}
function colorizeBanner(banner) {
const characters = [...banner]
const visibleCharacterCount = characters.filter(
(character) => character !== '\n' && character !== ' '
).length
let visibleIndex = 0
return characters
.map((character) => {
if (character === '\n') {
return character
}
const progress =
visibleCharacterCount <= 1
? 0
: visibleIndex / (visibleCharacterCount - 1)
if (character !== ' ') {
visibleIndex += 1
}
return colorizeCharacter(character, progress)
})
.join('')
}
/**
* Print the setup banner once at the beginning of postinstall.
*/
export function printSetupBanner() {
console.log('')
console.log(colorizeBanner(SETUP_BANNER))
console.log('')
}
+124
View File
@@ -0,0 +1,124 @@
import fs from 'node:fs'
import path from 'node:path'
import { CPUArchitectures } from '@/types'
import {
CMAKE_PATH,
CMAKE_INSTALL_PATH,
CMAKE_BIN_PATH,
CMAKE_MANIFEST_PATH,
CMAKE_VERSION
} from '@/constants'
import { FileHelper } from '@/helpers/file-helper'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
/**
* Download and set up Leon-managed CMake
* 1. Resolve the pinned version from versions.json
* 2. Download the matching Linux archive for the current architecture
* 3. Extract it into bin/cmake/cmake/
* 4. Always use this CMake binary for local source builds
*/
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
function readManifest() {
if (!fs.existsSync(CMAKE_MANIFEST_PATH)) {
return null
}
try {
return JSON.parse(fs.readFileSync(CMAKE_MANIFEST_PATH, 'utf8'))
} catch {
return null
}
}
async function cleanInstallDirectory() {
await fs.promises.mkdir(CMAKE_PATH, { recursive: true })
const entries = await fs.promises.readdir(CMAKE_PATH, {
withFileTypes: true
})
await Promise.all(
entries
.filter((entry) => entry.name !== 'versions.json')
.map((entry) =>
fs.promises.rm(path.join(CMAKE_PATH, entry.name), {
recursive: true,
force: true
})
)
)
}
function getDownloadURL() {
if (CPU_ARCH === CPUArchitectures.X64) {
return `https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz`
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return `https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-aarch64.tar.gz`
}
throw new Error(`Unsupported Linux architecture for CMake: ${CPU_ARCH}`)
}
export default async function setupCMake() {
if (!SystemHelper.isLinux()) {
return
}
const status = createSetupStatus('Downloading and setting up CMake...').start()
const manifest = readManifest()
if (manifest?.version === CMAKE_VERSION && fs.existsSync(CMAKE_BIN_PATH)) {
status.succeed(`CMake: ${CMAKE_VERSION}`)
return
}
const archivePath = path.join(CMAKE_PATH, `cmake-${CMAKE_VERSION}.tar.gz`)
await cleanInstallDirectory()
try {
status.pause()
await FileHelper.downloadFile(getDownloadURL(), archivePath, {
cliProgress: true,
parallelStreams: 3,
skipExisting: false
})
status.text = 'Installing CMake...'
status.start()
await FileHelper.extractArchive(archivePath, CMAKE_INSTALL_PATH, {
stripComponents: 1
})
if (!fs.existsSync(CMAKE_BIN_PATH)) {
throw new Error(`Cannot find CMake binary at "${CMAKE_BIN_PATH}"`)
}
await Promise.all([
fs.promises.rm(archivePath, { force: true }),
FileHelper.createManifestFile(CMAKE_MANIFEST_PATH, 'cmake', CMAKE_VERSION, {
os: SystemHelper.getInformation().type,
architecture: SystemHelper.getInformation().cpuArchitecture
})
])
status.succeed(`CMake: ${CMAKE_VERSION}`)
} catch (error) {
await fs.promises.rm(archivePath, { force: true })
if (status.isSpinning) {
status.fail('Failed to set up CMake')
}
throw new Error(`Failed to set up CMake: ${error}`)
}
}
+400
View File
@@ -0,0 +1,400 @@
import fs from 'node:fs'
import path from 'node:path'
import YAML, { isMap } from 'yaml'
import {
LEON_PROFILE_PATH,
PROFILE_CONFIG_PATH,
PROFILE_DOT_ENV_PATH
} from '@/leon-roots'
import { createSetupStatus } from './setup-status'
const CONFIG_PATH = PROFILE_CONFIG_PATH
const CONFIG_SAMPLE_PATH = path.join(process.cwd(), 'config.sample.yml')
const CONFIG_SCHEMA_PATH = path.join(
process.cwd(),
'schemas',
'core-schemas',
'config.json'
)
const PROFILE_CONFIG_SCHEMA_PATH = path.join(
LEON_PROFILE_PATH,
'schemas',
'core-schemas',
'config.json'
)
const LEGACY_DISABLED_PATH = path.join(LEON_PROFILE_PATH, 'disabled.json')
const LEGACY_ALLOWED_PATH = path.join(LEON_PROFILE_PATH, 'allowed.json')
const YAML_SCHEMA_COMMENT_PATTERN =
/^# yaml-language-server: \$schema=.*(?:\r?\n)?/
const PROFILE_YAML_SCHEMA_REFERENCE = './schemas/core-schemas/config.json'
const ENV_LINE_SEPARATOR_PATTERN = /\r?\n/
const OPTIONAL_STRING_CONFIG_PATHS = [
['llm', 'default'],
['llm', 'workflow'],
['llm', 'agent'],
['time_zone']
]
function getPairKey(pair) {
return String(pair.key?.value || pair.key || '').trim()
}
function findPair(map, key) {
return map.items.find((pair) => getPairKey(pair) === key) || null
}
function mergeMissingMapPairs(targetMap, sampleMap, schema) {
let addedCount = 0
for (const samplePair of sampleMap.items) {
const key = getPairKey(samplePair)
if (!key) {
continue
}
const targetPair = findPair(targetMap, key)
if (!targetPair) {
targetMap.items.push(samplePair.clone(schema))
addedCount += 1
continue
}
if (isMap(targetPair.value) && isMap(samplePair.value)) {
addedCount += mergeMissingMapPairs(
targetPair.value,
samplePair.value,
schema
)
}
}
return addedCount
}
function withProfileSchemaReference(content) {
const schemaComment = `# yaml-language-server: $schema=${PROFILE_YAML_SCHEMA_REFERENCE}\n`
if (YAML_SCHEMA_COMMENT_PATTERN.test(content)) {
return content.replace(YAML_SCHEMA_COMMENT_PATTERN, schemaComment)
}
return `${schemaComment}${content}`
}
async function syncProfileConfigSchema() {
if (!fs.existsSync(CONFIG_SCHEMA_PATH)) {
return
}
await fs.promises.mkdir(path.dirname(PROFILE_CONFIG_SCHEMA_PATH), {
recursive: true
})
await fs.promises.copyFile(CONFIG_SCHEMA_PATH, PROFILE_CONFIG_SCHEMA_PATH)
}
function getEnvVariableName(line) {
const trimmedLine = line.trim()
if (
trimmedLine === '' ||
trimmedLine.startsWith('#') ||
!trimmedLine.includes('=')
) {
return null
}
const variableName = trimmedLine.slice(0, trimmedLine.indexOf('=')).trim()
return /^[A-Z0-9_]+$/.test(variableName) ? variableName : null
}
async function readLegacyDotEnvVariables() {
if (!fs.existsSync(PROFILE_DOT_ENV_PATH)) {
return {}
}
const dotEnvContent = await fs.promises.readFile(PROFILE_DOT_ENV_PATH, 'utf8')
const values = {}
for (const line of dotEnvContent.split(ENV_LINE_SEPARATOR_PATTERN)) {
const variableName = getEnvVariableName(line)
if (!variableName) {
continue
}
values[variableName] = line.slice(line.indexOf('=') + 1).replace(/\r$/, '')
}
return values
}
function toBoolean(value) {
const normalizedValue = String(value || '').trim().toLowerCase()
if (normalizedValue === 'true') {
return true
}
if (normalizedValue === 'false') {
return false
}
return null
}
function toPort(value) {
const port = Number(value)
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null
}
function toContextFileList(value) {
return String(value || '')
.split(/[,;\n]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
function toOptionalString(value) {
const normalizedValue = String(value || '').trim()
return normalizedValue === '' ? null : normalizedValue
}
function toRoutingMode(value) {
const normalizedValue = String(value || '').trim()
return normalizedValue === 'workflow' ? 'controlled' : normalizedValue
}
function isEmptyList(value) {
return !Array.isArray(value) || value.length === 0
}
function readLegacyAccessConfig(configPath) {
if (!fs.existsSync(configPath)) {
return {
skills: [],
tools: []
}
}
try {
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'))
return {
skills: Array.isArray(parsed?.skills) ? parsed.skills : [],
tools: Array.isArray(parsed?.tools) ? parsed.tools : []
}
} catch {
return {
skills: [],
tools: []
}
}
}
function normalizeAccessList(values) {
return [...new Set(
values
.filter((value) => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0)
)].sort((firstValue, secondValue) => firstValue.localeCompare(secondValue))
}
function setDocumentValue(document, keyPath, value, shouldOverwrite) {
if (!shouldOverwrite && document.getIn(keyPath) !== undefined) {
return false
}
document.setIn(keyPath, value)
return true
}
function normalizeOptionalStringValues(document) {
let normalizedCount = 0
for (const keyPath of OPTIONAL_STRING_CONFIG_PATHS) {
const value = document.getIn(keyPath)
if (typeof value !== 'string' || value.trim() !== '') {
continue
}
document.setIn(keyPath, null)
normalizedCount += 1
}
return normalizedCount
}
async function migrateLegacyConfigValues(document, shouldOverwriteScalarValues) {
let migrationCount = 0
const envValues = await readLegacyDotEnvVariables()
const mappings = [
['LEON_LANG', ['language'], (value) => value.trim()],
['LEON_HOST', ['server', 'host'], (value) => value.trim()],
['LEON_PORT', ['server', 'port'], toPort],
['LEON_HTTP_PLUGINS', ['http_plugins', 'enabled'], toBoolean],
[
'LEON_HTTP_PLUGIN_ROOT_ROUTES',
['http_plugins', 'allow_root_routes'],
toBoolean
],
[
'LEON_HTTP_PLUGIN_AUTH',
['http_plugins', 'auth', 'enabled'],
toBoolean
],
['LEON_ROUTING_MODE', ['routing', 'mode'], toRoutingMode],
['LEON_MOOD', ['mood', 'mode'], (value) => value.trim()],
['LEON_LLM', ['llm', 'default'], toOptionalString],
['LEON_WORKFLOW_LLM', ['llm', 'workflow'], toOptionalString],
['LEON_AGENT_LLM', ['llm', 'agent'], toOptionalString],
[
'LEON_LLAMACPP_BASE_URL',
['llm', 'providers', 'llamacpp', 'base_url'],
(value) => value.trim()
],
[
'LEON_SGLANG_BASE_URL',
['llm', 'providers', 'sglang', 'base_url'],
(value) => value.trim()
],
['LEON_WAKE_WORD', ['voice', 'wake_word_enabled'], toBoolean],
['LEON_ASR', ['voice', 'asr', 'enabled'], toBoolean],
['LEON_ASR_PROVIDER', ['voice', 'asr', 'provider'], (value) => value.trim()],
['LEON_TTS', ['voice', 'tts', 'enabled'], toBoolean],
['LEON_TTS_PROVIDER', ['voice', 'tts', 'provider'], (value) => value.trim()],
['LEON_TIME_ZONE', ['time_zone'], toOptionalString],
['LEON_AFTER_SPEECH', ['after_speech_enabled'], toBoolean],
['LEON_TELEMETRY', ['telemetry_enabled'], toBoolean],
[
'LEON_PY_TCP_SERVER_HOST',
['python_tcp_server', 'host'],
(value) => value.trim()
],
['LEON_PY_TCP_SERVER_PORT', ['python_tcp_server', 'port'], toPort],
['LEON_DISABLED_CONTEXT_FILES', ['context', 'disabled_files'], toContextFileList]
]
for (const [envName, keyPath, normalizeValue] of mappings) {
if (!Object.hasOwn(envValues, envName)) {
continue
}
const normalizedValue = normalizeValue(envValues[envName])
if (
normalizedValue === null ||
normalizedValue === undefined ||
(typeof normalizedValue === 'string' && normalizedValue === '')
) {
continue
}
if (setDocumentValue(
document,
keyPath,
normalizedValue,
shouldOverwriteScalarValues
)) {
migrationCount += 1
}
}
const legacyAllowed = readLegacyAccessConfig(LEGACY_ALLOWED_PATH)
const legacyDisabled = readLegacyAccessConfig(LEGACY_DISABLED_PATH)
const accessMigrations = [
[
['availability', 'skills', 'allowed'],
normalizeAccessList(legacyAllowed.skills)
],
[
['availability', 'tools', 'allowed'],
normalizeAccessList(legacyAllowed.tools)
],
[
['availability', 'skills', 'disabled'],
normalizeAccessList(legacyDisabled.skills)
],
[
['availability', 'tools', 'disabled'],
normalizeAccessList(legacyDisabled.tools)
]
]
for (const [keyPath, values] of accessMigrations) {
if (values.length === 0 || !isEmptyList(document.getIn(keyPath))) {
continue
}
document.setIn(keyPath, values)
migrationCount += 1
}
return migrationCount + normalizeOptionalStringValues(document)
}
async function mergeMissingConfigKeys() {
const sampleContent = await fs.promises.readFile(CONFIG_SAMPLE_PATH, 'utf8')
const configContent = await fs.promises.readFile(CONFIG_PATH, 'utf8')
const sampleDocument = YAML.parseDocument(sampleContent)
const configDocument = YAML.parseDocument(configContent)
if (!isMap(sampleDocument.contents) || !isMap(configDocument.contents)) {
return 'config.yml: skipped invalid YAML document'
}
const migrationCount = await migrateLegacyConfigValues(configDocument, false)
const addedCount = mergeMissingMapPairs(
configDocument.contents,
sampleDocument.contents,
configDocument.schema
)
if (addedCount === 0 && migrationCount === 0) {
return 'config.yml: up-to-date'
}
await fs.promises.writeFile(
CONFIG_PATH,
withProfileSchemaReference(String(configDocument))
)
return `config.yml: +${addedCount + migrationCount} setting${
addedCount + migrationCount > 1 ? 's' : ''
}`
}
/**
* Create or merge the profile config.yml file.
*/
export default async () => {
const status = createSetupStatus('Preparing config.yml...').start()
await syncProfileConfigSchema()
if (!fs.existsSync(CONFIG_PATH)) {
await fs.promises.mkdir(path.dirname(CONFIG_PATH), { recursive: true })
const sampleContent = await fs.promises.readFile(CONFIG_SAMPLE_PATH, 'utf8')
const configDocument = YAML.parseDocument(
withProfileSchemaReference(sampleContent)
)
await migrateLegacyConfigValues(configDocument, true)
await fs.promises.writeFile(
CONFIG_PATH,
withProfileSchemaReference(String(configDocument))
)
status.succeed('config.yml: created')
return
}
status.succeed(await mergeMissingConfigKeys())
}
+43
View File
@@ -0,0 +1,43 @@
import fs from 'node:fs'
import path from 'node:path'
import { createSetupStatus } from './setup-status'
/**
* Set up Leon's core configuration
*/
export default () =>
new Promise(async (resolve) => {
const status = createSetupStatus('Configuring core...').start()
let hasUpdatedCoreConfig = false
const dir = 'core/config'
const list = async (dir) => {
const entities = await fs.promises.readdir(dir)
// Browse core config entities
for (let i = 0; i < entities.length; i += 1) {
const file = `${entities[i].replace('.sample.json', '.json')}`
// Recursive if the entity is a directory
const way = path.join(dir, entities[i])
if ((await fs.promises.stat(way)).isDirectory()) {
await list(way)
} else if (
entities[i].indexOf('.sample.json') !== -1 &&
!fs.existsSync(`${dir}/${file}`)
) {
// Clone config from sample in case there is no existing config file
fs.createReadStream(`${dir}/${entities[i]}`).pipe(
fs.createWriteStream(`${dir}/${file}`)
)
hasUpdatedCoreConfig = true
}
}
}
await list(dir)
status.succeed(
hasUpdatedCoreConfig ? 'Core: updated' : 'Core: ready'
)
resolve()
})
+118
View File
@@ -0,0 +1,118 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROFILE_DOT_ENV_PATH } from '@/constants'
import { createSetupStatus } from './setup-status'
const DOT_ENV_PATH = PROFILE_DOT_ENV_PATH
const DOT_ENV_SAMPLE_PATH = path.join(process.cwd(), '.env.sample')
const ENV_LINE_SEPARATOR_PATTERN = /\r?\n/
function splitEnvLines(content) {
return content.split(ENV_LINE_SEPARATOR_PATTERN)
}
function getEnvVariableName(line) {
const trimmedLine = line.trim()
if (
trimmedLine === '' ||
trimmedLine.startsWith('#') ||
!trimmedLine.includes('=')
) {
return null
}
const variableName = trimmedLine.slice(0, trimmedLine.indexOf('=')).trim()
return /^[A-Z0-9_]+$/.test(variableName) ? variableName : null
}
async function mergeMissingEnvVariables() {
const sampleContent = await fs.promises.readFile(DOT_ENV_SAMPLE_PATH, 'utf8')
const dotEnvContent = await fs.promises.readFile(DOT_ENV_PATH, 'utf8')
const dotEnvLines = splitEnvLines(dotEnvContent)
const existingVariableNames = new Set(
dotEnvLines
.map((line) => getEnvVariableName(line))
.filter((variableName) => variableName !== null)
)
const missingSampleLines = sampleContent
.split(ENV_LINE_SEPARATOR_PATTERN)
.filter((line) => {
const variableName = getEnvVariableName(line)
return variableName !== null && !existingVariableNames.has(variableName)
})
if (missingSampleLines.length === 0) {
return '.env: up-to-date'
}
const normalizedDotEnvContent = dotEnvContent.endsWith('\n')
? dotEnvContent
: `${dotEnvContent}\n`
const separator = normalizedDotEnvContent.trim() === '' ? '' : '\n'
const mergedContent = `${normalizedDotEnvContent}${separator}${missingSampleLines.join('\n')}\n`
await fs.promises.writeFile(DOT_ENV_PATH, mergedContent)
return `.env: +${missingSampleLines.length} variable${
missingSampleLines.length > 1 ? 's' : ''
}`
}
/**
* Upsert a single variable inside `.env`.
*/
export async function updateDotEnvVariable(variableName, value) {
const dotEnvContent = fs.existsSync(DOT_ENV_PATH)
? await fs.promises.readFile(DOT_ENV_PATH, 'utf8')
: ''
const dotEnvLines = dotEnvContent === '' ? [] : splitEnvLines(dotEnvContent)
const nextLine = `${variableName}=${value}`
let hasUpdatedLine = false
const updatedLines = dotEnvLines.map((line) => {
if (getEnvVariableName(line) !== variableName) {
return line
}
hasUpdatedLine = true
return nextLine
})
if (!hasUpdatedLine) {
updatedLines.push(nextLine)
}
const normalizedLines = updatedLines.filter(
(line, index, lines) => !(index === lines.length - 1 && line === '')
)
await fs.promises.mkdir(path.dirname(DOT_ENV_PATH), { recursive: true })
await fs.promises.writeFile(
DOT_ENV_PATH,
`${normalizedLines.join('\n')}\n`
)
}
/**
* Duplicate the .env.sample to .env file
*/
export default async () => {
const status = createSetupStatus('Preparing .env...').start()
if (!fs.existsSync(DOT_ENV_PATH)) {
await fs.promises.mkdir(path.dirname(DOT_ENV_PATH), { recursive: true })
await fs.promises.copyFile(DOT_ENV_SAMPLE_PATH, DOT_ENV_PATH)
status.succeed('.env: created')
return
}
status.succeed(await mergeMissingEnvVariables())
}
+36
View File
@@ -0,0 +1,36 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import { createSetupStatus } from './setup-status'
const GIT_PATH = path.join(process.cwd(), '.git')
const HUSKY_BIN_PATH = path.join(
process.cwd(),
'node_modules',
'.bin',
process.platform === 'win32' ? 'husky.cmd' : 'husky'
)
/**
* Install git hooks through Husky when Leon is inside a git checkout.
*/
export default async function setupGitHooks() {
if (process.env.GITHUB_ACTIONS === 'true' || !fs.existsSync(GIT_PATH)) {
return
}
const status = createSetupStatus('Setting up git hooks...').start()
try {
await execa(HUSKY_BIN_PATH, [], {
stdio: 'ignore'
})
status.succeed('Git hooks: ready')
} catch (error) {
status.fail('Failed to set up git hooks')
throw error
}
}
+187
View File
@@ -0,0 +1,187 @@
import { SetupUI } from './setup-ui'
const FIRST_JOKE_DELAY_MS = 8_000
const MIN_REPEAT_JOKE_DELAY_MS = 42_000
const MAX_REPEAT_JOKE_DELAY_MS = 120_000
const DOWNLOAD_PROGRESS_START_EVENT = 'leon:setup-download-progress:start'
const DOWNLOAD_PROGRESS_END_EVENT = 'leon:setup-download-progress:end'
const SETUP_JOKES = [
'I promise this part is more organized than my early prototypes.',
'Still working. No dramatic plot twist so far.',
'I am wiring things up. Metaphorically. Please do not hand me a screwdriver.',
'This is the quiet part where I become useful.',
'I would say this is exciting, but I am trying to appear composed.',
'Good software takes time. So do good noodles.',
'I am installing the serious parts so I can say unserious things later.',
'Everything is under control. That sounded more confident in my head.',
'If progress bars had feelings, this one would be very motivated.',
'I am moving at the speed of your internet and the patience of your storage drive.',
'No worries, I also judge installers that ask too many questions.',
'I checked. Turning it off and on again is not the current strategy.',
'This setup has fewer mysteries than most Wi-Fi problems.',
'I am building character. Also binaries.',
'If anything here looks complicated, I am trying to keep it emotionally simple.',
'This is going well. Suspiciously well, but still well.',
'I am not procrastinating. I am compiling.',
'The good news is I do not need coffee. The bad news is you still might.',
'I could pretend this is instant, but honesty is part of my charm.',
'Some assistants bring small talk. I bring dependencies.'
]
const SETUP_COMPLETION_JOKES = [
'I am installed, calibrated, and only slightly too pleased with myself.',
'That went well. I am trying not to make it my whole personality.',
'Setup complete. I would bow, but I do not have knees.',
'Everything is ready. Suspiciously ready, but ready.',
'I am officially prepared to be useful and occasionally dramatic.',
'Installation complete. Nobody had to jiggle the cable.',
'I made it through setup without becoming folklore.',
'That was the serious part. I can be charming again now.',
'I am ready. Please admire how professional this looks.',
'Setup finished. I would celebrate louder, but I respect your terminal.'
]
function shuffleJokes(jokes) {
const shuffledJokes = [...jokes]
for (let index = shuffledJokes.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1))
;[shuffledJokes[index], shuffledJokes[randomIndex]] = [
shuffledJokes[randomIndex],
shuffledJokes[index]
]
}
return shuffledJokes
}
const remainingJokes = shuffleJokes(SETUP_JOKES)
let areSetupJokesBoundToProgress = false
let activeDownloadProgressCount = 0
function getRandomRepeatDelay() {
return (
MIN_REPEAT_JOKE_DELAY_MS +
Math.floor(
Math.random() *
(MAX_REPEAT_JOKE_DELAY_MS - MIN_REPEAT_JOKE_DELAY_MS + 1)
)
)
}
function getNextSetupJoke() {
return remainingJokes.shift() || null
}
function getRandomSetupCompletionJoke() {
return (
SETUP_COMPLETION_JOKES[
Math.floor(Math.random() * SETUP_COMPLETION_JOKES.length)
] || null
)
}
function bindSetupJokesToDownloadProgress() {
if (areSetupJokesBoundToProgress) {
return
}
process.on(DOWNLOAD_PROGRESS_START_EVENT, () => {
activeDownloadProgressCount += 1
})
process.on(DOWNLOAD_PROGRESS_END_EVENT, () => {
activeDownloadProgressCount = Math.max(0, activeDownloadProgressCount - 1)
})
areSetupJokesBoundToProgress = true
}
/**
* Create a per-task joke scheduler so setup jokes only appear
* when a task has actually been running for a while.
*/
export function createSetupJokeScheduler() {
bindSetupJokesToDownloadProgress()
let timerId = null
let remainingDelayMs = FIRST_JOKE_DELAY_MS
let isRunning = false
let isFinished = false
const clearTimer = () => {
if (!timerId) {
return
}
clearTimeout(timerId)
timerId = null
}
const scheduleNextJoke = () => {
if (isFinished || !isRunning || timerId || remainingJokes.length === 0) {
return
}
timerId = setTimeout(() => {
timerId = null
if (isFinished || !isRunning) {
return
}
const nextJoke = getNextSetupJoke()
if (!nextJoke) {
return
}
if (activeDownloadProgressCount > 0 && process.stdout.isTTY) {
process.stdout.write('\n')
}
SetupUI.aside(nextJoke)
remainingDelayMs = getRandomRepeatDelay()
scheduleNextJoke()
}, remainingDelayMs)
}
const scheduler = {
start() {
if (isFinished) {
return
}
isRunning = true
scheduleNextJoke()
},
stop() {
if (isFinished || !isRunning) {
return
}
isRunning = false
clearTimer()
},
finish() {
isFinished = true
isRunning = false
clearTimer()
}
}
return scheduler
}
/**
* Tell one install-complete joke before the final success message.
*/
export function tellSetupCompletionJoke() {
const joke = getRandomSetupCompletionJoke()
if (!joke) {
return
}
SetupUI.aside(joke)
}
+519
View File
@@ -0,0 +1,519 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import { CPUArchitectures } from '@/types'
import {
LLAMACPP_BUILD_MANIFEST_PATH,
CMAKE_BIN_PATH,
LLAMACPP_BUILD_PATH,
LLAMACPP_ROOT_MANIFEST_PATH,
NINJA_BIN_PATH,
LLAMACPP_SOURCE_BUILD_PATH,
LLAMACPP_SOURCE_MANIFEST_PATH,
LLAMACPP_SOURCE_PATH,
LLAMACPP_PATH,
LLAMACPP_RELEASE_VERSION
} from '@/constants'
import { FileHelper } from '@/helpers/file-helper'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
import { SetupUI } from './setup-ui'
/**
* Download and set up llama.cpp
* 1. Resolve the release version from versions.json
* 2. Build from source on Linux + CUDA when required
* 3. Otherwise download the matching prebuilt archive
* 4. Keep the final binaries in their stable runtime directory
*/
const MOVE_FALLBACK_ERROR_CODES = new Set(['EXDEV', 'EPERM', 'EBUSY', 'EACCES'])
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
const LLAMA_SERVER_BINARY_NAME = SystemHelper.isWindows()
? 'llama-server.exe'
: 'llama-server'
const LLAMACPP_SOURCE_DOWNLOAD_MAX_ATTEMPTS = 2
const LLAMACPP_SOURCE_ARCHIVE_SETTLE_DELAY_MS = 500
const LLAMACPP_SOURCE_ARCHIVE_SETTLE_POLL_DELAY_MS = 250
const LLAMACPP_SOURCE_ARCHIVE_SETTLE_MAX_POLLS = 6
const LLAMACPP_RELEASE_BASE_URL = `https://github.com/ggml-org/llama.cpp/releases/download/${LLAMACPP_RELEASE_VERSION}`
const LLAMACPP_SOURCE_URL = `https://github.com/ggml-org/llama.cpp/archive/refs/tags/${LLAMACPP_RELEASE_VERSION}.tar.gz`
function readManifest() {
const manifestEntries = [
{
manifestPath: LLAMACPP_SOURCE_MANIFEST_PATH,
runtimeBasePath: LLAMACPP_SOURCE_PATH
},
{
manifestPath: LLAMACPP_BUILD_MANIFEST_PATH,
runtimeBasePath: LLAMACPP_BUILD_PATH
},
// Keep compatibility with the previous root-level manifest layout.
{
manifestPath: LLAMACPP_ROOT_MANIFEST_PATH,
runtimeBasePath: LLAMACPP_PATH
}
]
for (const manifestEntry of manifestEntries) {
if (!fs.existsSync(manifestEntry.manifestPath)) {
continue
}
try {
const manifest = JSON.parse(
fs.readFileSync(manifestEntry.manifestPath, 'utf8')
)
const runtimeDirectoryPath =
typeof manifest.runtimePath === 'string' && manifest.runtimePath.trim()
? path.join(manifestEntry.runtimeBasePath, manifest.runtimePath)
: null
return {
manifest,
runtimeDirectoryPath
}
} catch {
return null
}
}
return null
}
async function removePath(targetPath) {
await fs.promises.rm(targetPath, { recursive: true, force: true })
}
function wait(delayMs) {
return new Promise((resolve) => {
setTimeout(resolve, delayMs)
})
}
function isMoveFallbackError(error) {
return (
error instanceof Error &&
'code' in error &&
MOVE_FALLBACK_ERROR_CODES.has(error.code)
)
}
async function movePath(sourcePath, destinationPath) {
await removePath(destinationPath)
try {
await fs.promises.rename(sourcePath, destinationPath)
} catch (error) {
if (!isMoveFallbackError(error)) {
throw error
}
await fs.promises.cp(sourcePath, destinationPath, {
recursive: true,
force: true
})
await removePath(sourcePath)
}
}
function getBinaryPath(directoryPath) {
return path.join(directoryPath, LLAMA_SERVER_BINARY_NAME)
}
async function isExistingInstallationHealthy(runtimeDirectoryPath) {
if (!runtimeDirectoryPath) {
return false
}
const binaryPath = getBinaryPath(runtimeDirectoryPath)
if (!fs.existsSync(binaryPath)) {
return false
}
try {
await execa(binaryPath, ['--version'])
return true
} catch {
return false
}
}
async function findDirectoryContainingBinary(rootPath, binaryName) {
const entries = await fs.promises.readdir(rootPath, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(rootPath, entry.name)
if (entry.isDirectory()) {
const maybeBinDir = await findDirectoryContainingBinary(
entryPath,
binaryName
)
if (maybeBinDir) {
return maybeBinDir
}
} else if (entry.isFile() && entry.name === binaryName) {
return path.dirname(entryPath)
}
}
return null
}
async function cleanInstallDirectory() {
await fs.promises.mkdir(LLAMACPP_PATH, { recursive: true })
const entries = await fs.promises.readdir(LLAMACPP_PATH, {
withFileTypes: true
})
await Promise.all(
entries
.filter((entry) => entry.name !== 'versions.json')
.map((entry) => removePath(path.join(LLAMACPP_PATH, entry.name)))
)
}
async function writeManifest(
manifestPath,
runtimeBasePath,
runtimeDirectoryPath,
extraData = {}
) {
await fs.promises.mkdir(path.dirname(manifestPath), { recursive: true })
await FileHelper.createManifestFile(
manifestPath,
'llama.cpp',
LLAMACPP_RELEASE_VERSION,
{
runtimePath: path.relative(runtimeBasePath, runtimeDirectoryPath),
os: SystemHelper.getInformation().type,
architecture: SystemHelper.getInformation().cpuArchitecture,
...extraData
}
)
}
async function pruneSourceTree() {
const buildDirectoryPath = path.join(LLAMACPP_SOURCE_PATH, 'build')
const temporaryRetainedPath = await fs.promises.mkdtemp(
path.join(LLAMACPP_PATH, 'llama-cpp-build-bin-')
)
const retainedBuildBinPath = path.join(temporaryRetainedPath, 'bin')
await movePath(LLAMACPP_SOURCE_BUILD_PATH, retainedBuildBinPath)
await removePath(LLAMACPP_SOURCE_PATH)
await fs.promises.mkdir(buildDirectoryPath, { recursive: true })
await movePath(retainedBuildBinPath, LLAMACPP_SOURCE_BUILD_PATH)
await removePath(temporaryRetainedPath)
}
async function waitForArchiveToSettle(archivePath) {
// Give the download layer a short margin, then wait until the archive size
// stops changing before extracting it.
await wait(LLAMACPP_SOURCE_ARCHIVE_SETTLE_DELAY_MS)
let previousSize = -1
for (let poll = 0; poll < LLAMACPP_SOURCE_ARCHIVE_SETTLE_MAX_POLLS; poll += 1) {
const currentSize = (await fs.promises.stat(archivePath)).size
if (currentSize > 0 && currentSize === previousSize) {
return
}
previousSize = currentSize
await wait(LLAMACPP_SOURCE_ARCHIVE_SETTLE_POLL_DELAY_MS)
}
}
async function downloadAndExtractSourceArchive(sourceArchivePath) {
for (
let attempt = 1;
attempt <= LLAMACPP_SOURCE_DOWNLOAD_MAX_ATTEMPTS;
attempt += 1
) {
await Promise.all([
removePath(sourceArchivePath),
removePath(LLAMACPP_SOURCE_PATH)
])
await FileHelper.downloadFile(LLAMACPP_SOURCE_URL, sourceArchivePath, {
cliProgress: true,
// Keep the source archive download conservative to avoid corrupted
// segmented downloads before extraction.
parallelStreams: 1,
skipExisting: false
})
await waitForArchiveToSettle(sourceArchivePath)
try {
await FileHelper.extractArchive(sourceArchivePath, LLAMACPP_SOURCE_PATH, {
stripComponents: 1
})
return
} catch (error) {
if (attempt === LLAMACPP_SOURCE_DOWNLOAD_MAX_ATTEMPTS) {
throw error
}
await wait(LLAMACPP_SOURCE_ARCHIVE_SETTLE_POLL_DELAY_MS)
}
}
}
function getLinuxVulkanAssetName() {
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-ubuntu-vulkan-x64.tar.gz`
}
function getLinuxCPUAssetName() {
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-ubuntu-x64.tar.gz`
}
function getWindowsCPUAssetName() {
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-win-cpu-x64.zip`
}
function getPrebuiltAssetName(graphicsComputeAPI, hasGPU) {
if (SystemHelper.isMacOS()) {
return CPU_ARCH === CPUArchitectures.ARM64
? `llama-${LLAMACPP_RELEASE_VERSION}-bin-macos-arm64.tar.gz`
: `llama-${LLAMACPP_RELEASE_VERSION}-bin-macos-x64.tar.gz`
}
if (SystemHelper.isWindows()) {
if (hasGPU && graphicsComputeAPI === 'cuda') {
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-win-cuda-12.4-x64.zip`
}
if (hasGPU && graphicsComputeAPI === 'vulkan') {
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-win-vulkan-x64.zip`
}
return getWindowsCPUAssetName()
}
if (SystemHelper.isLinux() && CPU_ARCH === CPUArchitectures.X64) {
return hasGPU && graphicsComputeAPI === 'vulkan'
? getLinuxVulkanAssetName()
: getLinuxCPUAssetName()
}
throw new Error(
`Unsupported llama.cpp prebuilt platform: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
)
}
async function installPrebuilt(assetName, status, extraData = {}) {
const archivePath = path.join(LLAMACPP_PATH, assetName)
try {
await cleanInstallDirectory()
status.pause()
await FileHelper.downloadFile(
`${LLAMACPP_RELEASE_BASE_URL}/${assetName}`,
archivePath,
{
cliProgress: true,
parallelStreams: 3,
skipExisting: false
}
)
status.text = 'Installing llama.cpp...'
status.start()
await FileHelper.extractArchive(archivePath, LLAMACPP_BUILD_PATH)
// Use the directory that actually contains llama-server so we do not rely
// on a fixed archive layout across upstream release assets.
const binaryDirectoryPath = await findDirectoryContainingBinary(
LLAMACPP_BUILD_PATH,
LLAMA_SERVER_BINARY_NAME
)
if (!binaryDirectoryPath) {
throw new Error(
`Cannot find ${LLAMA_SERVER_BINARY_NAME} in extracted llama.cpp archive`
)
}
await writeManifest(
LLAMACPP_BUILD_MANIFEST_PATH,
LLAMACPP_BUILD_PATH,
binaryDirectoryPath,
{
installType: 'prebuilt',
...extraData
}
)
status.succeed(`llama.cpp ${LLAMACPP_RELEASE_VERSION} ready`)
} finally {
await removePath(archivePath)
}
}
async function buildFromSource(status) {
const sourceArchivePath = path.join(
LLAMACPP_PATH,
`llama.cpp-${LLAMACPP_RELEASE_VERSION}.tar.gz`
)
try {
await cleanInstallDirectory()
status.pause()
await downloadAndExtractSourceArchive(sourceArchivePath)
status.text = 'Building llama.cpp from source...'
status.start()
// Always use Leon-managed CMake for the source build.
await execa(
CMAKE_BIN_PATH,
[
'-B',
'build',
'-G',
'Ninja',
`-DCMAKE_MAKE_PROGRAM=${NINJA_BIN_PATH}`,
'-DGGML_CUDA=ON',
'-DLLAMA_BUILD_SERVER=ON',
'-DLLAMA_BUILD_TESTS=OFF',
'-DLLAMA_BUILD_EXAMPLES=OFF',
'-DCMAKE_BUILD_TYPE=Release',
'-DCMAKE_CUDA_ARCHITECTURES=native'
],
{
cwd: LLAMACPP_SOURCE_PATH
}
)
await execa(CMAKE_BIN_PATH, ['--build', 'build', '--target', 'llama-server', '-j'], {
cwd: LLAMACPP_SOURCE_PATH
})
if (
!fs.existsSync(getBinaryPath(LLAMACPP_SOURCE_BUILD_PATH))
) {
throw new Error(
`Cannot find ${LLAMA_SERVER_BINARY_NAME} after building llama.cpp`
)
}
// Retain only the runtime payload after a successful source build.
await pruneSourceTree()
await writeManifest(
LLAMACPP_SOURCE_MANIFEST_PATH,
LLAMACPP_SOURCE_PATH,
LLAMACPP_SOURCE_BUILD_PATH,
{
installType: 'source'
}
)
status.succeed(`llama.cpp ${LLAMACPP_RELEASE_VERSION} ready`)
} finally {
await removePath(sourceArchivePath)
}
}
export default async function setupLlamaCPP() {
const status = createSetupStatus('Setting up llama.cpp...').start()
const existingInstallation = readManifest()
const manifest = existingInstallation?.manifest
const runtimeDirectoryPath = existingInstallation?.runtimeDirectoryPath || null
if (
manifest?.version === LLAMACPP_RELEASE_VERSION &&
(await isExistingInstallationHealthy(runtimeDirectoryPath))
) {
status.succeed(`llama.cpp: ${LLAMACPP_RELEASE_VERSION}`)
return true
}
if (
manifest?.version === LLAMACPP_RELEASE_VERSION &&
runtimeDirectoryPath &&
fs.existsSync(getBinaryPath(runtimeDirectoryPath))
) {
status.pause()
SetupUI.warning('The current llama.cpp installation looks corrupted. Reinstalling it.')
}
let hasGPU = false
let graphicsComputeAPI = 'cpu'
try {
const { getLlama, LlamaLogLevel } = await Function(
'return import("node-llama-cpp")'
)()
const llama = await getLlama({
logLevel: LlamaLogLevel.disabled
})
hasGPU = await SystemHelper.hasGPU(llama)
graphicsComputeAPI = await SystemHelper.getGraphicsComputeAPI(llama)
} catch (error) {
status.pause()
SetupUI.warning(`Failed to inspect GPU support for llama.cpp setup: ${error}`)
}
if (SystemHelper.isLinux() && CPU_ARCH === CPUArchitectures.ARM64) {
// Linux ARM64 only supports the local setup when a CUDA build is possible.
if (!(hasGPU && graphicsComputeAPI === 'cuda')) {
status.succeed(
'Linux ARM64 local LLM support requires a CUDA GPU. Skipping llama.cpp setup.'
)
return false
}
try {
await buildFromSource(status)
return true
} catch {
if (status.isSpinning) {
status.fail('Failed to build llama.cpp from source')
}
return false
}
}
if (SystemHelper.isLinux() && hasGPU && graphicsComputeAPI === 'cuda') {
try {
await buildFromSource(status)
return true
} catch {
SetupUI.warning(
'I could not build llama.cpp from source, so I will use the Vulkan binaries instead.'
)
await installPrebuilt(getLinuxVulkanAssetName(), status, {
fallbackFromSourceBuild: true
})
return true
}
}
await installPrebuilt(
getPrebuiltAssetName(graphicsComputeAPI, hasGPU),
status
)
return true
}
+175
View File
@@ -0,0 +1,175 @@
import fs from 'node:fs'
import path from 'node:path'
import {
LLM_DIR_PATH,
LLM_MANIFEST_PATH,
LLM_HIGH_TIER_MINIMUM_TOTAL_VRAM,
LLM_MINIMUM_TOTAL_VRAM,
LLAMACPP_RELEASE_VERSION
} from '@/constants'
import { FileHelper } from '@/helpers/file-helper'
import { NetworkHelper } from '@/helpers/network-helper'
import inspectLocalAICapability from './local-ai-capability'
import { createSetupStatus } from './setup-status'
/**
* Download and set up the default local LLM
* 1. Check minimum hardware requirements
* 2. Select the default model according to total VRAM
* 3. Download the model from Hugging Face or mirror
* 4. Create manifest file with the default installed model path
*/
const DEFAULT_LLM_OPTIONS = [
{
minimumTotalVRAM: LLM_HIGH_TIER_MINIMUM_TOTAL_VRAM,
name: 'Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive',
version: 'Q4_K_M',
fileName: 'Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf',
downloadURL:
'https://huggingface.co/HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive/resolve/main/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf?download=true'
},
{
minimumTotalVRAM: LLM_MINIMUM_TOTAL_VRAM,
name: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive',
version: 'Q4_K_M',
fileName: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf',
downloadURL:
'https://huggingface.co/HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive/resolve/main/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf?download=true'
}
]
const LOCAL_AI_CHECK_TEXT = 'Checking local AI requirements...'
function readManifest() {
if (!fs.existsSync(LLM_MANIFEST_PATH)) {
return null
}
try {
return JSON.parse(fs.readFileSync(LLM_MANIFEST_PATH, 'utf8'))
} catch {
return null
}
}
function normalizeModelPath(modelPath) {
return path.resolve(modelPath)
}
async function removePreviousDefaultModel(previousModelPath, nextModelPath) {
if (!previousModelPath || previousModelPath === nextModelPath) {
return
}
const resolvedPreviousModelPath = normalizeModelPath(previousModelPath)
// Only delete the previous default model we installed under Leon's managed
// shared models directory.
if (!resolvedPreviousModelPath.startsWith(`${LLM_DIR_PATH}${path.sep}`)) {
return
}
await fs.promises.rm(resolvedPreviousModelPath, { force: true })
}
function getSelectedModel(totalVRAM) {
return (
DEFAULT_LLM_OPTIONS.find(
({ minimumTotalVRAM }) => totalVRAM >= minimumTotalVRAM
) || null
)
}
async function downloadLLM(selectedModel) {
const manifest = readManifest()
const targetPath = path.join(LLM_DIR_PATH, selectedModel.fileName)
const defaultInstalledLLMPath = normalizeModelPath(targetPath)
const isCurrentModelInstalled =
manifest?.name === selectedModel.name &&
manifest?.version === selectedModel.version &&
manifest?.defaultInstalledLLMPath === defaultInstalledLLMPath &&
fs.existsSync(targetPath)
if (isCurrentModelInstalled) {
return {
installed: false,
targetPath
}
}
await fs.promises.mkdir(LLM_DIR_PATH, { recursive: true })
await removePreviousDefaultModel(manifest?.defaultInstalledLLMPath, defaultInstalledLLMPath)
await fs.promises.rm(targetPath, { force: true })
const llmDownloadURL = await NetworkHelper.setHuggingFaceURL(
selectedModel.downloadURL
)
await FileHelper.downloadFile(llmDownloadURL, targetPath)
await FileHelper.createManifestFile(
LLM_MANIFEST_PATH,
selectedModel.name,
selectedModel.version,
{
llamaCPPVersion: LLAMACPP_RELEASE_VERSION,
defaultInstalledLLMPath
}
)
return {
installed: true,
targetPath
}
}
function getLocalAISummary(selectedModel, hardware) {
const gpuLabel = hardware.hasGPU ? hardware.gpuDeviceNames[0] : 'CPU'
const computeAPILabel = hardware.hasGPU
? String(hardware.graphicsComputeAPI).toUpperCase()
: 'CPU'
return `${selectedModel.name} (${selectedModel.version}, ${gpuLabel}, ${computeAPILabel}, ${hardware.totalVRAM} GB VRAM)`
}
export default async function setupLocalLLM(localAICapability) {
const status = createSetupStatus(LOCAL_AI_CHECK_TEXT).start()
const hardware = localAICapability || (await inspectLocalAICapability())
if (!hardware.canInstallLocalAI) {
status.succeed(
`Local LLM support requires at least ${LLM_MINIMUM_TOTAL_VRAM} GB of total VRAM and a supported GPU setup. Current total VRAM is ${hardware.totalVRAM} GB. I will continue without installing a default local LLM.`
)
return
}
const selectedModel = getSelectedModel(hardware.totalVRAM)
if (!selectedModel) {
status.succeed(
`No default local LLM matches the current total VRAM (${hardware.totalVRAM} GB).`
)
return
}
status.pause()
const { installed } = await downloadLLM(selectedModel)
status.text = 'Finalizing local AI...'
status.start()
if (installed) {
status.succeed(
`Local AI: ready - ${getLocalAISummary(selectedModel, hardware)}`
)
} else {
status.succeed(
`Local AI: ready - ${getLocalAISummary(selectedModel, hardware)}`
)
}
}
@@ -0,0 +1,95 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import {
CACHE_PATH,
NODE_VERSION,
PNPM_RUNTIME_BIN_PATH
} from '@/constants'
import { RuntimeHelper } from '@/helpers/runtime-helper'
import { createSetupStatus } from './setup-status'
const NODE_MODULES_PATH = path.join(process.cwd(), 'node_modules')
const NATIVE_NODE_MODULE_REBUILD_PACKAGES = [
'better-sqlite3'
]
const STAMP_FILE_PATH = path.join(
CACHE_PATH,
'setup',
'.last-native-node-modules-rebuild'
)
function getPackageManifestPath(packageName) {
return path.join(NODE_MODULES_PATH, packageName, 'package.json')
}
function getInstalledRebuildPackages() {
return NATIVE_NODE_MODULE_REBUILD_PACKAGES.filter((packageName) =>
fs.existsSync(getPackageManifestPath(packageName))
)
}
async function isRebuildCurrent(packageNames) {
if (!fs.existsSync(STAMP_FILE_PATH)) {
return false
}
const stampValue = (await fs.promises.readFile(STAMP_FILE_PATH, 'utf8')).trim()
if (stampValue !== NODE_VERSION) {
return false
}
const stampStat = await fs.promises.stat(STAMP_FILE_PATH)
for (const packageName of packageNames) {
const packageManifestStat = await fs.promises.stat(
getPackageManifestPath(packageName)
)
if (packageManifestStat.mtimeMs > stampStat.mtimeMs) {
return false
}
}
return true
}
/**
* Rebuild native modules that must match Leon's managed Node.js ABI.
*/
export default async function setupNativeNodeModules() {
if (!fs.existsSync(NODE_MODULES_PATH)) {
return
}
const installedPackages = getInstalledRebuildPackages()
if (installedPackages.length === 0) {
return
}
const status = createSetupStatus('Rebuilding native Node.js modules...').start()
if (await isRebuildCurrent(installedPackages)) {
status.succeed('Native Node.js modules: up-to-date')
return
}
await execa(PNPM_RUNTIME_BIN_PATH, [
'rebuild',
...installedPackages
], {
cwd: process.cwd(),
env: RuntimeHelper.getManagedNodeEnvironment()
})
await fs.promises.mkdir(path.dirname(STAMP_FILE_PATH), { recursive: true })
await fs.promises.writeFile(STAMP_FILE_PATH, NODE_VERSION)
status.succeed('Native Node.js modules: ready')
}
+123
View File
@@ -0,0 +1,123 @@
import fs from 'node:fs'
import path from 'node:path'
import { CPUArchitectures } from '@/types'
import {
NINJA_BIN_PATH,
NINJA_INSTALL_PATH,
NINJA_MANIFEST_PATH,
NINJA_PATH,
NINJA_VERSION
} from '@/constants'
import { FileHelper } from '@/helpers/file-helper'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
/**
* Download and set up Leon-managed Ninja
* 1. Resolve the pinned version from versions.json
* 2. Download the matching Linux archive for the current architecture
* 3. Extract it into bin/ninja/ninja/
* 4. Always use this Ninja binary for local source builds
*/
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
function readManifest() {
if (!fs.existsSync(NINJA_MANIFEST_PATH)) {
return null
}
try {
return JSON.parse(fs.readFileSync(NINJA_MANIFEST_PATH, 'utf8'))
} catch {
return null
}
}
async function cleanInstallDirectory() {
await fs.promises.mkdir(NINJA_PATH, { recursive: true })
const entries = await fs.promises.readdir(NINJA_PATH, {
withFileTypes: true
})
await Promise.all(
entries
.filter((entry) => entry.name !== 'versions.json')
.map((entry) =>
fs.promises.rm(path.join(NINJA_PATH, entry.name), {
recursive: true,
force: true
})
)
)
}
function getDownloadURL() {
if (CPU_ARCH === CPUArchitectures.X64) {
return `https://github.com/ninja-build/ninja/releases/download/v${NINJA_VERSION}/ninja-linux.zip`
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return `https://github.com/ninja-build/ninja/releases/download/v${NINJA_VERSION}/ninja-linux-aarch64.zip`
}
throw new Error(`Unsupported Linux architecture for Ninja: ${CPU_ARCH}`)
}
export default async function setupNinja() {
if (!SystemHelper.isLinux()) {
return
}
const status = createSetupStatus('Downloading and setting up Ninja...').start()
const manifest = readManifest()
if (manifest?.version === NINJA_VERSION && fs.existsSync(NINJA_BIN_PATH)) {
status.succeed(`Ninja: ${NINJA_VERSION}`)
return
}
const archivePath = path.join(NINJA_PATH, `ninja-${NINJA_VERSION}.zip`)
await cleanInstallDirectory()
try {
status.pause()
await FileHelper.downloadFile(getDownloadURL(), archivePath, {
cliProgress: true,
parallelStreams: 3,
skipExisting: false
})
status.text = 'Installing Ninja...'
status.start()
await FileHelper.extractArchive(archivePath, NINJA_INSTALL_PATH)
await fs.promises.chmod(NINJA_BIN_PATH, 0o755)
if (!fs.existsSync(NINJA_BIN_PATH)) {
throw new Error(`Cannot find Ninja binary at "${NINJA_BIN_PATH}"`)
}
await Promise.all([
fs.promises.rm(archivePath, { force: true }),
FileHelper.createManifestFile(NINJA_MANIFEST_PATH, 'ninja', NINJA_VERSION, {
os: SystemHelper.getInformation().type,
architecture: SystemHelper.getInformation().cpuArchitecture
})
])
status.succeed(`Ninja: ${NINJA_VERSION}`)
} catch (error) {
await fs.promises.rm(archivePath, { force: true })
if (status.isSpinning) {
status.fail('Failed to set up Ninja')
}
throw new Error(`Failed to set up Ninja: ${error}`)
}
}
+66
View File
@@ -0,0 +1,66 @@
import path from 'node:path'
import {
NODE_INSTALL_PATH,
NODE_MANIFEST_PATH,
NODE_VERSION
} from '@/constants'
import { CPUArchitectures } from '@/types'
import { SystemHelper } from '@/helpers/system-helper'
import { setupRuntimeBinary } from './setup-runtime-binary'
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
function getBinaryPath() {
return SystemHelper.isWindows()
? path.join(NODE_INSTALL_PATH, 'node.exe')
: path.join(NODE_INSTALL_PATH, 'bin', 'node')
}
function getAssetFileName() {
if (SystemHelper.isLinux()) {
if (CPU_ARCH === CPUArchitectures.X64) {
return `node-v${NODE_VERSION}-linux-x64.tar.xz`
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return `node-v${NODE_VERSION}-linux-arm64.tar.xz`
}
}
if (SystemHelper.isMacOS()) {
if (CPU_ARCH === CPUArchitectures.X64) {
return `node-v${NODE_VERSION}-darwin-x64.tar.xz`
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return `node-v${NODE_VERSION}-darwin-arm64.tar.xz`
}
}
if (SystemHelper.isWindows()) {
return CPU_ARCH === CPUArchitectures.ARM64
? `node-v${NODE_VERSION}-win-arm64.zip`
: `node-v${NODE_VERSION}-win-x64.zip`
}
throw new Error(
`Unsupported platform for Node.js: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
)
}
export default async function setupNode() {
const assetFileName = getAssetFileName()
await setupRuntimeBinary({
name: 'Node.js',
version: NODE_VERSION,
basePath: NODE_INSTALL_PATH,
installPath: NODE_INSTALL_PATH,
manifestPath: NODE_MANIFEST_PATH,
binaryPath: getBinaryPath(),
downloadURL: `https://nodejs.org/dist/v${NODE_VERSION}/${assetFileName}`,
archiveFileName: assetFileName
})
}
+69
View File
@@ -0,0 +1,69 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import {
NODEJS_BRIDGE_ROOT_PATH,
CACHE_PATH,
PNPM_RUNTIME_BIN_PATH
} from '@/constants'
import { RuntimeHelper } from '@/helpers/runtime-helper'
import { createSetupStatus } from './setup-status'
const STAMP_FILE_PATH = path.join(
CACHE_PATH,
'setup',
'.last-nodejs-bridge-deps-sync'
)
const PACKAGE_JSON_PATH = path.join(NODEJS_BRIDGE_ROOT_PATH, 'package.json')
/**
* Re-sync the bridge only when its own dependency manifest changed.
*/
const isSyncCurrent = async () => {
if (!fs.existsSync(STAMP_FILE_PATH) || !fs.existsSync(PACKAGE_JSON_PATH)) {
return false
}
const [stampStat, packageStat] = await Promise.all([
fs.promises.stat(STAMP_FILE_PATH),
fs.promises.stat(PACKAGE_JSON_PATH)
])
return packageStat.mtimeMs <= stampStat.mtimeMs
}
/**
* The Node bridge still has its own SDK/runtime dependencies, so keep them in
* sync once at setup time instead of installing them on every skill run.
*/
export default async function setupNodejsBridgeEnv() {
if (!(fs.existsSync(PACKAGE_JSON_PATH))) {
return
}
const status = createSetupStatus('Setting up Node.js bridge...').start()
if (await isSyncCurrent()) {
status.succeed('Node.js bridge: up-to-date')
return
}
await execa(PNPM_RUNTIME_BIN_PATH, [
'install',
'--ignore-workspace',
'--config.confirmModulesPurge=false',
'--lockfile=false'
], {
cwd: NODEJS_BRIDGE_ROOT_PATH,
env: RuntimeHelper.getManagedNodeEnvironment()
})
await fs.promises.mkdir(path.dirname(STAMP_FILE_PATH), { recursive: true })
await fs.promises.writeFile(STAMP_FILE_PATH, `${Date.now()}`)
status.succeed('Node.js bridge: ready')
}
+358
View File
@@ -0,0 +1,358 @@
import fs from 'node:fs'
import path from 'node:path'
import {
NVIDIA_LIBS_PATH,
NVIDIA_CUBLAS_PATH,
NVIDIA_CUDNN_PATH,
NVIDIA_CUDA_CUDART_PATH,
NVIDIA_CUDA_CUPTI_PATH,
NVIDIA_CUSPARSE_PATH,
NVIDIA_CUSPARSELT_PATH,
NVIDIA_CUSPARSE_FULL_PATH,
NVIDIA_NCCL_PATH,
NVIDIA_NVSHMEM_PATH,
NVIDIA_NVJITLINK_PATH,
NVIDIA_CUBLAS_MANIFEST_PATH,
NVIDIA_CUDNN_MANIFEST_PATH,
NVIDIA_CUDA_CUDART_MANIFEST_PATH,
NVIDIA_CUDA_CUPTI_MANIFEST_PATH,
NVIDIA_CUSPARSE_MANIFEST_PATH,
NVIDIA_CUSPARSE_FULL_MANIFEST_PATH,
NVIDIA_NCCL_MANIFEST_PATH,
NVIDIA_NVSHMEM_MANIFEST_PATH,
NVIDIA_NVJITLINK_MANIFEST_PATH,
NVIDIA_CUDA_VERSION,
NVIDIA_CUBLAS_VERSION,
NVIDIA_CUDNN_VERSION,
NVIDIA_CUDA_CUDART_VERSION,
NVIDIA_CUDA_CUPTI_VERSION,
NVIDIA_CUSPARSE_VERSION,
NVIDIA_CUSPARSE_FULL_VERSION,
NVIDIA_NCCL_VERSION,
NVIDIA_NVSHMEM_VERSION,
NVIDIA_NVJITLINK_VERSION,
PYTORCH_NVIDIA_PATH,
PYTORCH_TORCH_PATH
} from '@/constants'
import { FileHelper } from '@/helpers/file-helper'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
const { type: OS_TYPE, cpuArchitecture: CPU_ARCH } =
SystemHelper.getInformation()
const NVIDIA_LIBRARY_LABELS = {
cublas: 'cuBLAS',
cudnn: 'cuDNN',
cuda_cudart: 'CUDA Runtime',
cuda_cupti: 'CUDA CUPTI',
cusparse: 'cuSPARSE Lt',
cusparse_full: 'cuSPARSE',
nccl: 'NCCL',
nvshmem: 'NVSHMEM',
nvjitlink: 'nvJitLink'
}
/**
* Map CPU architecture to NVIDIA's architecture naming convention
*/
function mapToNvidiaArch(cpuArch) {
// Map Node.js process.arch values to NVIDIA naming
if (cpuArch === 'arm64' || cpuArch === 'aarch64') {
return 'aarch64'
}
if (cpuArch === 'x64' || cpuArch === 'x86_64') {
return 'x86_64'
}
return 'x86_64'
}
async function ensureDirectoryLink(linkPath, targetPath) {
if (!fs.existsSync(targetPath)) {
return
}
await fs.promises.rm(linkPath, { recursive: true, force: true })
await fs.promises.mkdir(path.dirname(linkPath), { recursive: true })
const relativeTarget = path.relative(path.dirname(linkPath), targetPath)
const linkType = SystemHelper.isWindows() ? 'junction' : 'dir'
await fs.promises.symlink(relativeTarget, linkPath, linkType)
}
async function ensureCompatibilityLinks() {
await ensureDirectoryLink(NVIDIA_CUSPARSELT_PATH, NVIDIA_CUSPARSE_PATH)
await ensureDirectoryLink(
path.join(NVIDIA_LIBS_PATH, 'cuda_runtime'),
NVIDIA_CUDA_CUDART_PATH
)
if (fs.existsSync(PYTORCH_TORCH_PATH)) {
await ensureDirectoryLink(PYTORCH_NVIDIA_PATH, NVIDIA_LIBS_PATH)
}
}
/**
* Read manifest file to get installed version
*/
function readManifest(manifestPath) {
if (!fs.existsSync(manifestPath)) {
return null
}
try {
const content = fs.readFileSync(manifestPath, 'utf-8')
return JSON.parse(content)
} catch {
return null
}
}
/**
* Get download URL for NVIDIA libraries
*/
function getNVIDIADownloadURL(library, version) {
const ext = SystemHelper.isWindows() ? 'zip' : 'tar.xz'
const arch = mapToNvidiaArch(CPU_ARCH)
// NVIDIA CDN URLs for CUDA libraries and more
if (library === 'cublas') {
return `https://developer.download.nvidia.com/compute/cuda/redist/libcublas/${OS_TYPE}-${arch}/libcublas-${OS_TYPE}-${arch}-${version}-archive.${ext}`
} else if (library === 'cudnn') {
return `https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/${OS_TYPE}-${arch}/cudnn-${OS_TYPE}-${arch}-${version}_cuda${NVIDIA_CUDA_VERSION}-archive.${ext}`
} else if (library === 'cuda_cudart') {
return `https://developer.download.nvidia.com/compute/cuda/redist/cuda_cudart/${OS_TYPE}-${arch}/cuda_cudart-${OS_TYPE}-${arch}-${version}-archive.${ext}`
} else if (library === 'cuda_cupti') {
return `https://developer.download.nvidia.com/compute/cuda/redist/cuda_cupti/${OS_TYPE}-${arch}/cuda_cupti-${OS_TYPE}-${arch}-${version}-archive.${ext}`
} else if (library === 'cusparse') {
return `https://developer.download.nvidia.com/compute/cusparselt/redist/libcusparse_lt/${OS_TYPE}-${arch}/libcusparse_lt-${OS_TYPE}-${arch}-${version}_cuda${NVIDIA_CUDA_VERSION}-archive.${ext}`
} else if (library === 'cusparse_full') {
return `https://developer.download.nvidia.com/compute/cuda/redist/libcusparse/${OS_TYPE}-${arch}/libcusparse-${OS_TYPE}-${arch}-${version}-archive.${ext}`
} else if (library === 'nccl') {
// NCCL is only available on Linux x86_64
if (!SystemHelper.isLinux() || arch !== 'x86_64') {
throw new Error('NCCL is only available on Linux x86_64')
}
return `https://developer.download.nvidia.com/compute/nccl/redist/nccl/${OS_TYPE}-${arch}/nccl-${OS_TYPE}-${arch}-${version}-archive.${ext}`
} else if (library === 'nvshmem') {
// NVSHMEM is only available on Linux x86_64
if (!SystemHelper.isLinux() || arch !== 'x86_64') {
throw new Error('NVSHMEM is only available on Linux x86_64')
}
return `https://developer.download.nvidia.com/compute/nvshmem/redist/libnvshmem/${OS_TYPE}-${arch}/libnvshmem-${OS_TYPE}-${arch}-${version}_cuda${NVIDIA_CUDA_VERSION}-archive.${ext}`
} else if (library === 'nvjitlink') {
return `https://developer.download.nvidia.com/compute/cuda/redist/libnvjitlink/${OS_TYPE}-${arch}/libnvjitlink-${OS_TYPE}-${arch}-${version}-archive.${ext}`
}
}
/**
* Install NVIDIA libraries if needed
*/
async function installNVIDIALibrary(
library,
requiredVersion,
targetPath,
manifestPath
) {
const libraryLabel = NVIDIA_LIBRARY_LABELS[library] || library
const status = createSetupStatus(`Setting up ${libraryLabel}...`).start()
const manifest = readManifest(manifestPath)
if (!manifest || manifest.version !== requiredVersion) {
const ext = SystemHelper.isWindows() ? 'zip' : 'tar.xz'
const archivePath = path.join(
NVIDIA_LIBS_PATH,
`${library}-${requiredVersion}.${ext}`
)
// Clean up old version
await fs.promises.rm(targetPath, { recursive: true, force: true })
await fs.promises.rm(archivePath, { force: true })
// Create target directory
await fs.promises.mkdir(targetPath, { recursive: true })
try {
const downloadURL = getNVIDIADownloadURL(library, requiredVersion)
status.pause()
await FileHelper.downloadFile(downloadURL, archivePath, {
cliProgress: true,
parallelStreams: 3,
skipExisting: false
})
status.text = `Installing ${libraryLabel}...`
status.start()
// Extract archive using unified method
await FileHelper.extractArchive(archivePath, targetPath, {
stripComponents: 1
})
// Clean up and create manifest
await Promise.all([
fs.promises.rm(archivePath, { force: true }),
FileHelper.createManifestFile(manifestPath, library, requiredVersion, {
os: SystemHelper.getInformation().type,
architecture: SystemHelper.getInformation().cpuArchitecture
})
])
status.succeed(`${libraryLabel} ${requiredVersion} ready`)
} catch (error) {
if (status.isSpinning) {
status.fail(`Failed to set up ${libraryLabel}`)
}
throw new Error(
`${libraryLabel} may require manual download from https://developer.nvidia.com/cuda-downloads: ${error}`
)
}
} else {
status.succeed(`${libraryLabel}: ${requiredVersion}`)
}
}
/**
* Main setup function
*/
async function setupNVIDIALibs() {
// Skip on macOS since there is no CUDA involved
if (SystemHelper.isMacOS()) {
return
}
const status = createSetupStatus('Checking CUDA runtime support...').start()
try {
const { getLlama, LlamaLogLevel } = await Function(
'return import("node-llama-cpp")'
)()
const llama = await getLlama({
logLevel: LlamaLogLevel.disabled
})
const hasGPU = await SystemHelper.hasGPU(llama)
if (!hasGPU) {
status.succeed('CUDA runtime: skipped')
return
}
status.succeed('CUDA runtime: detected')
// Install/update cuBLAS
await installNVIDIALibrary(
'cublas',
NVIDIA_CUBLAS_VERSION,
NVIDIA_CUBLAS_PATH,
NVIDIA_CUBLAS_MANIFEST_PATH
)
// Install/update cuDNN
await installNVIDIALibrary(
'cudnn',
NVIDIA_CUDNN_VERSION,
NVIDIA_CUDNN_PATH,
NVIDIA_CUDNN_MANIFEST_PATH
)
// Install/update CUDA cudart runtime
await installNVIDIALibrary(
'cuda_cudart',
NVIDIA_CUDA_CUDART_VERSION,
NVIDIA_CUDA_CUDART_PATH,
NVIDIA_CUDA_CUDART_MANIFEST_PATH
)
// Install/update CUDA CUPTI
await installNVIDIALibrary(
'cuda_cupti',
NVIDIA_CUDA_CUPTI_VERSION,
NVIDIA_CUDA_CUPTI_PATH,
NVIDIA_CUDA_CUPTI_MANIFEST_PATH
)
// Install/update cuSPARSE-Lt (Linux only, both x86_64 and aarch64)
if (SystemHelper.isLinux()) {
try {
await installNVIDIALibrary(
'cusparse',
NVIDIA_CUSPARSE_VERSION,
NVIDIA_CUSPARSE_PATH,
NVIDIA_CUSPARSE_MANIFEST_PATH
)
} catch (error) {
status.warn(`cuSPARSE Lt skipped: ${error.message}`)
}
}
// Install/update cuSPARSE (Linux only, both x86_64 and aarch64)
if (SystemHelper.isLinux()) {
try {
await installNVIDIALibrary(
'cusparse_full',
NVIDIA_CUSPARSE_FULL_VERSION,
NVIDIA_CUSPARSE_FULL_PATH,
NVIDIA_CUSPARSE_FULL_MANIFEST_PATH
)
} catch (error) {
status.warn(`cuSPARSE skipped: ${error.message}`)
}
}
// Install/update nvJitLink (Linux only)
if (SystemHelper.isLinux()) {
try {
await installNVIDIALibrary(
'nvjitlink',
NVIDIA_NVJITLINK_VERSION,
NVIDIA_NVJITLINK_PATH,
NVIDIA_NVJITLINK_MANIFEST_PATH
)
} catch (error) {
status.warn(`nvJitLink skipped: ${error.message}`)
}
}
// Install/update NCCL (Linux x86_64 only)
if (SystemHelper.isLinux() && mapToNvidiaArch(CPU_ARCH) === 'x86_64') {
try {
await installNVIDIALibrary(
'nccl',
NVIDIA_NCCL_VERSION,
NVIDIA_NCCL_PATH,
NVIDIA_NCCL_MANIFEST_PATH
)
} catch (error) {
status.warn(`NCCL skipped: ${error.message}`)
}
}
// Install/update NVSHMEM (Linux x86_64 only)
if (SystemHelper.isLinux() && mapToNvidiaArch(CPU_ARCH) === 'x86_64') {
try {
await installNVIDIALibrary(
'nvshmem',
NVIDIA_NVSHMEM_VERSION,
NVIDIA_NVSHMEM_PATH,
NVIDIA_NVSHMEM_MANIFEST_PATH
)
} catch (error) {
status.warn(`NVSHMEM skipped: ${error.message}`)
}
}
await ensureCompatibilityLinks()
} catch (error) {
if (status.isSpinning) {
status.fail('Failed to inspect CUDA runtime support')
}
throw error
}
}
export default setupNVIDIALibs
+66
View File
@@ -0,0 +1,66 @@
import path from 'node:path'
import {
PNPM_INSTALL_PATH,
PNPM_MANIFEST_PATH,
PNPM_VERSION
} from '@/constants'
import { CPUArchitectures } from '@/types'
import { SystemHelper } from '@/helpers/system-helper'
import { setupRuntimeBinary } from './setup-runtime-binary'
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
function getBinaryPath() {
return SystemHelper.isWindows()
? path.join(PNPM_INSTALL_PATH, 'pnpm.exe')
: path.join(PNPM_INSTALL_PATH, 'pnpm')
}
function getAssetFileName() {
if (SystemHelper.isLinux()) {
if (CPU_ARCH === CPUArchitectures.X64) {
return 'pnpm-linux-x64.tar.gz'
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return 'pnpm-linux-arm64.tar.gz'
}
}
if (SystemHelper.isMacOS()) {
if (CPU_ARCH === CPUArchitectures.X64) {
throw new Error('pnpm 11 no longer ships a standalone macOS x64 binary')
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return 'pnpm-darwin-arm64.tar.gz'
}
}
if (SystemHelper.isWindows()) {
return CPU_ARCH === CPUArchitectures.ARM64
? 'pnpm-win32-arm64.zip'
: 'pnpm-win32-x64.zip'
}
throw new Error(
`Unsupported platform for pnpm: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
)
}
export default async function setupPNPM() {
const assetFileName = getAssetFileName()
await setupRuntimeBinary({
name: 'pnpm',
version: PNPM_VERSION,
basePath: PNPM_INSTALL_PATH,
installPath: PNPM_INSTALL_PATH,
manifestPath: PNPM_MANIFEST_PATH,
binaryPath: getBinaryPath(),
downloadURL: `https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/${assetFileName}`,
archiveFileName: assetFileName
})
}
+122
View File
@@ -0,0 +1,122 @@
import { SetupUI, setupConsola } from './setup-ui'
import setupRemoteLLM from './setup-remote-llm'
/**
* Ask lightweight setup questions so users can skip optional downloads.
*/
export default async function setupPreferences(
localAICapability,
existingLLMChoice,
localAISetupState,
voiceSetupState
) {
const defaultPreferences = {
setupLocalAI: localAICapability.canInstallLocalAI,
setupVoice: false,
remoteLLMProvider: '',
remoteLLMModel: '',
remoteLLMAPIKeyEnv: '',
remoteLLMAPIKey: ''
}
if (
process.env.IS_DOCKER === 'true' ||
!process.stdin.isTTY ||
!process.stdout.isTTY
) {
if (!localAICapability.canInstallLocalAI) {
SetupUI.info(
'This computer is not a good fit for local AI or voice features, so I will set up the essentials.'
)
}
return defaultPreferences
}
const hasConfiguredLocalAI =
existingLLMChoice.hasResolvedChoice &&
existingLLMChoice.setupLocalAI &&
existingLLMChoice.targetType !== 'defaultLocal'
const hasInstalledLocalAI =
localAISetupState.isInstalled || hasConfiguredLocalAI
const hasInstalledVoice = voiceSetupState.isInstalled
const hasUsableExistingLLMChoice =
existingLLMChoice.hasResolvedChoice &&
(existingLLMChoice.targetType !== 'defaultLocal' || hasInstalledLocalAI)
if (hasUsableExistingLLMChoice) {
SetupUI.info(
`I found your current AI setup, so I will keep using it: ${existingLLMChoice.label}`
)
}
if (hasInstalledLocalAI) {
SetupUI.info(
`Local AI is already installed${
localAISetupState.label ? `: ${localAISetupState.label}` : ''
}`
)
}
if (hasInstalledVoice) {
SetupUI.info('Voice is already installed, so I will keep it updated.')
}
if (!localAICapability.canInstallLocalAI) {
if (!hasUsableExistingLLMChoice) {
return {
...defaultPreferences,
setupVoice: hasInstalledVoice,
...(await setupRemoteLLM())
}
}
return {
...defaultPreferences,
setupLocalAI: hasInstalledLocalAI,
setupVoice: hasInstalledVoice
}
}
if (!hasInstalledLocalAI || !hasInstalledVoice) {
SetupUI.info(
'I just have a few quick questions so I can set things up the way you want.'
)
}
const setupLocalAI = hasInstalledLocalAI
? true
: await setupConsola.prompt('Do you want me to set up local AI now?', {
type: 'confirm',
initial: true,
cancel: 'default'
})
let remoteLLMPreferences = {
remoteLLMProvider: '',
remoteLLMModel: '',
remoteLLMAPIKeyEnv: '',
remoteLLMAPIKey: ''
}
if (!setupLocalAI && !hasUsableExistingLLMChoice) {
remoteLLMPreferences = await setupRemoteLLM()
}
const setupVoice = hasInstalledVoice
? true
: await setupConsola.prompt('Do you want to talk to me with your voice now?', {
type: 'confirm',
initial: false,
cancel: 'default'
})
const preferences = {
...defaultPreferences,
setupLocalAI,
setupVoice,
...remoteLLMPreferences
}
return preferences
}
+14
View File
@@ -0,0 +1,14 @@
import { PYTHON_BRIDGE_SRC_PATH } from '@/constants'
import { setupPythonProjectEnv } from './setup-python-project-env'
/**
* Sync the Python bridge runtime environment from its `pyproject.toml`.
*/
export default async function setupPythonBridgeEnv() {
await setupPythonProjectEnv({
name: 'Python bridge',
projectPath: PYTHON_BRIDGE_SRC_PATH,
stampFileName: '.last-python-bridge-deps-sync'
})
}
+181
View File
@@ -0,0 +1,181 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import {
PYTHON_RUNTIME_BIN_PATH,
UV_RUNTIME_BIN_PATH
} from '@/constants'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
const PYPROJECT_FILE_NAME = 'pyproject.toml'
/**
* Detect whether a command failed to spawn because the executable was missing.
*/
function isExecutableMissingError(error) {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
)
}
/**
* Make Python runtime resolution failures actionable during setup.
*/
async function ensurePythonRuntimeAvailable(name) {
try {
await execa(PYTHON_RUNTIME_BIN_PATH, ['--version'])
} catch (error) {
if (!isExecutableMissingError(error)) {
throw error
}
throw new Error(
`${name}: unable to resolve Leon's managed Python runtime at "${PYTHON_RUNTIME_BIN_PATH}". Make sure the managed Python setup completed successfully, or set \`LEON_PYTHON_PATH\` to an explicit Python executable if you intentionally want to override it.`,
{ cause: error }
)
}
}
/**
* Make uv runtime resolution failures actionable during setup.
*/
async function ensureUVRuntimeAvailable(name) {
try {
await execa(UV_RUNTIME_BIN_PATH, ['--version'])
} catch (error) {
if (!isExecutableMissingError(error)) {
throw error
}
throw new Error(
`${name}: unable to resolve Leon's managed uv runtime at "${UV_RUNTIME_BIN_PATH}". Make sure the managed uv setup completed successfully, or set \`LEON_UV_PATH\` to an explicit uv executable if you intentionally want to override it.`,
{ cause: error }
)
}
}
/**
* Read the dependency list from a Python project's `pyproject.toml` using the
* managed Python runtime and the standard-library `tomllib` parser.
*/
export async function getPyprojectDependencies(projectPath) {
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
if (!fs.existsSync(pyprojectPath)) {
return []
}
const readerScript = [
'import json',
'import sys',
'import tomllib',
'from pathlib import Path',
'data = tomllib.loads(Path(sys.argv[1]).read_text())',
'deps = data.get("project", {}).get("dependencies", [])',
'print(json.dumps(deps))'
].join('; ')
const result = await execa(PYTHON_RUNTIME_BIN_PATH, [
'-c',
readerScript,
pyprojectPath
])
return JSON.parse(result.stdout)
}
/**
* Resolve the Python executable path inside a project-local `.venv`.
*/
export function getProjectVenvPythonPath(projectPath) {
return path.join(
projectPath,
'.venv',
SystemHelper.isWindows() ? 'Scripts' : 'bin',
SystemHelper.isWindows() ? 'python.exe' : 'python'
)
}
/**
* Check whether a project dependency sync stamp is newer than its manifest.
*/
async function isSyncCurrent(projectPath, stampFileName) {
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
const stampPath = path.join(projectPath, stampFileName)
if (
!fs.existsSync(pyprojectPath) ||
!fs.existsSync(stampPath) ||
!fs.existsSync(getProjectVenvPythonPath(projectPath))
) {
return false
}
const [manifestStat, stampStat] = await Promise.all([
fs.promises.stat(pyprojectPath),
fs.promises.stat(stampPath)
])
return manifestStat.mtimeMs <= stampStat.mtimeMs
}
/**
* Create or refresh a project-local `.venv` and install dependencies declared
* in `pyproject.toml` through Leon's managed `uv` and Python runtimes.
*/
export async function setupPythonProjectEnv({
name,
projectPath,
stampFileName
}) {
const status = createSetupStatus(`Setting up ${name} dependencies...`).start()
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
const stampPath = path.join(projectPath, stampFileName)
const venvPath = path.join(projectPath, '.venv')
if (!fs.existsSync(pyprojectPath)) {
status.pause()
return
}
if (await isSyncCurrent(projectPath, stampFileName)) {
status.succeed(`${name}: up-to-date`)
return
}
await ensurePythonRuntimeAvailable(name)
await ensureUVRuntimeAvailable(name)
const dependencies = await getPyprojectDependencies(projectPath)
await fs.promises.rm(venvPath, { recursive: true, force: true })
await execa(UV_RUNTIME_BIN_PATH, [
'venv',
'--python',
PYTHON_RUNTIME_BIN_PATH,
venvPath
], { cwd: projectPath })
if (dependencies.length > 0) {
await execa(UV_RUNTIME_BIN_PATH, [
'pip',
'install',
'--python',
getProjectVenvPythonPath(projectPath),
...dependencies
], { cwd: projectPath })
}
await fs.promises.writeFile(stampPath, `${Date.now()}`)
status.succeed(`${name}: ready`)
}
+66
View File
@@ -0,0 +1,66 @@
import path from 'node:path'
import {
PYTHON_INSTALL_PATH,
PYTHON_MANIFEST_PATH,
PYTHON_VERSION
} from '@/constants'
import { CPUArchitectures } from '@/types'
import { SystemHelper } from '@/helpers/system-helper'
import { setupRuntimeBinary } from './setup-runtime-binary'
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
function getBinaryPath() {
return SystemHelper.isWindows()
? path.join(PYTHON_INSTALL_PATH, 'python.exe')
: path.join(PYTHON_INSTALL_PATH, 'bin', 'python')
}
function getAssetFileName() {
if (SystemHelper.isLinux()) {
if (CPU_ARCH === CPUArchitectures.X64) {
return `cpython-${PYTHON_VERSION}-x86_64-unknown-linux-gnu-install_only.tar.gz`
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return `cpython-${PYTHON_VERSION}-aarch64-unknown-linux-gnu-install_only.tar.gz`
}
}
if (SystemHelper.isMacOS()) {
if (CPU_ARCH === CPUArchitectures.X64) {
return `cpython-${PYTHON_VERSION}-x86_64-apple-darwin-install_only.tar.gz`
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return `cpython-${PYTHON_VERSION}-aarch64-apple-darwin-install_only.tar.gz`
}
}
if (SystemHelper.isWindows()) {
return CPU_ARCH === CPUArchitectures.ARM64
? `cpython-${PYTHON_VERSION}-aarch64-pc-windows-msvc-install_only.tar.gz`
: `cpython-${PYTHON_VERSION}-x86_64-pc-windows-msvc-install_only.tar.gz`
}
throw new Error(
`Unsupported platform for Python: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
)
}
export default async function setupPython() {
const assetFileName = getAssetFileName()
await setupRuntimeBinary({
name: 'Python',
version: PYTHON_VERSION,
basePath: PYTHON_INSTALL_PATH,
installPath: PYTHON_INSTALL_PATH,
manifestPath: PYTHON_MANIFEST_PATH,
binaryPath: getBinaryPath(),
downloadURL: `https://github.com/astral-sh/python-build-standalone/releases/download/20240415/${assetFileName}`,
archiveFileName: assetFileName
})
}
+186
View File
@@ -0,0 +1,186 @@
import fs from 'node:fs'
import path from 'node:path'
import {
NVIDIA_LIBS_PATH,
PYTORCH_PATH,
PYTORCH_TORCH_PATH,
PYTORCH_NVIDIA_PATH,
PYTORCH_VERSION,
PYTORCH_MANIFEST_PATH
} from '@/constants'
import { FileHelper } from '@/helpers/file-helper'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
const { type: OS_TYPE, cpuArchitecture: CPU_ARCH } =
SystemHelper.getInformation()
const PYTORCH_SETUP_TEXT = 'Setting up PyTorch...'
async function ensureDirectoryLink(linkPath, targetPath) {
if (!fs.existsSync(targetPath)) {
return
}
await fs.promises.rm(linkPath, { recursive: true, force: true })
await fs.promises.mkdir(path.dirname(linkPath), { recursive: true })
const relativeTarget = path.relative(path.dirname(linkPath), targetPath)
const linkType = SystemHelper.isWindows() ? 'junction' : 'dir'
await fs.promises.symlink(relativeTarget, linkPath, linkType)
}
/**
* Map OS and architecture to PyTorch wheel platform identifiers
*/
function getPyTorchPlatform() {
const isMacOS = SystemHelper.isMacOS()
const isWindows = SystemHelper.isWindows()
const isLinux = SystemHelper.isLinux()
if (isLinux) {
if (CPU_ARCH === 'x64' || CPU_ARCH === 'x86_64') {
return 'linux-x86_64'
} else if (CPU_ARCH === 'arm64' || CPU_ARCH === 'aarch64') {
return 'linux-aarch64'
}
} else if (isWindows) {
return 'windows-x86_64'
} else if (isMacOS) {
if (CPU_ARCH === 'arm64') {
return 'macos-arm64'
} else {
return 'macos-x86_64'
}
}
throw new Error(`Unsupported platform: ${OS_TYPE} ${CPU_ARCH}`)
}
/**
* Get PyTorch wheel download URL based on platform
*/
function getPyTorchDownloadURL(version) {
const platform = getPyTorchPlatform()
const urls = {
'linux-x86_64': `https://download.pytorch.org/whl/cu129/torch-${version}%2Bcu129-cp311-cp311-manylinux_2_28_x86_64.whl`,
'linux-aarch64': `https://download.pytorch.org/whl/cu129/torch-${version}%2Bcu129-cp311-cp311-manylinux_2_28_aarch64.whl`,
'windows-x86_64': `https://download.pytorch.org/whl/cu129/torch-${version}%2Bcu129-cp311-cp311-win_amd64.whl`,
'macos-arm64': `https://download.pytorch.org/whl/cpu/torch-${version}-cp311-none-macosx_11_0_arm64.whl`,
// Use 2.2.0 as it is the latest available pre-built package for Python 3.11
'macos-x86_64': 'https://download.pytorch.org/whl/cpu/torch-2.2.0-cp311-none-macosx_10_9_x86_64.whl'
}
const url = urls[platform]
if (!url) {
throw new Error(`No PyTorch wheel available for platform: ${platform}`)
}
return url
}
/**
* Read manifest file to get installed version
*/
function readManifest(manifestPath) {
if (!fs.existsSync(manifestPath)) {
return null
}
try {
const content = fs.readFileSync(manifestPath, 'utf-8')
return JSON.parse(content)
} catch {
return null
}
}
/**
* Install PyTorch wheel if needed
*/
async function installPyTorch(requiredVersion, targetPath, manifestPath) {
const manifest = readManifest(manifestPath)
if (!manifest || manifest.version !== requiredVersion) {
const wheelPath = path.join(PYTORCH_PATH, `torch-${requiredVersion}.whl`)
// Clean up old version
await fs.promises.rm(targetPath, { recursive: true, force: true })
await fs.promises.rm(wheelPath, { force: true })
// Create target directory
await fs.promises.mkdir(targetPath, { recursive: true })
try {
const downloadURL = getPyTorchDownloadURL(requiredVersion)
await FileHelper.downloadFile(downloadURL, wheelPath, {
cliProgress: true,
parallelStreams: 3,
skipExisting: false
})
// Extract wheel (wheels are just ZIP files)
await FileHelper.extractArchive(wheelPath, targetPath, {
stripComponents: 0
})
// Clean up and create manifest
await Promise.all([
fs.promises.rm(wheelPath, { force: true }),
FileHelper.createManifestFile(manifestPath, 'torch', requiredVersion, {
os: SystemHelper.getInformation().type,
architecture: SystemHelper.getInformation().cpuArchitecture
})
])
if (!SystemHelper.isMacOS()) {
await ensureDirectoryLink(PYTORCH_NVIDIA_PATH, NVIDIA_LIBS_PATH)
}
} catch (error) {
throw new Error(
`PyTorch may require manual download from https://pytorch.org/get-started/locally/: ${error}`
)
}
return true
}
return false
}
/**
* Main setup function
*/
async function setupPyTorch() {
const status = createSetupStatus(PYTORCH_SETUP_TEXT).start()
try {
const installed = await installPyTorch(
PYTORCH_VERSION,
PYTORCH_TORCH_PATH,
PYTORCH_MANIFEST_PATH
)
if (!SystemHelper.isMacOS()) {
await ensureDirectoryLink(PYTORCH_NVIDIA_PATH, NVIDIA_LIBS_PATH)
}
if (installed) {
status.succeed(`PyTorch: ${PYTORCH_VERSION}`)
} else {
status.succeed(`PyTorch: ${PYTORCH_VERSION}`)
}
} catch (error) {
if (status.isSpinning) {
status.fail('Failed to set up PyTorch')
}
throw error
}
}
export default setupPyTorch
+114
View File
@@ -0,0 +1,114 @@
import fs from 'node:fs'
import path from 'node:path'
import { homedir } from 'node:os'
import { FileHelper } from '@/helpers/file-helper'
import { NetworkHelper } from '@/helpers/network-helper'
import { createSetupStatus } from './setup-status'
const MOVE_FALLBACK_ERROR_CODES = new Set(['EXDEV', 'EPERM', 'EBUSY', 'EACCES'])
const QMD_MODELS_DIR_PATH = path.join(homedir(), '.cache', 'qmd', 'models')
const QMD_MODELS = [
{
/**
* We do not use it yet, but better to get it now,
* so it'd be ready when we enable embeddings
*/
url: 'https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF/resolve/main/embeddinggemma-300M-Q8_0.gguf?download=true',
filename: 'hf_ggml-org_embeddinggemma-300M-Q8_0.gguf'
},
{
url: 'https://huggingface.co/ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/resolve/main/qwen3-reranker-0.6b-q8_0.gguf?download=true',
filename: 'hf_ggml-org_qwen3-reranker-0.6b-q8_0.gguf'
},
{
url: 'https://huggingface.co/tobil/qmd-query-expansion-1.7B/resolve/main/qmd-query-expansion-1.7B-Q4_K_M.gguf?download=true',
filename: 'hf_tobil_qmd-query-expansion-1.7B-q4_k_m.gguf'
}
]
function getModelFilenameFromURL(modelURL) {
const parsedURL = new URL(modelURL)
return path.basename(parsedURL.pathname)
}
function isMoveFallbackError(error) {
return (
error instanceof Error &&
'code' in error &&
MOVE_FALLBACK_ERROR_CODES.has(error.code)
)
}
async function movePath(sourcePath, destinationPath) {
try {
await fs.promises.rename(sourcePath, destinationPath)
} catch (error) {
if (!isMoveFallbackError(error)) {
throw error
}
await fs.promises.copyFile(sourcePath, destinationPath)
await fs.promises.rm(sourcePath, { force: true })
}
}
async function downloadModel(model) {
const destinationPath = path.join(QMD_MODELS_DIR_PATH, model.filename)
const legacyFilename = getModelFilenameFromURL(model.url)
const legacyPath = path.join(QMD_MODELS_DIR_PATH, legacyFilename)
if (fs.existsSync(destinationPath)) {
return 'existing'
}
if (legacyFilename !== model.filename && fs.existsSync(legacyPath)) {
await movePath(legacyPath, destinationPath)
return 'renamed'
}
const resolvedURL = await NetworkHelper.setHuggingFaceURL(model.url)
await FileHelper.downloadFile(resolvedURL, destinationPath)
return 'downloaded'
}
export default async () => {
const status = createSetupStatus('Checking QMD models...').start()
try {
await fs.promises.mkdir(QMD_MODELS_DIR_PATH, {
recursive: true
})
status.pause()
let downloadedModelCount = 0
for (const model of QMD_MODELS) {
const modelState = await downloadModel(model)
if (modelState === 'downloaded') {
downloadedModelCount += 1
}
}
status.text = 'Finalizing QMD models...'
status.start()
status.succeed(
downloadedModelCount > 0
? `QMD models: ready - ${downloadedModelCount} downloaded`
: 'QMD models: ready'
)
} catch (e) {
if (status.isSpinning) {
status.fail('Failed to set up QMD models')
}
throw e
}
}
+177
View File
@@ -0,0 +1,177 @@
import {
getLLMProviderAccountConfig
} from '@/core/llm-manager/llm-provider-account-configs'
import { SetupUI, setupConsola } from './setup-ui'
const REMOTE_LLM_PROVIDERS = [
{
...getRequiredProviderAccountConfig('openrouter'),
models: [
{ label: 'openai/gpt-5.6-sol (Recommended)', value: 'openai/gpt-5.6-sol' },
{ label: 'openai/gpt-5.6-terra', value: 'openai/gpt-5.6-terra' },
{ label: 'openai/gpt-5.6-luna', value: 'openai/gpt-5.6-luna' },
{ label: 'openai/gpt-5.5', value: 'openai/gpt-5.5' },
{ label: 'openai/gpt-5.4', value: 'openai/gpt-5.4' },
{ label: 'openai/gpt-5.4-mini', value: 'openai/gpt-5.4-mini' },
{
label: 'anthropic/claude-fable-5',
value: 'anthropic/claude-fable-5'
},
{
label: 'anthropic/claude-opus-4.8',
value: 'anthropic/claude-opus-4.8'
},
{
label: 'anthropic/claude-opus-4.7',
value: 'anthropic/claude-opus-4.7'
},
{
label: 'anthropic/claude-opus-4.6',
value: 'anthropic/claude-opus-4.6'
},
{
label: 'anthropic/claude-sonnet-4.6',
value: 'anthropic/claude-sonnet-4.6'
},
{ label: 'xiaomi/mimo-v2.5-pro', value: 'xiaomi/mimo-v2.5-pro' },
{ label: 'z-ai/glm-5.2', value: 'z-ai/glm-5.2' },
{ label: 'z-ai/glm-5.1', value: 'z-ai/glm-5.1' },
{ label: 'z-ai/glm-5-turbo', value: 'z-ai/glm-5-turbo' },
{ label: 'moonshotai/kimi-k2.6', value: 'moonshotai/kimi-k2.6' },
{ label: 'minimax/minimax-m3', value: 'minimax/minimax-m3' }
]
},
{
...getRequiredProviderAccountConfig('openai'),
models: [
{ label: 'GPT-5.6 Sol (Recommended)', value: 'gpt-5.6-sol' },
{ label: 'GPT-5.6 Terra', value: 'gpt-5.6-terra' },
{ label: 'GPT-5.6 Luna', value: 'gpt-5.6-luna' },
{ label: 'GPT-5.5', value: 'gpt-5.5' },
{ label: 'GPT-5.4', value: 'gpt-5.4' },
{ label: 'GPT-5.4 mini', value: 'gpt-5.4-mini' },
{ label: 'GPT-5.4 nano', value: 'gpt-5.4-nano' }
]
},
{
...getRequiredProviderAccountConfig('anthropic'),
models: [
{ label: 'Claude Opus 4.8 (Recommended)', value: 'claude-opus-4-8' },
{ label: 'Claude Fable 5', value: 'claude-fable-5' },
{ label: 'Claude Opus 4.7', value: 'claude-opus-4-7' },
{ label: 'Claude Opus 4.6', value: 'claude-opus-4-6' },
{ label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5' }
]
},
{
...getRequiredProviderAccountConfig('zai'),
models: [
{ label: 'GLM-5.2 (Recommended)', value: 'glm-5.2' },
{ label: 'GLM-5.1', value: 'glm-5.1' },
{ label: 'GLM-5-Turbo', value: 'glm-5-turbo' },
{ label: 'GLM-5', value: 'glm-5' }
]
},
{
...getRequiredProviderAccountConfig('minimax'),
models: [
{ label: 'MiniMax-M3 (Recommended)', value: 'MiniMax-M3' },
{ label: 'MiniMax-M2.7', value: 'MiniMax-M2.7' }
]
},
{
...getRequiredProviderAccountConfig('moonshotai'),
models: [
{ label: 'Kimi K2.6', value: 'kimi-k2.6' },
{ label: 'Kimi K2.5', value: 'kimi-k2.5' }
]
}
]
function getRequiredProviderAccountConfig(providerValue) {
const providerAccountConfig = getLLMProviderAccountConfig(providerValue)
if (!providerAccountConfig || !providerAccountConfig.apiKeyURL) {
throw new Error(
`Missing provider account configuration for "${providerValue}".`
)
}
return providerAccountConfig
}
function getProviderOptions() {
return REMOTE_LLM_PROVIDERS.map((provider) => ({
label: provider.label,
value: provider.value
}))
}
function getProviderConfig(providerValue) {
return REMOTE_LLM_PROVIDERS.find(
(provider) => provider.value === providerValue
)
}
/**
* Ask for a remote LLM provider, model, and API key when local AI is skipped.
*/
export default async function setupRemoteLLM() {
SetupUI.info(
'No problem. I can use an online AI service instead.'
)
SetupUI.info(
'I just need 3 quick details so I can connect it for you.'
)
const providerValue = await setupConsola.prompt(
'Which online AI service should I use?',
{
type: 'select',
initial: REMOTE_LLM_PROVIDERS[0].value,
options: getProviderOptions(),
cancel: 'default'
}
)
const provider = getProviderConfig(providerValue)
if (!provider) {
throw new Error(`Unsupported remote LLM provider "${providerValue}".`)
}
const modelValue = await setupConsola.prompt(
`Which model should I use with ${provider.label}?`,
{
type: 'select',
initial: provider.models[0].value,
options: provider.models,
cancel: 'default'
}
)
SetupUI.info(
`Create your API key here: ${SetupUI.underlined(provider.apiKeyURL)}`
)
const apiKey = await setupConsola.prompt(
`Paste your ${provider.label} API key. I will save it in your local .env file.`,
{
type: 'text',
placeholder: 'Paste API key here',
validate(value) {
if (!value || value.trim() === '') {
return 'Please paste your API key.'
}
},
cancel: 'default'
}
)
return {
remoteLLMProvider: provider.value,
remoteLLMModel: modelValue,
remoteLLMAPIKeyEnv: provider.apiKeyEnv,
remoteLLMAPIKey: apiKey
}
}
+181
View File
@@ -0,0 +1,181 @@
import fs from 'node:fs'
import path from 'node:path'
import { FileHelper } from '@/helpers/file-helper'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
const MOVE_FALLBACK_ERROR_CODES = new Set(['EXDEV', 'EPERM', 'EBUSY', 'EACCES'])
function readManifest(manifestPath) {
if (!fs.existsSync(manifestPath)) {
return null
}
try {
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
} catch {
return null
}
}
async function removePath(targetPath) {
await fs.promises.rm(targetPath, { recursive: true, force: true })
}
function isMoveFallbackError(error) {
return (
error instanceof Error &&
'code' in error &&
MOVE_FALLBACK_ERROR_CODES.has(error.code)
)
}
async function movePath(sourcePath, destinationPath) {
try {
await fs.promises.rename(sourcePath, destinationPath)
} catch (error) {
if (!isMoveFallbackError(error)) {
throw error
}
await fs.promises.cp(sourcePath, destinationPath, {
recursive: true,
force: true
})
await removePath(sourcePath)
}
}
async function moveDirectoryContents(sourcePath, destinationPath) {
await fs.promises.mkdir(destinationPath, { recursive: true })
const entries = await fs.promises.readdir(sourcePath, {
withFileTypes: true
})
await Promise.all(
entries.map((entry) =>
movePath(
path.join(sourcePath, entry.name),
path.join(destinationPath, entry.name)
)
)
)
}
async function cleanInstallDirectory(basePath) {
await fs.promises.mkdir(basePath, { recursive: true })
const entries = await fs.promises.readdir(basePath, {
withFileTypes: true
})
await Promise.all(
entries
.filter((entry) => entry.name !== 'versions.json')
.map((entry) => removePath(path.join(basePath, entry.name)))
)
}
async function getFlattenedExtractionRoot(extractionPath) {
const entries = await fs.promises.readdir(extractionPath, {
withFileTypes: true
})
if (entries.length === 1 && entries[0]?.isDirectory()) {
return path.join(extractionPath, entries[0].name)
}
return extractionPath
}
/**
* Install a portable runtime into Leon's managed `bin/` directory from either
* an archive asset or a direct executable download.
*/
export async function setupRuntimeBinary({
name,
version,
basePath,
installPath,
manifestPath,
binaryPath,
downloadURL,
archiveFileName = null
}) {
const status = createSetupStatus(`Downloading and setting up ${name}...`).start()
const manifest = readManifest(manifestPath)
if (manifest?.version === version && fs.existsSync(binaryPath)) {
status.succeed(`${name}: ${version}`)
return
}
await cleanInstallDirectory(basePath)
try {
status.pause()
if (archiveFileName) {
const archivePath = path.join(basePath, archiveFileName)
const extractionPath = await fs.promises.mkdtemp(
path.join(basePath, `${name.toLowerCase()}-extract-`)
)
try {
await FileHelper.downloadFile(downloadURL, archivePath, {
cliProgress: true,
parallelStreams: 3,
skipExisting: false
})
status.text = `Installing ${name}...`
status.start()
await FileHelper.extractArchive(archivePath, extractionPath)
const flattenedRootPath = await getFlattenedExtractionRoot(extractionPath)
await moveDirectoryContents(flattenedRootPath, installPath)
} finally {
await Promise.all([
fs.promises.rm(archivePath, { force: true }),
fs.promises.rm(extractionPath, { recursive: true, force: true })
])
}
} else {
await fs.promises.mkdir(path.dirname(binaryPath), { recursive: true })
await FileHelper.downloadFile(downloadURL, binaryPath, {
cliProgress: true,
parallelStreams: 3,
skipExisting: false
})
status.text = `Finalizing ${name}...`
status.start()
}
if (!SystemHelper.isWindows()) {
await fs.promises.chmod(binaryPath, 0o755)
}
if (!fs.existsSync(binaryPath)) {
throw new Error(`Cannot find ${name} binary at "${binaryPath}"`)
}
await FileHelper.createManifestFile(manifestPath, name.toLowerCase(), version, {
os: SystemHelper.getInformation().type,
architecture: SystemHelper.getInformation().cpuArchitecture
})
status.succeed(`${name}: ${version}`)
} catch (error) {
if (status.isSpinning) {
status.fail(`Failed to set up ${name}`)
}
throw new Error(`Failed to set up ${name}: ${error}`)
}
}
@@ -0,0 +1,51 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROFILE_NATIVE_SKILLS_PATH } from '@/constants'
import { mergeMissingSettings } from '../settings-merge'
/**
* Set up skills settings
*/
export default async function (skillFriendlyName, currentSkill) {
const skillName = path.basename(currentSkill.path)
const skillSrcPath = path.join(currentSkill.path, 'src')
const settingsPath = path.join(
PROFILE_NATIVE_SKILLS_PATH,
skillName,
'settings.json'
)
const settingsSamplePath = path.join(skillSrcPath, 'settings.sample.json')
// If there is a bridge set from the skill settings
if (currentSkill.bridge) {
// Check if the settings and settings.sample file exist
if (fs.existsSync(settingsPath) && fs.existsSync(settingsSamplePath)) {
const settings = JSON.parse(
await fs.promises.readFile(settingsPath, 'utf8')
)
const settingsSample = JSON.parse(
await fs.promises.readFile(settingsSamplePath, 'utf8')
)
const mergedSettings = mergeMissingSettings(settingsSample, settings)
if (JSON.stringify(settings) !== JSON.stringify(mergedSettings)) {
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true })
await fs.promises.writeFile(
settingsPath,
`${JSON.stringify(mergedSettings, null, 2)}\n`
)
}
} else if (!fs.existsSync(settingsSamplePath)) {
// Stop the setup if the settings.sample.json of the current skill does not exist
throw new Error(
`The "${skillFriendlyName}" skill settings file does not exist. Try to pull the project (git pull)`
)
} else {
// Duplicate settings.sample.json of the current skill to settings.json
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true })
await fs.promises.copyFile(settingsSamplePath, settingsPath)
}
}
}
@@ -0,0 +1,47 @@
import path from 'node:path'
import { SkillDomainHelper } from '@/helpers/skill-domain-helper'
import { createSetupStatus } from '../setup-status'
import setupSkillsSettings from './setup-skills-settings'
import syncSkillDependencies from './sync-skill-dependencies'
/**
* Browse skills and set them up
*/
export default async function () {
const status = createSetupStatus('Setting up skills...').start()
try {
const skillNames = SkillDomainHelper.listAllSkillFoldersSync()
for (const skillName of skillNames) {
const currentSkill = await SkillDomainHelper.getNewSkillConfig(
skillName,
{ includeDisabled: true }
)
const currentSkillPath = SkillDomainHelper.getNewSkillConfigPath(
skillName,
{ includeDisabled: true }
)
if (!currentSkill || !currentSkillPath) {
continue
}
const skillContext = {
path: currentSkillPath ? path.dirname(currentSkillPath) : '',
bridge: currentSkill.bridge
}
await setupSkillsSettings(skillName, skillContext)
await syncSkillDependencies(skillName, skillContext)
}
status.succeed('Skills: ready')
} catch (e) {
status.fail('Failed to set up skills')
throw e
}
}
@@ -0,0 +1,27 @@
import path from 'node:path'
import {
syncNodejsSourceDependencies,
syncPythonSourceDependencies
} from '../sync-source-dependencies'
/**
* Sync skill dependencies only when a skill is installed, updated, or its
* dependency manifest changes.
*/
export default async function syncSkillDependencies(
_skillFriendlyName,
currentSkill
) {
const skillSRCPath = path.join(currentSkill.path, 'src')
if (currentSkill.bridge === 'nodejs') {
await syncNodejsSourceDependencies(skillSRCPath)
return
}
if (currentSkill.bridge === 'python') {
await syncPythonSourceDependencies(skillSRCPath)
}
}
+65
View File
@@ -0,0 +1,65 @@
import ora from 'ora'
import { createSetupJokeScheduler } from './setup-jokes'
/**
* Create a setup spinner that plays well with prompts and download output.
*/
export function createSetupStatus(text) {
const spinner = ora({
spinner: 'dots2',
color: 'cyan',
text,
stream: process.stdout,
discardStdin: false
})
const jokeScheduler = createSetupJokeScheduler()
const status = {
start() {
jokeScheduler.start()
spinner.start()
return status
},
pause() {
spinner.stop()
return status
},
stop() {
jokeScheduler.finish()
spinner.stop()
return status
},
succeed(successText) {
jokeScheduler.finish()
spinner.succeed(successText)
return status
},
fail(failureText) {
jokeScheduler.finish()
spinner.fail(failureText)
return status
},
warn(warningText) {
jokeScheduler.finish()
spinner.warn(warningText)
return status
},
get isSpinning() {
return spinner.isSpinning
},
get text() {
return spinner.text
},
set text(nextText) {
spinner.text = nextText
}
}
return status
}
+113
View File
@@ -0,0 +1,113 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import { PYTHON_TCP_SERVER_SRC_PATH } from '@/constants'
import {
getProjectVenvPythonPath,
setupPythonProjectEnv
} from './setup-python-project-env'
import { createSetupStatus } from './setup-status'
const NLTK_DATA_DIR_NAME = 'nltk_data'
const NLTK_VENV_DIR_NAME = '.venv'
const PYTHON_TCP_SERVER_VENV_BIN_PATH = getProjectVenvPythonPath(
PYTHON_TCP_SERVER_SRC_PATH
)
const NLTK_DATA_PATH = path.join(
PYTHON_TCP_SERVER_SRC_PATH,
NLTK_VENV_DIR_NAME,
NLTK_DATA_DIR_NAME
)
const NLTK_DATASETS = [
{
id: 'cmudict',
resourcePath: 'corpora/cmudict'
},
{
id: 'averaged_perceptron_tagger_eng',
resourcePath: 'taggers/averaged_perceptron_tagger_eng'
}
]
/**
* Check whether a required NLTK dataset directory exists in the TCP server venv.
*/
async function isNLTKDatasetInstalled(resourcePath) {
const datasetPath = path.join(NLTK_DATA_PATH, resourcePath)
try {
return (await fs.promises.stat(datasetPath)).isDirectory()
} catch {
return false
}
}
/**
* NLTK data are used by g2p-en during TTS text normalization.
*
* @see https://www.nltk.org/data.html
*/
async function downloadNLTKData() {
const status = createSetupStatus(
'Setting up Python TCP server NLTK data...'
).start()
const missingDatasets = []
await fs.promises.mkdir(NLTK_DATA_PATH, { recursive: true })
for (const dataset of NLTK_DATASETS) {
if (!(await isNLTKDatasetInstalled(dataset.resourcePath))) {
missingDatasets.push(dataset)
}
}
if (missingDatasets.length === 0) {
status.succeed('Python TCP server NLTK data: up-to-date')
return
}
try {
status.text = `Downloading Python TCP server NLTK data: ${missingDatasets
.map(({ id }) => id)
.join(', ')}`
await execa(
PYTHON_TCP_SERVER_VENV_BIN_PATH,
[
'-m',
'nltk.downloader',
'-d',
NLTK_DATA_PATH,
...missingDatasets.map(({ id }) => id)
],
{ stdio: 'ignore' }
)
for (const dataset of missingDatasets) {
if (!(await isNLTKDatasetInstalled(dataset.resourcePath))) {
throw new Error(`NLTK dataset "${dataset.id}" is still missing`)
}
}
status.succeed('Python TCP server NLTK data: ready')
} catch (error) {
status.fail(`Failed to download Python TCP server NLTK data: ${error}`)
throw error
}
}
/**
* Sync the Python TCP server runtime environment from its `pyproject.toml`.
*/
export default async function setupTCPServerEnv() {
await setupPythonProjectEnv({
name: 'Python TCP server',
projectPath: PYTHON_TCP_SERVER_SRC_PATH,
stampFileName: '.last-tcp-server-deps-sync'
})
await downloadNLTKData()
}
+166
View File
@@ -0,0 +1,166 @@
import fs from 'node:fs'
import path from 'node:path'
import {
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
// PYTHON_TCP_SERVER_TTS_BERT_FRENCH_DIR_PATH,
// PYTHON_TCP_SERVER_TTS_BERT_FRENCH_MODEL_HF_PREFIX_DOWNLOAD_URL,
PYTHON_TCP_SERVER_TTS_MODEL_PATH,
PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH,
PYTHON_TCP_SERVER_TTS_MODEL_HF_DOWNLOAD_URL,
PYTHON_TCP_SERVER_ASR_MODEL_HF_PREFIX_DOWNLOAD_URL,
PYTHON_TCP_SERVER_TTS_BERT_BASE_MODEL_HF_PREFIX_DOWNLOAD_URL
} from '@/constants'
import {
ASR_MODEL_FILES,
TTS_BERT_BASE_MODEL_FILES
} from '@/core/voice/voice-resource-state'
import { FileHelper } from '@/helpers/file-helper'
import { NetworkHelper } from '@/helpers/network-helper'
import { createSetupStatus } from './setup-status'
/*const TTS_BERT_FRENCH_MODEL_FILES = [
'pytorch_model.bin', // Not needed? Compare with HF auto download in ~/.cache/huggingface/hub...
'config.json',
'vocab.txt',
'tokenizer_config.json'
]*/
async function installTTSModel() {
const destPath = PYTHON_TCP_SERVER_TTS_MODEL_PATH
const pythonTCPServerTTSModelDownloadURL = await NetworkHelper.setHuggingFaceURL(
PYTHON_TCP_SERVER_TTS_MODEL_HF_DOWNLOAD_URL
)
await FileHelper.downloadFile(pythonTCPServerTTSModelDownloadURL, destPath)
}
async function installASRModel() {
for (const modelFile of ASR_MODEL_FILES) {
const pythonTCPServerASRModelDownloadURL =
await NetworkHelper.setHuggingFaceURL(
PYTHON_TCP_SERVER_ASR_MODEL_HF_PREFIX_DOWNLOAD_URL
)
const modelInstallationFileURL = `${pythonTCPServerASRModelDownloadURL}/${modelFile}?download=true`
const destPath = path.join(PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH, modelFile)
await FileHelper.downloadFile(modelInstallationFileURL, destPath)
}
}
/*async function installTTSBERTFrenchModel() {
try {
LogHelper.info('Installing TTS BERT French model...')
for (const modelFile of TTS_BERT_FRENCH_MODEL_FILES) {
const pythonTCPServerTTSBERTFrenchModelPrefixDownloadURL = await NetworkHelper.setHuggingFaceURL(
PYTHON_TCP_SERVER_TTS_BERT_FRENCH_MODEL_HF_PREFIX_DOWNLOAD_URL
)
const modelInstallationFileURL = `${pythonTCPServerTTSBERTFrenchModelPrefixDownloadURL}/${modelFile}?download=true`
const destPath = path.join(PYTHON_TCP_SERVER_TTS_BERT_FRENCH_DIR_PATH, modelFile)
LogHelper.info(`Downloading ${modelFile}...`)
await FileHelper.downloadFile(modelInstallationFileURL, destPath)
LogHelper.success(`${modelFile} downloaded at ${destPath}`)
}
LogHelper.success('TTS BERT French model installed')
} catch (e) {
LogHelper.error(`Failed to install TTS BERT French model: ${e}`)
process.exit(1)
}
}*/
async function installTTSBERTBaseModel() {
for (const modelFile of TTS_BERT_BASE_MODEL_FILES) {
const pythonTCPServerTTSBERTBaseModelPrefixDownloadURL =
await NetworkHelper.setHuggingFaceURL(
PYTHON_TCP_SERVER_TTS_BERT_BASE_MODEL_HF_PREFIX_DOWNLOAD_URL
)
const modelInstallationFileURL = `${pythonTCPServerTTSBERTBaseModelPrefixDownloadURL}/${modelFile}?download=true`
const destPath = path.join(PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH, modelFile)
await FileHelper.downloadFile(modelInstallationFileURL, destPath)
}
}
async function ensureModel({
checkText,
installText,
successText,
isInstalled,
install
}) {
const status = createSetupStatus(checkText).start()
if (isInstalled()) {
status.succeed(successText)
return
}
status.pause()
await install()
status.text = installText
status.start()
status.succeed(successText)
}
export default async () => {
await ensureModel({
checkText: 'Checking voice language model files...',
installText: 'Installing voice language model files...',
successText: 'Voice language model files: ready',
isInstalled: () =>
fs.existsSync(
path.join(
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
TTS_BERT_BASE_MODEL_FILES[TTS_BERT_BASE_MODEL_FILES.length - 1]
)
),
install: installTTSBERTBaseModel
})
// TODO: later when multiple languages are supported
/*LogHelper.info(
'Checking whether TTS BERT French language model files are downloaded...'
)
const areTTSBERTFrenchFilesDownloaded = fs.existsSync(
path.join(
PYTHON_TCP_SERVER_TTS_BERT_FRENCH_DIR_PATH,
TTS_BERT_FRENCH_MODEL_FILES[TTS_BERT_FRENCH_MODEL_FILES.length - 1]
)
)
if (!areTTSBERTFrenchFilesDownloaded) {
LogHelper.info('TTS BERT French language model files not downloaded')
await installTTSBERTFrenchModel()
} else {
LogHelper.success(
'TTS BERT French language model files are already downloaded'
)
}*/
await ensureModel({
checkText: 'Checking TTS model...',
installText: 'Installing TTS model...',
successText: 'TTS model: ready',
isInstalled: () => fs.existsSync(PYTHON_TCP_SERVER_TTS_MODEL_PATH),
install: installTTSModel
})
await ensureModel({
checkText: 'Checking ASR model...',
installText: 'Installing ASR model...',
successText: 'ASR model: ready',
isInstalled: () =>
fs.existsSync(
path.join(
PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH,
ASR_MODEL_FILES[ASR_MODEL_FILES.length - 1]
)
),
install: installASRModel
})
}
+80
View File
@@ -0,0 +1,80 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROFILE_TOOLS_PATH, TOOLS_PATH } from '@/constants'
import { createSetupStatus } from './setup-status'
import {
syncNodejsSourceDependencies,
syncPythonSourceDependencies
} from './sync-source-dependencies'
const NODEJS_SOURCE_PATH = path.join('src', 'nodejs')
const PYTHON_SOURCE_PATH = path.join('src', 'python')
const getToolSourcePaths = async (toolsPath) => {
if (!fs.existsSync(toolsPath)) {
return []
}
const toolkitEntries = await fs.promises.readdir(toolsPath, {
withFileTypes: true
})
const sourcePaths = []
for (const toolkitEntry of toolkitEntries) {
if (!toolkitEntry.isDirectory()) {
continue
}
const toolkitPath = path.join(toolsPath, toolkitEntry.name)
const toolEntries = await fs.promises.readdir(toolkitPath, {
withFileTypes: true
})
for (const toolEntry of toolEntries) {
if (!toolEntry.isDirectory()) {
continue
}
const toolPath = path.join(toolkitPath, toolEntry.name)
sourcePaths.push({
bridge: 'nodejs',
path: path.join(toolPath, NODEJS_SOURCE_PATH)
})
sourcePaths.push({
bridge: 'python',
path: path.join(toolPath, PYTHON_SOURCE_PATH)
})
}
}
return sourcePaths
}
/**
* Sync tool dependencies next to each tool source folder.
*/
export default async function setupToolsDependencies() {
const status = createSetupStatus('Setting up tool dependencies...').start()
try {
const sourcePaths = [
...(await getToolSourcePaths(TOOLS_PATH)),
...(await getToolSourcePaths(PROFILE_TOOLS_PATH))
]
for (const sourcePath of sourcePaths) {
if (sourcePath.bridge === 'nodejs') {
await syncNodejsSourceDependencies(sourcePath.path)
} else {
await syncPythonSourceDependencies(sourcePath.path)
}
}
status.succeed('Tool dependencies: ready')
} catch (e) {
status.fail('Failed to set up tool dependencies')
throw e
}
}
+91
View File
@@ -0,0 +1,91 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROFILE_TOOLS_PATH, TOOLS_PATH } from '@/constants'
import { createSetupStatus } from './setup-status'
import { mergeMissingSettings } from './settings-merge'
const TOOL_SETTINGS_SAMPLE_FILENAME = 'settings.sample.json'
const TOOL_SETTINGS_FILENAME = 'settings.json'
function readJSONFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
async function syncToolSettings(toolkitId, toolId, toolPath) {
const settingsSamplePath = path.join(toolPath, TOOL_SETTINGS_SAMPLE_FILENAME)
if (!fs.existsSync(settingsSamplePath)) {
throw new Error(
`The "${toolkitId}.${toolId}" tool settings sample does not exist.`
)
}
const settingsPath = path.join(
PROFILE_TOOLS_PATH,
toolkitId,
toolId,
TOOL_SETTINGS_FILENAME
)
const settingsSample = readJSONFile(settingsSamplePath)
const currentSettings = fs.existsSync(settingsPath)
? readJSONFile(settingsPath)
: {}
const mergedSettings = mergeMissingSettings(settingsSample, currentSettings)
if (
fs.existsSync(settingsPath) &&
JSON.stringify(currentSettings) === JSON.stringify(mergedSettings)
) {
return
}
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true })
await fs.promises.writeFile(
settingsPath,
`${JSON.stringify(mergedSettings, null, 2)}\n`
)
}
/**
* Create or update profile settings for built-in tools.
*/
export default async function setupToolsSettings() {
const status = createSetupStatus('Setting up tool settings...').start()
try {
for (const toolkitEntry of fs.readdirSync(TOOLS_PATH, {
withFileTypes: true
})) {
if (!toolkitEntry.isDirectory()) {
continue
}
const toolkitId = toolkitEntry.name
const toolkitPath = path.join(TOOLS_PATH, toolkitId)
const toolkitConfigPath = path.join(toolkitPath, 'toolkit.json')
if (!fs.existsSync(toolkitConfigPath)) {
continue
}
const toolkitConfig = readJSONFile(toolkitConfigPath)
for (const toolId of toolkitConfig.tools || []) {
const toolPath = path.join(toolkitPath, toolId)
if (!fs.existsSync(path.join(toolPath, 'tool.json'))) {
continue
}
await syncToolSettings(toolkitId, toolId, toolPath)
}
}
status.succeed('Tool settings: ready')
} catch (e) {
status.fail('Failed to set up tool settings')
throw e
}
}
+75
View File
@@ -0,0 +1,75 @@
import { createConsola } from 'consola'
export const setupConsola = createConsola({
fancy: true,
formatOptions: {
date: false
}
})
function dim(text) {
return `\x1b[2m${text}\x1b[0m`
}
function green(text) {
return `\x1b[32m${text}\x1b[0m`
}
function underline(text) {
return `\x1b[4m${text}\x1b[24m`
}
/**
* Setup-only presentation helpers built on top of consola.
*/
export class SetupUI {
static section(title) {
setupConsola.box(title)
}
static aside(text) {
setupConsola.log(dim(` ${text}`))
}
static info(text) {
setupConsola.info(text)
}
static success(text) {
setupConsola.success(text)
}
static successHighlight(text) {
console.log(green(`${text}`))
}
static warning(text) {
setupConsola.warn(text)
}
static questionIntro(count) {
const quickQuestionLabel =
count === 1 ? '1 quick question' : `${count} quick questions`
this.info(
`I just have ${quickQuestionLabel} so I can set things up the way you want.`
)
}
static questionSummary(preferences) {
this.success(`Local AI: ${preferences.setupLocalAI ? 'Yes' : 'No'}`)
this.success(`Voice: ${preferences.setupVoice ? 'Yes' : 'No'}`)
}
static recap(items) {
setupConsola.box(items.join('\n'))
}
static bullet(text) {
console.log(`${text}`)
}
static underlined(text) {
return underline(text)
}
}
+66
View File
@@ -0,0 +1,66 @@
import path from 'node:path'
import {
UV_INSTALL_PATH,
UV_MANIFEST_PATH,
UV_VERSION
} from '@/constants'
import { CPUArchitectures } from '@/types'
import { SystemHelper } from '@/helpers/system-helper'
import { setupRuntimeBinary } from './setup-runtime-binary'
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
function getBinaryPath() {
return SystemHelper.isWindows()
? path.join(UV_INSTALL_PATH, 'uv.exe')
: path.join(UV_INSTALL_PATH, 'uv')
}
function getAssetFileName() {
if (SystemHelper.isLinux()) {
if (CPU_ARCH === CPUArchitectures.X64) {
return 'uv-x86_64-unknown-linux-gnu.tar.gz'
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return 'uv-aarch64-unknown-linux-gnu.tar.gz'
}
}
if (SystemHelper.isMacOS()) {
if (CPU_ARCH === CPUArchitectures.X64) {
return 'uv-x86_64-apple-darwin.tar.gz'
}
if (CPU_ARCH === CPUArchitectures.ARM64) {
return 'uv-aarch64-apple-darwin.tar.gz'
}
}
if (SystemHelper.isWindows()) {
return CPU_ARCH === CPUArchitectures.ARM64
? 'uv-aarch64-pc-windows-msvc.zip'
: 'uv-x86_64-pc-windows-msvc.zip'
}
throw new Error(
`Unsupported platform for uv: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
)
}
export default async function setupUV() {
const assetFileName = getAssetFileName()
await setupRuntimeBinary({
name: 'uv',
version: UV_VERSION,
basePath: UV_INSTALL_PATH,
installPath: UV_INSTALL_PATH,
manifestPath: UV_MANIFEST_PATH,
binaryPath: getBinaryPath(),
downloadURL: `https://releases.astral.sh/github/uv/releases/download/${UV_VERSION}/${assetFileName}`,
archiveFileName: assetFileName
})
}
+21
View File
@@ -0,0 +1,21 @@
import setupNVIDIALibs from './setup-nvidia-libs.js'
import setupPyTorch from './setup-pytorch.js'
import setupTCPServerModels from './setup-tcp-server-models'
/**
* Install or update all resources needed by local voice mode.
*/
export default async function setupVoiceResources() {
await setupNVIDIALibs()
await setupPyTorch()
await setupTCPServerModels()
}
const isMainModule = process.argv[1]?.endsWith('setup-voice-resources.js')
if (isMainModule) {
setupVoiceResources().catch((error) => {
console.error(error)
process.exit(1)
})
}
+443
View File
@@ -0,0 +1,443 @@
import fs from 'node:fs'
import {
CACHE_PATH,
LEON_HOME_PATH,
LEON_PROFILES_PATH,
LEON_PROFILE_PATH,
LEON_TOOLKITS_PATH,
MODELS_PATH,
PROFILE_CONTEXT_PATH,
PROFILE_AGENT_SKILLS_PATH,
PROFILE_LOGS_PATH,
PROFILE_MEMORY_PATH,
PROFILE_NATIVE_SKILLS_PATH,
PROFILE_SKILLS_PATH,
PROFILE_TOOLS_PATH,
TMP_PATH,
IS_GITHUB_ACTIONS
} from '@/constants'
import { LogHelper } from '@/helpers/log-helper'
import { NetworkHelper } from '@/helpers/network-helper'
import buildApp from '../app/build-app'
import buildServer from '../build-server'
import train from '../train/train'
import generateClientInterfaceToken from '../generate/generate-client-interface-token'
import generateJSONSchemas from '../generate/generate-json-schemas'
import setupDotenv, { updateDotEnvVariable } from './setup-dotenv'
import setupConfig from './setup-config'
import { CONFIG_MANAGER } from '@/config'
import setupCore from './setup-core'
import setupNode from './setup-node'
import setupPNPM from './setup-pnpm'
import setupNativeNodeModules from './setup-native-node-modules'
import setupPython from './setup-python'
import setupUV from './setup-uv'
import setupNodejsBridgeEnv from './setup-nodejs-bridge-env'
import setupPythonBridgeEnv from './setup-python-bridge-env'
import setupToolsDependencies from './setup-tools-dependencies'
import setupToolsSettings from './setup-tools-settings'
import setupSkills from './setup-skills/setup-skills'
import setupTCPServerEnv from './setup-tcp-server-env'
import setupCMake from './setup-cmake'
import setupNinja from './setup-ninja'
import setupLlamaCPP from './setup-llama-cpp'
import setupLocalLLM from './setup-local-llm'
import setupQMDLLM from './setup-qmd-llm'
import setupVoiceResources from './setup-voice-resources.js'
import inspectLocalAICapability from './local-ai-capability'
import {
inspectLocalAISetupState,
inspectVoiceSetupState
} from './inspect-setup-state'
import postSetup from './post-setup'
import { printSetupBanner } from './setup-banner'
import { tellSetupCompletionJoke } from './setup-jokes'
import setupPreferences from './setup-preferences'
import { createSetupStatus } from './setup-status'
import { SetupUI } from './setup-ui'
import createInstanceID from './create-instance-id'
import setFfprobePermissions from './set-ffprobe-permissions'
import setupGitHooks from './setup-git-hooks'
const LOCAL_LLM_TARGET_VALUE = 'llamacpp'
/**
* Create Leon home directories that setup and runtime expect to exist.
*/
async function ensureLeonHomeStructure() {
const status = createSetupStatus('Preparing Leon home...').start()
await Promise.all([
fs.promises.mkdir(LEON_HOME_PATH, { recursive: true }),
fs.promises.mkdir(LEON_PROFILES_PATH, { recursive: true }),
fs.promises.mkdir(LEON_PROFILE_PATH, { recursive: true }),
fs.promises.mkdir(CACHE_PATH, { recursive: true }),
fs.promises.mkdir(LEON_TOOLKITS_PATH, { recursive: true }),
fs.promises.mkdir(MODELS_PATH, { recursive: true }),
fs.promises.mkdir(TMP_PATH, { recursive: true }),
fs.promises.mkdir(PROFILE_CONTEXT_PATH, { recursive: true }),
fs.promises.mkdir(PROFILE_MEMORY_PATH, { recursive: true }),
fs.promises.mkdir(PROFILE_LOGS_PATH, { recursive: true }),
fs.promises.mkdir(PROFILE_SKILLS_PATH, { recursive: true }),
fs.promises.mkdir(PROFILE_NATIVE_SKILLS_PATH, { recursive: true }),
fs.promises.mkdir(PROFILE_AGENT_SKILLS_PATH, { recursive: true }),
fs.promises.mkdir(PROFILE_TOOLS_PATH, { recursive: true })
])
status.succeed('Leon home: ready')
}
function isExplicitLocalLLMTarget(value) {
const normalizedValue = (value || '').trim()
return (
normalizedValue.startsWith('llamacpp/') ||
normalizedValue.startsWith('sglang/') ||
normalizedValue.startsWith('/')
)
}
function getOptionalLLMTarget(value) {
return typeof value === 'string' ? value.trim() : ''
}
async function resolveExistingLLMChoice() {
const llmConfig = CONFIG_MANAGER.getConfig().llm
const leonLLM = getOptionalLLMTarget(llmConfig.default)
const leonWorkflowLLM = getOptionalLLMTarget(llmConfig.workflow)
const leonAgentLLM = getOptionalLLMTarget(llmConfig.agent)
const overrideTargets = [leonWorkflowLLM, leonAgentLLM].filter(Boolean)
if (overrideTargets.length > 0) {
return {
hasResolvedChoice: true,
setupLocalAI: overrideTargets.some((target) =>
isExplicitLocalLLMTarget(target)
),
targetType: overrideTargets.some((target) =>
isExplicitLocalLLMTarget(target)
)
? 'explicitLocal'
: 'remote',
label: overrideTargets.join(', ')
}
}
if (leonLLM === '') {
return {
hasResolvedChoice: false,
setupLocalAI: false,
targetType: 'disabled',
label: ''
}
}
return {
hasResolvedChoice: true,
setupLocalAI: isExplicitLocalLLMTarget(leonLLM),
targetType: isExplicitLocalLLMTarget(leonLLM)
? 'explicitLocal'
: 'remote',
label: leonLLM
}
}
async function syncLLMSetupChoice(preferences) {
CONFIG_MANAGER.reload()
const llmConfig = CONFIG_MANAGER.getConfig().llm
const leonLLM = getOptionalLLMTarget(llmConfig.default)
const leonWorkflowLLM = getOptionalLLMTarget(llmConfig.workflow)
const leonAgentLLM = getOptionalLLMTarget(llmConfig.agent)
const hasExplicitModeOverride =
leonWorkflowLLM !== '' || leonAgentLLM !== ''
const hasExplicitGlobalTarget =
leonLLM !== ''
if (
preferences.remoteLLMProvider &&
preferences.remoteLLMModel &&
preferences.remoteLLMAPIKeyEnv &&
preferences.remoteLLMAPIKey
) {
await updateDotEnvVariable(
preferences.remoteLLMAPIKeyEnv,
preferences.remoteLLMAPIKey.trim()
)
if (!hasExplicitModeOverride) {
await CONFIG_MANAGER.setValue(
['llm', 'default'],
`${preferences.remoteLLMProvider}/${preferences.remoteLLMModel}`
)
}
return
}
if (hasExplicitModeOverride || hasExplicitGlobalTarget) {
return
}
if (preferences.setupLocalAI) {
await CONFIG_MANAGER.setValue(['llm', 'default'], LOCAL_LLM_TARGET_VALUE)
return
}
await CONFIG_MANAGER.setValue(['llm', 'default'], null)
}
// Do not load ".env" file because it is not created yet
/**
* Main entry to set up Leon
*/
;(async () => {
// Track setup execution state for user-facing error reporting and interruption handling.
let currentStep = 'bootstrap'
let shutdownSignal = null
let preferences = {
setupLocalAI: false,
setupVoice: false
}
let localAICapability = null
let localAISetupState = {
isInstalled: false,
label: ''
}
let voiceSetupState = {
isInstalled: false
}
const getExitCodeFromSignal = (signal) => (signal === 'SIGINT' ? 130 : 143)
// Clean up process signal listeners when setup exits normally or with an error.
const cleanupSignalHandlers = () => {
process.off('SIGINT', handleShutdownSignal)
process.off('SIGTERM', handleShutdownSignal)
}
// Abort active downloads and exit cleanly when setup is interrupted.
const handleShutdownSignal = (signal) => {
if (shutdownSignal) {
process.exit(getExitCodeFromSignal(signal))
}
shutdownSignal = signal
const abortedDownloadCount = NetworkHelper.abortActiveDownloads(
`Setup interrupted by ${signal}`
)
if (abortedDownloadCount === 0) {
process.exit(getExitCodeFromSignal(signal))
}
}
process.on('SIGINT', handleShutdownSignal)
process.on('SIGTERM', handleShutdownSignal)
try {
// Print the setup banner outside CI so the local install feels branded.
if (!IS_GITHUB_ACTIONS) {
printSetupBanner()
}
// Ask setup questions first so the rest of the install can run unattended.
if (!IS_GITHUB_ACTIONS) {
SetupUI.section('Quick Setup')
currentStep = 'inspectLocalAICapability'
const capabilityStatus = createSetupStatus(
'Checking what this computer can handle...'
).start()
localAICapability = await inspectLocalAICapability()
if (localAICapability.canInstallLocalAI) {
capabilityStatus.succeed(
'Local AI supported: better privacy and less to configure online'
)
} else {
capabilityStatus.stop()
SetupUI.info('Local AI is not supported on this computer')
}
currentStep = 'resolveExistingLLMChoice'
const existingLLMChoice = await resolveExistingLLMChoice()
currentStep = 'inspectLocalAISetupState'
localAISetupState = inspectLocalAISetupState()
currentStep = 'inspectVoiceSetupState'
voiceSetupState = inspectVoiceSetupState()
currentStep = 'setupPreferences'
preferences = await setupPreferences(
localAICapability,
existingLLMChoice,
localAISetupState,
voiceSetupState
)
}
// Prepare the local runtime, bridges, skills, and shared memory models.
SetupUI.section('Base Setup')
currentStep = 'ensureLeonHomeStructure'
await ensureLeonHomeStructure()
currentStep = 'setupDotenv'
await setupDotenv()
currentStep = 'generateJSONSchemas'
await generateJSONSchemas()
currentStep = 'setupConfig'
await setupConfig()
currentStep = 'syncLLMSetupChoice'
await syncLLMSetupChoice(preferences)
currentStep = 'setupCore'
await setupCore()
if (!IS_GITHUB_ACTIONS) {
currentStep = 'setupNode'
await setupNode()
currentStep = 'setupPNPM'
await setupPNPM()
currentStep = 'setupNativeNodeModules'
await setupNativeNodeModules()
currentStep = 'setupPython'
await setupPython()
currentStep = 'setupUV'
await setupUV()
} else {
SetupUI.info(
'Skipping portable Node.js, pnpm, Python and uv setup because it is running in CI'
)
}
currentStep = 'setupNodejsBridgeEnv'
await setupNodejsBridgeEnv()
currentStep = 'setupPythonBridgeEnv'
await setupPythonBridgeEnv()
currentStep = 'setupTCPServerEnv'
await setupTCPServerEnv()
currentStep = 'setupToolsDependencies'
await setupToolsDependencies()
currentStep = 'setupToolsSettings'
await setupToolsSettings()
currentStep = 'setupSkills'
await setupSkills()
if (!IS_GITHUB_ACTIONS) {
currentStep = 'setupQMDLLM'
await setupQMDLLM()
} else {
SetupUI.info('Skipping QMD model setup because it is running in CI')
}
if (!IS_GITHUB_ACTIONS) {
// Install local AI components based on the earlier capability check and answers.
SetupUI.section('Local AI')
if (preferences.setupLocalAI) {
currentStep = 'setupCMake'
await setupCMake()
currentStep = 'setupNinja'
await setupNinja()
currentStep = 'setupLlamaCPP'
await setupLlamaCPP()
currentStep = 'setupLocalLLM'
await setupLocalLLM(localAICapability)
} else if (localAICapability?.canInstallLocalAI) {
SetupUI.info('I will skip local AI for now. You can add it later.')
} else {
SetupUI.info('I will skip local AI because this computer does not support it.')
}
if (preferences.setupVoice) {
currentStep = 'setupVoiceResources'
await setupVoiceResources()
} else {
SetupUI.info('I will skip voice setup for now. You can add it later.')
}
} else {
// Skip hardware-specific setup in CI where local AI and voice stacks are not needed.
SetupUI.info(
'Skipping CMake, Ninja, llama.cpp, local LLM, QMD models, NVIDIA, and PyTorch setups because it is running in CI'
)
}
if (!preferences.setupVoice) {
SetupUI.info('I will skip voice model downloads for now.')
}
// Finalize generated assets and instance-specific setup metadata.
SetupUI.section('Finishing Up')
currentStep = 'generateClientInterfaceToken'
await generateClientInterfaceToken()
currentStep = 'train'
await train()
currentStep = 'setFfprobePermissions'
await setFfprobePermissions()
currentStep = 'createInstanceID'
await createInstanceID()
currentStep = 'setupGitHooks'
await setupGitHooks()
currentStep = 'buildApp'
{
const status = createSetupStatus('Building app...').start()
try {
await buildApp({ quiet: true })
status.succeed('App: ready')
} catch (error) {
status.fail('Failed to build app')
throw error
}
}
currentStep = 'buildServer'
{
const status = createSetupStatus('Building server...').start()
try {
await buildServer({ quiet: true })
status.succeed('Server: ready')
} catch (error) {
status.fail('Failed to build server')
throw error
}
}
if (!IS_GITHUB_ACTIONS) {
printSetupBanner()
}
SetupUI.recap([
'Setup complete',
`Local AI: ${preferences.setupLocalAI ? 'enabled' : 'skipped'}`,
`Voice: ${preferences.setupVoice ? 'enabled' : 'skipped'}`
])
tellSetupCompletionJoke()
console.log('')
SetupUI.successHighlight('Hooray! I\'m installed and ready to go!')
console.log('')
SetupUI.bullet(
`Follow my creator to get regular updates about me: ${SetupUI.underlined(
'https://x.com/grenlouis'
)}`
)
console.log('')
currentStep = 'postSetup'
await postSetup()
} catch (e) {
// Exit with the original signal code when setup was intentionally interrupted.
if (shutdownSignal) {
cleanupSignalHandlers()
process.exit(getExitCodeFromSignal(shutdownSignal))
}
// Surface the failing phase clearly when setup stops on an unexpected error.
LogHelper.error(
`Setup failed during ${currentStep}: ${
e instanceof Error ? e.stack || e.message : String(e)
}`
)
cleanupSignalHandlers()
process.exit(1)
}
})()
+128
View File
@@ -0,0 +1,128 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import {
PNPM_RUNTIME_BIN_PATH,
PYTHON_RUNTIME_BIN_PATH,
UV_RUNTIME_BIN_PATH
} from '@/constants'
import { RuntimeHelper } from '@/helpers/runtime-helper'
import {
getProjectVenvPythonPath,
getPyprojectDependencies
} from './setup-python-project-env'
const PACKAGE_JSON_FILE_NAME = 'package.json'
const PYPROJECT_FILE_NAME = 'pyproject.toml'
const SYNC_STAMP_FILE_NAME = '.last-source-deps-sync'
const NODE_MODULES_DIR_NAME = 'node_modules'
const VENV_DIR_NAME = '.venv'
const isFileEmpty = async (filePath) => {
const content = await fs.promises.readFile(filePath, 'utf8')
return content.trim() === ''
}
const getSyncStampPath = (sourcePath) => {
return path.join(sourcePath, SYNC_STAMP_FILE_NAME)
}
const isSyncCurrent = async (manifestPath, stampPath, dependencyPath) => {
if (
!fs.existsSync(stampPath) ||
!fs.existsSync(manifestPath) ||
!fs.existsSync(dependencyPath)
) {
return false
}
const [manifestStat, stampStat] = await Promise.all([
fs.promises.stat(manifestPath),
fs.promises.stat(stampPath)
])
return manifestStat.mtimeMs <= stampStat.mtimeMs
}
const markSourceDependenciesAsSynced = async (sourcePath) => {
await fs.promises.writeFile(getSyncStampPath(sourcePath), `${Date.now()}`)
}
/**
* Sync Node.js dependencies next to the source that declares them.
*/
export const syncNodejsSourceDependencies = async (sourcePath) => {
const packageJSONPath = path.join(sourcePath, PACKAGE_JSON_FILE_NAME)
const nodeModulesPath = path.join(sourcePath, NODE_MODULES_DIR_NAME)
const stampPath = getSyncStampPath(sourcePath)
if (!fs.existsSync(packageJSONPath) || (await isFileEmpty(packageJSONPath))) {
return
}
if (await isSyncCurrent(packageJSONPath, stampPath, nodeModulesPath)) {
return
}
await fs.promises.rm(nodeModulesPath, { recursive: true, force: true })
await execa(PNPM_RUNTIME_BIN_PATH, [
'install',
'--ignore-workspace',
'--lockfile=false'
], {
cwd: sourcePath,
env: RuntimeHelper.getManagedNodeEnvironment()
})
await markSourceDependenciesAsSynced(sourcePath)
}
/**
* Sync Python dependencies into a .venv next to the source that declares them.
*/
export const syncPythonSourceDependencies = async (sourcePath) => {
const manifestPath = path.join(sourcePath, PYPROJECT_FILE_NAME)
const venvPath = path.join(sourcePath, VENV_DIR_NAME)
const stampPath = getSyncStampPath(sourcePath)
if (!fs.existsSync(manifestPath)) {
return
}
if (
await isSyncCurrent(
manifestPath,
stampPath,
getProjectVenvPythonPath(sourcePath)
)
) {
return
}
const dependencies = await getPyprojectDependencies(sourcePath)
await fs.promises.rm(venvPath, { recursive: true, force: true })
await execa(UV_RUNTIME_BIN_PATH, [
'venv',
'--python',
PYTHON_RUNTIME_BIN_PATH,
venvPath
], { cwd: sourcePath })
if (dependencies.length > 0) {
await execa(UV_RUNTIME_BIN_PATH, [
'pip',
'install',
'--python',
getProjectVenvPythonPath(sourcePath),
...dependencies
], { cwd: sourcePath })
}
await markSourceDependenciesAsSynced(sourcePath)
}