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
+2
View File
@@ -0,0 +1,2 @@
package-lock=false
save-exact=true
+23
View File
@@ -0,0 +1,23 @@
{
"name": "leon-nodejs-bridge",
"description": "Leon's Node.js bridge to communicate between the core and skills made with JavaScript",
"main": "dist/bin/leon-nodejs-bridge.js",
"type": "module",
"author": {
"name": "Louis Grenard",
"email": "louis@getleon.ai",
"url": "https://twitter.com/grenlouis"
},
"license": "MIT",
"homepage": "https://getleon.ai",
"bugs": {
"url": "https://github.com/leon-ai/leon/issues"
},
"dependencies": {
"axios": "1.15.0",
"lodash": "4.18.1"
},
"devDependencies": {
"@types/lodash": "4.14.194"
}
}
+128
View File
@@ -0,0 +1,128 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SkillLocaleConfigSchema } from '@/schemas/skill-schemas'
import type { IntentObject, NLPAction } from '@sdk/types'
import {
CODEBASE_PATH,
LEON_HOME_PATH,
LEON_PROFILE_PATH
} from '@@/server/src/leon-roots'
const args = process.argv.slice(2)
const runtimeIndex = args.indexOf('--runtime')
const runtime =
runtimeIndex >= 0 && args[runtimeIndex + 1] ? args[runtimeIndex + 1] : 'skill'
const filteredArgs = args.filter((_, index) => {
if (index === runtimeIndex || index === runtimeIndex + 1) {
return false
}
return true
})
const intentPathCandidate = filteredArgs.find((arg) => !arg.startsWith('--'))
const INTENT_OBJ_FILE_PATH =
runtime === 'skill' ? intentPathCandidate : undefined
export const LEON_VERSION = process.env['npm_package_version']
export const RUNTIME = runtime
export {
CODEBASE_PATH,
LEON_HOME_PATH,
LEON_PROFILE_PATH
}
export const LEON_TOOLKITS_PATH = path.join(LEON_HOME_PATH, 'toolkits')
export const CODEBASE_CONTEXT_PATH = path.join(CODEBASE_PATH, 'core', 'context')
export const PROFILE_CONTEXT_PATH = path.join(LEON_PROFILE_PATH, 'context')
export const PROFILE_MEMORY_PATH = path.join(LEON_PROFILE_PATH, 'memory')
export const PROFILE_MEMORY_DB_PATH = path.join(
PROFILE_MEMORY_PATH,
'index.sqlite'
)
export const PROFILE_SKILLS_PATH = path.join(LEON_PROFILE_PATH, 'skills')
export const PROFILE_NATIVE_SKILLS_PATH = path.join(
PROFILE_SKILLS_PATH,
'native'
)
export const PROFILE_AGENT_SKILLS_PATH = path.join(
PROFILE_SKILLS_PATH,
'agent'
)
export const PROFILE_TOOLS_PATH = path.join(LEON_PROFILE_PATH, 'tools')
const BIN_PATH = path.join(LEON_HOME_PATH, 'bin')
const BRIDGES_PATH = path.join(CODEBASE_PATH, 'bridges')
const NODEJS_BRIDGE_ROOT_PATH = path.join(BRIDGES_PATH, 'nodejs')
const NODEJS_BRIDGE_SRC_PATH = path.join(NODEJS_BRIDGE_ROOT_PATH, 'src')
const NODEJS_BRIDGE_VERSION_FILE_PATH = path.join(
NODEJS_BRIDGE_SRC_PATH,
'version.ts'
)
export const TOOLS_PATH = path.join(CODEBASE_PATH, 'tools')
export const PROFILE_DISABLED_PATH = path.join(LEON_PROFILE_PATH, 'disabled.json')
export const PROFILE_ALLOWED_PATH = path.join(LEON_PROFILE_PATH, 'allowed.json')
export const [, NODEJS_BRIDGE_VERSION] = fs
.readFileSync(NODEJS_BRIDGE_VERSION_FILE_PATH, 'utf8')
.split('\'')
let parsedIntentObject: IntentObject | null = null
if (INTENT_OBJ_FILE_PATH) {
if (!fs.existsSync(INTENT_OBJ_FILE_PATH)) {
throw new Error(`Intent file not found: ${INTENT_OBJ_FILE_PATH}`)
}
parsedIntentObject = JSON.parse(
fs.readFileSync(INTENT_OBJ_FILE_PATH, 'utf8')
) as IntentObject
}
export const INTENT_OBJECT: IntentObject = parsedIntentObject
? parsedIntentObject
: ({} as IntentObject)
export const NVIDIA_LIBS_PATH = path.join(BIN_PATH, 'nvidia')
export const PYTORCH_PATH = path.join(BIN_PATH, 'pytorch')
export const PYTORCH_TORCH_PATH = path.join(PYTORCH_PATH, 'torch')
export const SKILLS_PATH = path.join(CODEBASE_PATH, 'skills')
export const NATIVE_SKILLS_PATH = path.join(SKILLS_PATH, 'native')
export const AGENT_SKILLS_PATH = path.join(SKILLS_PATH, 'agent')
export const SKILL_PATH =
runtime === 'skill' && parsedIntentObject
? path.dirname(parsedIntentObject.skill_config_path)
: ''
const SKILL_LOCALE_CONFIG_CONTENT =
runtime === 'skill' && INTENT_OBJ_FILE_PATH && parsedIntentObject
? ((): SkillLocaleConfigSchema => {
const skillLocalePath = path.join(
SKILL_PATH,
'locales',
parsedIntentObject.extra_context.lang + '.json'
)
return JSON.parse(
fs.existsSync(skillLocalePath)
? fs.readFileSync(skillLocalePath, 'utf8')
: `{"variables": {}, "common_answers": {}, "widget_contents": {}, "actions": {"${parsedIntentObject.action_name}": {}}}`
) as SkillLocaleConfigSchema
})()
: {
variables: {},
common_answers: {},
widget_contents: {},
actions: {}
} satisfies SkillLocaleConfigSchema
export const SKILL_LOCALE_CONFIG = {
variables: SKILL_LOCALE_CONFIG_CONTENT.variables,
common_answers: SKILL_LOCALE_CONFIG_CONTENT.common_answers,
widget_contents: SKILL_LOCALE_CONFIG_CONTENT.widget_contents,
...((runtime === 'skill' && parsedIntentObject
? SKILL_LOCALE_CONFIG_CONTENT.actions[
parsedIntentObject.action_name as NLPAction
]
: {}) || {})
} as SkillLocaleConfigSchema & SkillLocaleConfigSchema['actions'][NLPAction]
+97
View File
@@ -0,0 +1,97 @@
import path from 'node:path'
import { FileHelper } from '@/helpers/file-helper'
import type { ActionFunction, ActionParams } from '@sdk/types'
import { INTENT_OBJECT, SKILL_PATH } from '@bridge/constants'
import { ParamsHelper } from '@sdk/params-helper'
import { leon } from '@sdk/leon'
import { setToolReporter } from '@sdk/tool-reporter'
const resolveActionFunction = (actionModule: unknown): ActionFunction | null => {
if (!actionModule || typeof actionModule !== 'object') {
return null
}
const moduleObject = actionModule as Record<string, unknown>
if (typeof moduleObject['run'] === 'function') {
return moduleObject['run'] as ActionFunction
}
const defaultExport =
moduleObject['default'] && typeof moduleObject['default'] === 'object'
? (moduleObject['default'] as Record<string, unknown>)
: null
if (defaultExport && typeof defaultExport['run'] === 'function') {
return defaultExport['run'] as ActionFunction
}
if (typeof moduleObject['default'] === 'function') {
return moduleObject['default'] as ActionFunction
}
return null
}
async function main(): Promise<void> {
setToolReporter(async (input) => {
await leon.answer(input)
})
const {
lang,
sentiment,
context_name,
skill_name,
action_name,
skill_config_path,
extra_context
} = INTENT_OBJECT
const params: ActionParams = {
lang,
utterance: INTENT_OBJECT.utterance as ActionParams['utterance'],
action_arguments:
INTENT_OBJECT.action_arguments as ActionParams['action_arguments'],
entities: INTENT_OBJECT.entities as ActionParams['entities'],
sentiment,
context_name,
skill_name,
action_name,
context: INTENT_OBJECT.context as ActionParams['context'],
skill_config: INTENT_OBJECT.skill_config as ActionParams['skill_config'],
skill_config_path,
extra_context
}
try {
const actionModule = await FileHelper.dynamicImportFromFile(
path.join(
SKILL_PATH,
'src',
'actions',
`${action_name}.ts`
)
)
const actionFunction = resolveActionFunction(actionModule)
if (!actionFunction) {
throw new TypeError(
`Action "${skill_name}:${action_name}" does not export a runnable action function`
)
}
const paramsHelper = new ParamsHelper(params)
await actionFunction(params, paramsHelper)
} catch (e) {
console.error(
`Error while running "${skill_name}" skill "${action_name}" action:`,
e
)
}
}
void main()
+9
View File
@@ -0,0 +1,9 @@
import { type ButtonProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Button extends WidgetComponent<ButtonProps> {
constructor(props: ButtonProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type CardProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Card extends WidgetComponent<CardProps> {
constructor(props: CardProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type CheckboxProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Checkbox extends WidgetComponent<CheckboxProps> {
constructor(props: CheckboxProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type CircularProgressProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class CircularProgress extends WidgetComponent<CircularProgressProps> {
constructor(props: CircularProgressProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type FlexboxProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Flexbox extends WidgetComponent<FlexboxProps> {
constructor(props: FlexboxProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type FormProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Form extends WidgetComponent<FormProps> {
constructor(props: FormProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type IconButtonProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class IconButton extends WidgetComponent<IconButtonProps> {
constructor(props: IconButtonProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type IconProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Icon extends WidgetComponent<IconProps> {
constructor(props: IconProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type ImageProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Image extends WidgetComponent<ImageProps> {
constructor(props: ImageProps) {
super(props)
}
}
+30
View File
@@ -0,0 +1,30 @@
export * from './button'
export * from './card'
export * from './checkbox'
export * from './circular-progress'
export * from './flexbox'
export * from './form'
export * from './icon'
export * from './icon-button'
export * from './image'
export * from './input'
export * from './link'
export * from './list'
export * from './list-header'
export * from './list-item'
export * from './loader'
export * from './progress'
export * from './radio'
export * from './radio-group'
export * from './range-slider'
export * from './scroll-container'
export * from './select'
export * from './select-option'
export * from './status'
export * from './switch'
export * from './tab'
export * from './tab-content'
export * from './tab-group'
export * from './tab-list'
export * from './text'
export * from './widget-wrapper'
+9
View File
@@ -0,0 +1,9 @@
import { type InputProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Input extends WidgetComponent<InputProps> {
constructor(props: InputProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type LinkProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Link extends WidgetComponent<LinkProps> {
constructor(props: LinkProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type ListHeaderProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class ListHeader extends WidgetComponent<ListHeaderProps> {
constructor(props: ListHeaderProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type ListItemProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class ListItem extends WidgetComponent<ListItemProps> {
constructor(props: ListItemProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type ListProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class List extends WidgetComponent<ListProps> {
constructor(props: ListProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type LoaderProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Loader extends WidgetComponent<LoaderProps> {
constructor(props: LoaderProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type ProgressProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Progress extends WidgetComponent<ProgressProps> {
constructor(props: ProgressProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type RadioGroupProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class RadioGroup extends WidgetComponent<RadioGroupProps> {
constructor(props: RadioGroupProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type RadioProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Radio extends WidgetComponent<RadioProps> {
constructor(props: RadioProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type RangeSliderProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class RangeSlider extends WidgetComponent<RangeSliderProps> {
constructor(props: RangeSliderProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type ScrollContainerProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class ScrollContainer extends WidgetComponent<ScrollContainerProps> {
constructor(props: ScrollContainerProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type SelectOptionProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class SelectOption extends WidgetComponent<SelectOptionProps> {
constructor(props: SelectOptionProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type SelectProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Select extends WidgetComponent<SelectProps> {
constructor(props: SelectProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type StatusProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Status extends WidgetComponent<StatusProps> {
constructor(props: StatusProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type SwitchProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Switch extends WidgetComponent<SwitchProps> {
constructor(props: SwitchProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type TabContentProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class TabContent extends WidgetComponent<TabContentProps> {
constructor(props: TabContentProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type TabGroupProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class TabGroup extends WidgetComponent<TabGroupProps> {
constructor(props: TabGroupProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type TabListProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class TabList extends WidgetComponent<TabListProps> {
constructor(props: TabListProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type TabProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Tab extends WidgetComponent<TabProps> {
constructor(props: TabProps) {
super(props)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { type TextProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class Text extends WidgetComponent<TextProps> {
constructor(props: TextProps) {
super(props)
}
}
@@ -0,0 +1,9 @@
import { type WidgetWrapperProps } from '@aurora'
import { WidgetComponent } from '../widget-component'
export class WidgetWrapper extends WidgetComponent<WidgetWrapperProps> {
constructor(props: WidgetWrapperProps) {
super(props)
}
}
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
import fs from 'node:fs'
import path from 'node:path'
import type {
AnswerData,
AnswerInput,
AnswerOutput,
AnswerConfig
} from '@sdk/types'
import { INTENT_OBJECT, SKILL_LOCALE_CONFIG } from '@bridge/constants'
import { WidgetWrapper } from '@sdk/aurora'
import { SUPPORTED_WIDGET_EVENTS } from '@sdk/widget-component'
class Leon {
private static instance: Leon
private static globalAnswers = JSON.parse(
fs.readFileSync(
path.join(
process.cwd(),
'core',
'data',
INTENT_OBJECT.lang,
'answers.json'
),
'utf8'
)
).answers
constructor() {
if (!Leon.instance) {
Leon.instance = this
}
}
/**
* Injects variables into the answer string
* @param answer The answer to inject variables into
* @param data The data to apply
* @example injectVariables('Hello {{ name }}', { name: 'Leon' }) // 'Hello Leon'
*/
private injectVariables(
answer: AnswerConfig,
data: AnswerData | null
): AnswerConfig {
let finalAnswer = answer
const applyData = (obj: AnswerData): void => {
for (const key in obj) {
if (typeof finalAnswer === 'string') {
finalAnswer = finalAnswer.replaceAll(`{{ ${key} }}`, String(obj[key]))
} else {
if (finalAnswer.text) {
finalAnswer.text = finalAnswer.text.replaceAll(
`{{ ${key} }}`,
String(obj[key])
)
}
if (finalAnswer.speech) {
finalAnswer.speech = finalAnswer.speech.replaceAll(
`{{ ${key} }}`,
String(obj[key])
)
}
}
}
}
if (data) {
applyData(data)
}
if (SKILL_LOCALE_CONFIG.variables) {
applyData(SKILL_LOCALE_CONFIG.variables)
}
return finalAnswer
}
/**
* Convert an answer config to a text-only value for widget fallback
* delivery and synchronized history.
*/
private getAnswerText(answer: AnswerConfig | ''): string {
if (!answer) {
return ''
}
if (typeof answer === 'string') {
return answer
}
return answer.text || answer.speech || ''
}
/**
* Apply data to the answer
* @param answerKey The answer key
* @param data The data to apply
* @example setAnswerData('key', { name: 'Leon' })
*/
public setAnswerData(
answerKey: string,
data: AnswerData = null
): AnswerConfig {
try {
const answers =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
SKILL_LOCALE_CONFIG.answers?.[answerKey] ??
SKILL_LOCALE_CONFIG.common_answers?.[answerKey] ??
Leon.globalAnswers?.[answerKey]
if (!answers) {
return answerKey
}
const answer = Array.isArray(answers)
? answers[Math.floor(Math.random() * answers.length)] ?? ''
: answers
return this.injectVariables(answer, data)
} catch (e) {
console.error(
`Error while setting answer data. Please verify that the answer key "${answerKey}" exists in the locale configuration. Details:`,
e
)
throw e
}
}
/**
* Send an answer to the core
* @param answerInput The answer input
* @example answer({ key: 'greet' }) // 'Hello world'
* @example answer({ key: 'welcome', data: { name: 'Louis' } }) // 'Welcome Louis'
* @example answer({ key: 'confirm', core: { next_action: 'guess_the_number_skill:set_up' } }) // 'Would you like to retry?'
* @example answer({ key: 'progress', data: { percentage: 50 }, replaceMessageId: 'progress_msg_123' }) // Replace previous progress message
*/
public async answer(answerInput: AnswerInput): Promise<string | null> {
try {
const resolvedAnswer =
answerInput.key != null
? this.setAnswerData(answerInput.key, answerInput.data)
: ''
const fallbackText = this.getAnswerText(resolvedAnswer)
if (answerInput.widget && !fallbackText) {
throw new Error(
'Widget answers must include a text fallback via `key`.'
)
}
const answerObject: AnswerOutput = {
...INTENT_OBJECT,
output: {
codes:
answerInput.widget && !answerInput.key
? 'widget'
: (answerInput.key as string),
answer: resolvedAnswer,
core: answerInput.core,
replaceMessageId: answerInput.replaceMessageId || null
}
}
if (answerInput.widget) {
answerObject.output.widget = {
actionName: `${INTENT_OBJECT.skill_name}:${INTENT_OBJECT.action_name}`,
widget: answerInput.widget.widget,
id: answerInput.widget.id,
onFetch: answerInput.widget.onFetch ?? null,
fallbackText,
historyMode: answerInput.widgetHistoryMode || 'persisted',
componentTree: new WidgetWrapper({
...answerInput.widget.wrapperProps,
children: [answerInput.widget.render()]
}),
supportedEvents: SUPPORTED_WIDGET_EVENTS as string[]
}
}
// "Temporize" for the data buffer output on the core
await new Promise((r) => setTimeout(r, 100))
// Write the answer object to stdout as a JSON string with a newline for brain chunk-by-chunk parsing
process.stdout.write(JSON.stringify(answerObject) + '\n')
// Return the message ID for future replacement
return (
answerInput.widget?.id ||
`msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
)
} catch (e) {
console.error('Error while creating answer:', e)
return null
}
}
}
export const leon = new Leon()
+118
View File
@@ -0,0 +1,118 @@
import path from 'node:path'
import fs from 'node:fs'
import {
PROFILE_NATIVE_SKILLS_PATH,
SKILL_PATH
} from '@bridge/constants'
const SKILL_NAME_SUFFIX = '_skill'
function normalizeSkillName(skillName: string): string {
return skillName.endsWith(SKILL_NAME_SUFFIX)
? skillName
: `${skillName}${SKILL_NAME_SUFFIX}`
}
interface MemoryOptions<T> {
name: string
defaultMemory?: T
}
export class Memory<T = unknown> {
private readonly memoryPath: string
private readonly name: string
private readonly defaultMemory: T | undefined
private isFromAnotherSkill: boolean
constructor(options: MemoryOptions<T>) {
const { name, defaultMemory } = options
this.name = name
this.defaultMemory = defaultMemory
this.memoryPath = path.join(
PROFILE_NATIVE_SKILLS_PATH,
path.basename(SKILL_PATH),
'memory',
`${this.name}.json`
)
this.isFromAnotherSkill = false
if (this.name.includes(':') && this.name.split(':').length === 3) {
this.isFromAnotherSkill = true
const [, skillName, memoryName] = this.name.split(':')
this.memoryPath = path.join(
PROFILE_NATIVE_SKILLS_PATH,
normalizeSkillName(skillName as string),
'memory',
`${memoryName}.json`
)
}
}
/**
* Clear the memory and set it to the default memory value
* @example clear()
*/
public async clear(): Promise<void> {
if (!this.isFromAnotherSkill) {
await this.write(this.defaultMemory as T)
} else {
throw new Error(
`You cannot clear the memory "${this.name}" as it belongs to another skill`
)
}
}
/**
* Read the memory
* @example read()
*/
public async read(): Promise<T> {
if (this.isFromAnotherSkill && !fs.existsSync(this.memoryPath)) {
throw new Error(
`You cannot read the memory "${this.name}" as it belongs to another skill which haven't written to this memory yet`
)
}
try {
if (!fs.existsSync(this.memoryPath)) {
await this.clear()
}
return JSON.parse(await fs.promises.readFile(this.memoryPath, 'utf-8'))
} catch (e) {
console.error(`Error while reading memory for "${this.name}":`, e)
throw e
}
}
/**
* Write the memory
* @param memory The memory to write
* @example write({ foo: 'bar' }) // { foo: 'bar' }
*/
public async write(memory: T): Promise<T> {
if (!this.isFromAnotherSkill) {
try {
await fs.promises.mkdir(path.dirname(this.memoryPath), {
recursive: true
})
await fs.promises.writeFile(
this.memoryPath,
JSON.stringify(memory, null, 2)
)
return memory
} catch (e) {
console.error(`Error while writing memory for "${this.name}":`, e)
throw e
}
} else {
throw new Error(
`You cannot write into the memory "${this.name}" as it belongs to another skill`
)
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import dns from 'node:dns'
import type { AxiosInstance } from 'axios'
import axios from 'axios'
import { LEON_VERSION, NODEJS_BRIDGE_VERSION } from '@bridge/constants'
interface NetworkOptions {
/** `baseURL` will be prepended to `url`. It can be convenient to set `baseURL` for an instance of `Network` to pass relative URLs. */
baseURL?: string
}
interface NetworkRequestOptions {
/** Server URL that will be used for the request. */
url: string
/** Request method to be used when making the request. */
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
/** Data to be sent as the request body. */
data?: unknown
/** Custom headers to be sent. */
headers?: Record<string, string>
/** Optional files for multipart/form-data requests (parity with Python SDK). */
files?: Record<string, unknown>
/** Whether to send JSON body (true by default). If false, send form data. */
useJson?: boolean
/** Response type (defaults to 'json'). Use 'arraybuffer' for binary data like audio/video files. */
responseType?:
| 'json'
| 'text'
| 'arraybuffer'
| 'blob'
| 'document'
| 'stream'
}
interface NetworkResponse<ResponseData> {
/** Data provided by the server. */
data: ResponseData
/** HTTP status code from the server response. */
statusCode: number
/** Options that was provided for the request. */
options: NetworkRequestOptions & NetworkOptions
}
const formatErrorData = (data: unknown): string => {
if (typeof data === 'string') {
return data
}
try {
return JSON.stringify(data)
} catch {
return String(data)
}
}
export class NetworkError<ResponseErrorData = unknown> extends Error {
public readonly response: NetworkResponse<ResponseErrorData>
constructor(response: NetworkResponse<ResponseErrorData>) {
super(`[NetworkError]: ${response.statusCode}`)
this.response = response
Object.setPrototypeOf(this, NetworkError.prototype)
}
}
export class Network {
private readonly options: NetworkOptions
private axios: AxiosInstance
constructor(options: NetworkOptions = {}) {
this.options = options
this.axios = axios.create({
baseURL: this.options.baseURL
})
}
/**
* Send HTTP request
* @param options Request options
* @example request({ url: '/send', method: 'POST', data: { message: 'Hi' } })
*/
public async request<ResponseData = unknown, ResponseErrorData = unknown>(
options: NetworkRequestOptions
): Promise<NetworkResponse<ResponseData>> {
try {
const response = await this.axios.request({
url: options.url,
method: options.method.toLowerCase(),
// For parity, we accept any data type here (including FormData)
data: options.data as never,
responseType: options.responseType,
headers: {
'User-Agent': `Leon Personal Assistant ${LEON_VERSION} - Node.js Bridge ${NODEJS_BRIDGE_VERSION}`,
...options.headers
}
})
let data = {} as ResponseData
// For binary response types, return data as-is
if (
options.responseType === 'arraybuffer' ||
options.responseType === 'blob' ||
options.responseType === 'stream'
) {
data = response.data as ResponseData
} else {
// For text/json responses, try to parse as JSON
try {
if (typeof response.data === 'string') {
data = JSON.parse(response.data)
} else {
data = response.data as ResponseData
}
} catch {
data = response.data as ResponseData
}
}
return {
data,
statusCode: response.status,
options: {
...this.options,
...options
}
}
} catch (error) {
let statusCode = 500
let dataRawText = ''
if (axios.isAxiosError(error)) {
dataRawText = error?.response?.data ?? ''
statusCode = error?.response?.status ?? 500
}
let data: ResponseErrorData
try {
data = JSON.parse(dataRawText)
} catch {
data = dataRawText as ResponseErrorData
}
const response: NetworkResponse<ResponseErrorData> = {
data,
statusCode,
options: {
...this.options,
...options
}
}
console.error(
'[NetworkError]',
response.statusCode,
options.method,
options.url,
formatErrorData(response.data)
)
throw new NetworkError<ResponseErrorData>(response)
}
}
/**
* Check if error is a network error
* @param error Error to check
* @example isNetworkError(error) // false
*/
public isNetworkError<ResponseErrorData = unknown>(
error: unknown
): error is NetworkError<ResponseErrorData> {
return error instanceof NetworkError
}
/**
* Verify whether there is an Internet connectivity
* @example isNetworkAvailable() // true
*/
public async isNetworkAvailable(): Promise<boolean> {
try {
await dns.promises.resolve('getleon.ai')
return true
} catch {
return false
}
}
}
@@ -0,0 +1 @@
export { default } from 'lodash'
+122
View File
@@ -0,0 +1,122 @@
import type { ActionParams, NEREntity } from '@sdk/types'
import { INTENT_OBJECT } from '@bridge/constants'
export class ParamsHelper {
private readonly params: ActionParams
constructor(params: ActionParams) {
this.params = params
}
/**
* Get the widget id if any
* @example getWidgetId() // 'timerwidget-5q1xlzeh
*/
getWidgetId(): string | null {
return (
INTENT_OBJECT.entities?.find((entity) => entity.entity === 'widgetid')
?.sourceText ?? null
)
}
/**
* Get a specific action argument from the current turn by its name
* @param name The name of the action argument to retrieve
*/
getActionArgument(name: string): string | undefined {
return this.params.action_arguments[name] as string | undefined
}
/**
* Find the first entity in the current turn that matches the given name
* @param entityName The name of the entity to find (e.g., 'language', 'date')
*/
findEntity(entityName: string): NEREntity | undefined {
return this.params.entities.find((entity) => entity.entity === entityName)
}
/**
* Find the last entity in the current turn that matches the given name
* Useful when an utterance contains duplicates
* @param entityName The name of the entity to find (e.g., 'color')
*/
findLastEntity(entityName: string): NEREntity | undefined {
return [...this.params.entities]
.reverse()
.find((entity) => entity.entity === entityName)
}
/**
* Find all entities in the current turn that match the given name
* @param entityName The name of the entities to find (e.g., 'date')
*/
findAllEntities(entityName: string): NEREntity[] {
return this.params.entities.filter((entity) => entity.entity === entityName)
}
/**
* Find the first action argument in the conversation context that matches the given name
* @param name The name of the action argument to find
*/
findActionArgumentFromContext(name: string): string | undefined {
for (const args of this.params.context.action_arguments) {
if (args && name in args) {
return args[name] as string | undefined
}
}
return undefined
}
/**
* Find the most recent value for a given action argument from the conversation context.
* It searches backwards from the most recent turn
* @param name The name of the action argument to find
*/
findLastActionArgumentFromContext(name: string): string | undefined {
// Iterate backwards through the history of action arguments
for (
let i = this.params.context.action_arguments.length - 1;
i >= 0;
i -= 1
) {
const args = this.params.context.action_arguments[i]
if (args && name in args) {
return args[name] as string | undefined
}
}
return undefined
}
/**
* Find the most recently detected entity (the last one from the context) that matches the given name.
* This is useful for recalling the last time an owner mentioned a specific piece of information
* @param entityName The name of the entity to find in the conversation history
*/
findLastEntityFromContext(entityName: string): NEREntity | undefined {
// The context.entities are stored chronologically, so reversing and finding the first is correct
return [...this.params.context.entities]
.reverse()
.find((entity) => entity.entity === entityName)
}
/**
* Find all historical entities that match the given name from the entire conversation context
* @param entityName The name of the entities to find in the conversation history
*/
findAllEntitiesFromContext(entityName: string): NEREntity[] {
return this.params.context.entities.filter(
(entity) => entity.entity === entityName
)
}
/**
* Get a value stored in the generic context data store
* @param key The key to retrieve
*/
getContextData<T = unknown>(key: string): T | undefined {
return this.params.context.data?.[key] as T | undefined
}
}
+134
View File
@@ -0,0 +1,134 @@
import path from 'node:path'
import fs from 'node:fs'
import { PROFILE_NATIVE_SKILLS_PATH, SKILL_PATH } from '@bridge/constants'
export class Settings<T extends Record<string, unknown>> {
private readonly settingsPath: string
private readonly settingsSamplePath: string
constructor() {
this.settingsPath = path.join(
PROFILE_NATIVE_SKILLS_PATH,
path.basename(SKILL_PATH),
'settings.json'
)
this.settingsSamplePath = path.join(
SKILL_PATH,
'src',
'settings.sample.json'
)
}
/**
* Check if a setting is already set
* @param key The key to verify whether its value is set
* @returns isSettingSet('apiKey') // true
*/
public async isSettingSet(key: string): Promise<boolean> {
const settingsSample = await this.getSettingsSample()
const settings = await this.get()
return (
!!settings[key] &&
JSON.stringify(settings[key]) !== JSON.stringify(settingsSample[key])
)
}
/**
* Clear the settings and set it to the default settings.sample.json file
* @example clear()
*/
public async clear(): Promise<void> {
const settingsSample = await this.getSettingsSample()
await this.set(settingsSample)
}
private async getSettingsSample(): Promise<T> {
try {
return JSON.parse(
await fs.promises.readFile(this.settingsSamplePath, 'utf8')
)
} catch (e) {
console.error(
`Error while reading settings sample at "${this.settingsSamplePath}":`,
e
)
throw e
}
}
/**
* Get the settings
* @param key The key of the setting to get
* @example get('API_KEY') // 'value'
* @example get() // { API_KEY: 'value' }
*/
public async get<Key extends keyof T>(key: Key): Promise<T[Key]>
public async get(): Promise<T>
public async get<Key extends keyof T>(key?: Key): Promise<T | T[Key]> {
try {
if (!fs.existsSync(this.settingsPath)) {
await this.clear()
}
const settings = JSON.parse(
await fs.promises.readFile(this.settingsPath, 'utf8')
)
if (key != null) {
return settings[key]
}
return settings
} catch (e) {
console.error(
`Error while reading settings at "${this.settingsPath}":`,
e
)
throw e
}
}
/**
* Set the settings
* @param key The key of the setting to set
* @param value The value of the setting to set
* @example set({ API_KEY: 'value' }) // { API_KEY: 'value' }
*/
public async set<Key extends keyof T>(key: Key, value: T[Key]): Promise<T>
public async set(settings: T): Promise<T>
public async set<Key extends keyof T>(
keyOrSettings: Key | T,
value?: T[Key]
): Promise<T> {
try {
const newSettings =
typeof keyOrSettings === 'object'
? keyOrSettings
: {
...(await this.get()),
[keyOrSettings]: value
}
await fs.promises.mkdir(path.dirname(this.settingsPath), {
recursive: true
})
await fs.promises.writeFile(
this.settingsPath,
JSON.stringify(newSettings, null, 2)
)
return newSettings
} catch (e) {
console.error(
`Error while writing settings at "${this.settingsPath}":`,
e
)
throw e
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import { formatFilePath } from '@sdk/utils'
import { Tool } from '@sdk/base-tool'
import { reportToolOutput } from '@sdk/tool-reporter'
export class MissingToolSettingsError extends Error {
missing: string[]
settingsPath: string
constructor(missing: string[], settingsPath: string) {
super(`Missing tool settings: ${missing.join(', ')}`)
this.name = 'MissingToolSettingsError'
this.missing = missing
this.settingsPath = settingsPath
}
}
export const isMissingToolSettingsError = (
error: unknown
): error is MissingToolSettingsError => {
return error instanceof MissingToolSettingsError
}
export default class ToolManager {
static async initTool<TTool extends Tool>(
ToolClass: new () => TTool
): Promise<TTool> {
const tool = new ToolClass()
const missing = tool.getMissingSettings()
if (missing) {
try {
await reportToolOutput({
key: 'bridges.tools.missing_settings',
data: {
tool_name: tool.aliasToolName,
missing: missing.missing.join(', '),
settings_path: formatFilePath(missing.settingsPath)
},
core: {
should_stop_skill: true
}
})
} catch (error) {
console.warn(
`[LEON_TOOL_LOG] Failed to report missing tool settings: ${
(error as Error).message
}`
)
}
throw new MissingToolSettingsError(missing.missing, missing.settingsPath)
}
return tool
}
}
+17
View File
@@ -0,0 +1,17 @@
export type ToolReporter = (input: Record<string, unknown>) => Promise<void>
let toolReporter: ToolReporter | null = null
export const setToolReporter = (reporter: ToolReporter): void => {
toolReporter = reporter
}
export const reportToolOutput = async (
input: Record<string, unknown>
): Promise<void> => {
if (!toolReporter) {
return
}
await toolReporter(input)
}
+164
View File
@@ -0,0 +1,164 @@
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { getPlatformName } from '@sdk/utils'
import {
PROFILE_TOOLS_PATH,
TOOLS_PATH
} from '@bridge/constants'
interface ToolConfig {
tool_id: string
toolkit_id: string
name: string
description: string
binaries?: Record<string, string>
resources?: Record<string, string[]>
functions: Record<
string,
{ description: string, input_schema: Record<string, string> }
>
}
interface ToolkitConfigData {
name: string
description: string
tools: string[]
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
)
}
function mergeMissingSettings(
defaultSettings: Record<string, unknown>,
existingSettings: Record<string, unknown>
): Record<string, unknown> {
const mergedSettings = { ...existingSettings }
for (const [key, defaultValue] of Object.entries(defaultSettings)) {
const existingValue = existingSettings[key]
if (!Object.prototype.hasOwnProperty.call(existingSettings, key)) {
mergedSettings[key] = defaultValue
continue
}
if (isPlainObject(defaultValue) && isPlainObject(existingValue)) {
mergedSettings[key] = mergeMissingSettings(defaultValue, existingValue)
}
}
return mergedSettings
}
export class ToolkitConfig {
private static configCache = new Map<string, ToolkitConfigData>()
private static settingsCache = new Map<string, Record<string, unknown>>()
/**
* Load tool configuration from the flat tools structure.
* @param toolkitName - The toolkit name (e.g., 'video_streaming')
* @param toolName - Name of the tool (e.g., 'ffmpeg')
*/
static load(toolkitName: string, toolName: string): ToolConfig {
const cacheKey = toolkitName
// Load toolkit config if not cached
if (!this.configCache.has(cacheKey)) {
const configPath = join(TOOLS_PATH, toolkitName, 'toolkit.json')
const configContent = readFileSync(configPath, 'utf-8')
const config = JSON.parse(configContent) as ToolkitConfigData
this.configCache.set(cacheKey, config)
}
const toolkitConfig = this.configCache.get(cacheKey)!
const toolConfigPath = join(TOOLS_PATH, toolkitName, toolName, 'tool.json')
if (!toolkitConfig.tools.includes(toolName) && !existsSync(toolConfigPath)) {
throw new Error(
`Tool '${toolName}' not found in toolkit '${toolkitConfig.name}'`
)
}
const toolConfigContent = readFileSync(toolConfigPath, 'utf-8')
const toolConfig = JSON.parse(toolConfigContent) as ToolConfig
return toolConfig
}
/**
* Load tool-specific settings from toolkit settings file
* @param toolkitName - The toolkit name (e.g., 'video_streaming')
* @param toolName - Name of the tool (e.g., 'ffmpeg')
* @param defaults - Default tool settings to apply when missing
*/
static loadToolSettings(
toolkitName: string,
toolName: string,
defaults: Record<string, unknown> = {}
): Record<string, unknown> {
const cacheKey = `${toolkitName}:${toolName}`
if (this.settingsCache.has(cacheKey)) {
return this.settingsCache.get(cacheKey) || {}
}
const settingsPath = join(
PROFILE_TOOLS_PATH,
toolkitName,
toolName,
'settings.json'
)
const settingsSamplePath = join(
TOOLS_PATH,
toolkitName,
toolName,
'settings.sample.json'
)
const settingsDir = dirname(settingsPath)
const defaultSettings = existsSync(settingsSamplePath)
? (JSON.parse(
readFileSync(settingsSamplePath, 'utf-8')
) as Record<string, unknown>)
: defaults
mkdirSync(settingsDir, { recursive: true })
let toolSettings: Record<string, unknown> = {}
let shouldWrite = false
if (existsSync(settingsPath)) {
const settingsContent = readFileSync(settingsPath, 'utf-8')
toolSettings = JSON.parse(settingsContent) as Record<string, unknown>
} else {
shouldWrite = true
}
const mergedSettings = mergeMissingSettings(defaultSettings, toolSettings)
if (!shouldWrite) {
shouldWrite = JSON.stringify(toolSettings) !== JSON.stringify(mergedSettings)
}
if (shouldWrite) {
writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2))
}
this.settingsCache.set(cacheKey, mergedSettings)
return mergedSettings
}
/**
* Get binary download URL for current platform with architecture granularity
*/
static getBinaryUrl(config: ToolConfig): string | undefined {
const platformName = getPlatformName()
return config.binaries?.[platformName]
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Action types
*/
import type {
ActionParams,
IntentObject,
SkillAnswerCoreData,
SkillAnswerOutput
} from '@/core/brain/types'
import type { SkillAnswerConfigSchema } from '@/schemas/skill-schemas'
import type { Widget } from '@sdk/widget'
import { ParamsHelper } from '@sdk/params-helper'
export type { ActionParams, IntentObject }
export * from '@/core/nlp/types'
export type ActionFunction = (
params: ActionParams,
paramsHelper: ParamsHelper
) => Promise<void>
/**
* Answer types
*/
export interface Answer {
key?: string
widget?: Widget
data?: AnswerData
core?: SkillAnswerCoreData
replaceMessageId?: string | null
widgetHistoryMode?: 'persisted' | 'system_widget'
}
export interface TextAnswer extends Answer {
key: string
}
export interface WidgetAnswer extends Answer {
widget: Widget
key?: string
}
export type AnswerData = Record<string, string | number> | null
export type AnswerInput = TextAnswer | WidgetAnswer | Answer
export type AnswerOutput = SkillAnswerOutput
export type AnswerConfig = SkillAnswerConfigSchema
+301
View File
@@ -0,0 +1,301 @@
import { platform, arch, cpus } from 'node:os'
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import axios from 'axios'
const HUGGING_FACE_URL = 'https://huggingface.co'
const HUGGING_FACE_MIRROR_URL = 'https://hf-mirror.com'
const ZIP_ARCHIVE_EXTENSIONS = new Set(['.zip', '.whl'])
/**
* Formats a file path as a clickable path with proper delimiters
* @param filePath The absolute file path to format
* @returns A formatted string that the client can detect and make clickable
* @example formatFilePath('/Users/john/video.mp4') // returns '[FILE_PATH]/Users/john/video.mp4[/FILE_PATH]'
*/
export function formatFilePath(filePath: string): string {
return `[FILE_PATH]${filePath}[/FILE_PATH]`
}
/**
* Formats multiple file paths as a list of clickable paths
* @param filePaths Array of absolute file paths
* @returns A formatted string with multiple clickable paths
* @example formatFilePaths(['/path1', '/path2']) // returns '[FILE_PATH]/path1[/FILE_PATH], [FILE_PATH]/path2[/FILE_PATH]'
*/
export function formatFilePaths(filePaths: string[]): string {
return filePaths.map(formatFilePath).join(', ')
}
/**
* Normalize a language input to an ISO 639-1 code.
* Supports direct language codes and locale tags such as `fr-FR`.
*/
export function normalizeLanguageCode(value: string): string | null {
const trimmedValue = value.trim()
if (trimmedValue === '') {
return null
}
try {
const locale = new Intl.Locale(trimmedValue)
return locale.language ? locale.language.toLowerCase() : null
} catch {
const normalizedValue = trimmedValue.toLowerCase()
if (
normalizedValue.length === 2 &&
[...normalizedValue].every((char) => char >= 'a' && char <= 'z')
) {
return normalizedValue
}
return null
}
}
/**
* Platform utilities for consistent platform and architecture detection
* Matches the naming convention from system-helper.ts BinaryFolderNames enum
*/
/**
* Get platform name with architecture granularity (matches system-helper.ts)
* Returns same format as BinaryFolderNames enum from system-helper.ts
*/
export function getPlatformName(): string {
const platformName = platform()
const cpuArchitecture = arch()
if (platformName === 'linux') {
if (cpuArchitecture === 'x64') {
return 'linux-x86_64'
}
return 'linux-aarch64'
}
if (platformName === 'darwin') {
const cpuCores = cpus()
const isM1 = cpuCores[0]?.model.includes('Apple')
if (isM1 || cpuArchitecture === 'arm64') {
return 'macosx-arm64'
}
return 'macosx-x86_64'
}
if (platformName === 'win32') {
return 'win-amd64'
}
return 'unknown'
}
/**
* Check if current platform is Windows
* @returns True if running on Windows, false otherwise
* @example if (isWindows()) { executableName += '.exe' }
*/
export function isWindows(): boolean {
return getPlatformName().startsWith('win')
}
/**
* Check if current platform is macOS
* @returns True if running on macOS, false otherwise
* @example if (isMacOS()) { await removeQuarantineAttribute(binaryPath) }
*/
export function isMacOS(): boolean {
return getPlatformName().startsWith('macosx')
}
/**
* Check if current platform is Linux
* @returns True if running on Linux, false otherwise
* @example if (isLinux()) { await checkSystemPackage('ffmpeg') }
*/
export function isLinux(): boolean {
return getPlatformName().startsWith('linux')
}
/**
* Check if the current network can access Hugging Face
* @example canAccessHuggingFace() // true
*/
export async function canAccessHuggingFace(): Promise<boolean> {
try {
await axios.head(HUGGING_FACE_URL, { timeout: 5000 })
return true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return false
}
}
/**
* Set the Hugging Face URL based on the network access
* @param url The URL to set
* @example setHuggingFaceURL('https://huggingface.co') // https://hf-mirror.com
*/
export async function setHuggingFaceURL(url: string): Promise<string> {
if (!url.includes('huggingface.co')) {
return url
}
const canAccess = await canAccessHuggingFace()
if (!canAccess) {
return url.replace(HUGGING_FACE_URL, HUGGING_FACE_MIRROR_URL)
}
return url
}
/**
* Format bytes into human-readable units
* @param bytes The number of bytes to format
* @returns A human-readable string representation
* @example formatBytes(1024) // "1 KB"
* @example formatBytes(1536) // "1.5 KB"
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) {
return '0 B'
}
const k = 1_024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* Format speed from raw number to human-readable format
* @param speed The speed in bytes per second (or already formatted string)
* @returns A human-readable speed string
* @example formatSpeed(1_024) // "1 KB/s"
* @example formatSpeed("1.5 MB/s") // "1.5 MB/s" (already formatted)
*/
export function formatSpeed(speed: string | number): string {
if (typeof speed === 'string') {
// If it's already formatted (e.g., "1.5 MB/s"), return as is
if (speed.includes('/s')) {
return speed
}
// If it's a string number, convert to number
speed = parseFloat(speed)
}
if (isNaN(speed) || speed === 0) {
return '0 B/s'
}
// Assume speed is in bytes per second
return formatBytes(speed) + '/s'
}
/**
* Format ETA from seconds to human-readable format
* @param eta The ETA in seconds (or already formatted string)
* @returns A human-readable ETA string
* @example formatETA(3661) // "1h 1m 1s"
* @example formatETA(90) // "1m 30s"
* @example formatETA("00:01:30") // "1m 30s" (parsed from HH:MM:SS)
*/
export function formatETA(eta: string | number): string {
if (typeof eta === 'string') {
// If it's already formatted (e.g., "00:02:45"), return as is
if (eta.includes(':')) {
return eta
}
// If it's a string number, convert to number
eta = parseFloat(eta)
}
if (isNaN(eta) || eta <= 0) {
return '∞'
}
const hours = Math.floor(eta / 3_600)
const minutes = Math.floor((eta % 3_600) / 60)
const seconds = Math.floor(eta % 60)
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
/**
* Extract archive file using native system commands
* Supports .zip, .tar, .tar.gz, .tar.xz, .tgz formats across all platforms
* @param archivePath The path to the archive file
* @param targetPath The path to extract to
* @param options Extraction options
* @example extractArchive('archive.zip', 'output/dir')
* @example extractArchive('archive.tar.xz', 'output/dir', { stripComponents: 1 })
*/
export async function extractArchive(
archivePath: string,
targetPath: string,
options?: {
stripComponents?: number
}
): Promise<void> {
const stripComponents = options?.stripComponents ?? 0
// Ensure target directory exists
await fs.promises.mkdir(targetPath, { recursive: true })
const ext = path.extname(archivePath).toLowerCase()
const basename = path.basename(archivePath).toLowerCase()
try {
if (ZIP_ARCHIVE_EXTENSIONS.has(ext)) {
if (isWindows()) {
execFileSync('tar', ['-xf', archivePath, '-C', targetPath], {
stdio: 'inherit',
windowsHide: true
})
} else {
execFileSync('unzip', ['-o', '-q', archivePath, '-d', targetPath], {
stdio: 'inherit',
windowsHide: true
})
}
} else if (
basename.endsWith('.tar.gz') ||
basename.endsWith('.tar.xz') ||
basename.endsWith('.tgz') ||
ext === '.tar'
) {
const tarArgs = ['-xf', archivePath, '-C', targetPath]
if (stripComponents > 0) {
tarArgs.push(`--strip-components=${stripComponents}`)
}
execFileSync('tar', tarArgs, {
stdio: 'inherit',
windowsHide: true
})
} else {
throw new Error(`Unsupported archive format: ${archivePath}`)
}
} catch (error) {
throw new Error(
`Failed to extract archive "${archivePath}": ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
@@ -0,0 +1,51 @@
export type SupportedWidgetEvent = (typeof SUPPORTED_WIDGET_EVENTS)[number]
interface WidgetEvent {
type: SupportedWidgetEvent
id: string
}
export const SUPPORTED_WIDGET_EVENTS = [
'onClick',
'onSubmit',
'onChange',
'onStart',
'onEnd'
] as const
function generateId(): string {
return Math.random().toString(36).substring(2, 7)
}
export abstract class WidgetComponent<T = unknown> {
public readonly component: string
public readonly id: string
public readonly props: T
public readonly events: WidgetEvent[]
protected constructor(props: T) {
this.component = this.constructor.name
this.id = `${this.component.toLowerCase()}-${generateId()}`
this.props = props
this.events = this.parseEvents()
}
private parseEvents(): WidgetEvent[] {
if (!this.props) {
return []
}
const eventTypes = Object.keys(this.props).filter(
(key) =>
key.startsWith('on') &&
SUPPORTED_WIDGET_EVENTS.includes(key as SupportedWidgetEvent)
) as SupportedWidgetEvent[]
return eventTypes.map((type) => ({
type,
id: `${this.id}_${type.toLowerCase()}-${generateId()}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
method: this.props[type]()
}))
}
}
+138
View File
@@ -0,0 +1,138 @@
import { type WidgetWrapperProps } from '@aurora'
import { INTENT_OBJECT, SKILL_LOCALE_CONFIG } from '@bridge/constants'
import { WidgetComponent } from '@sdk/widget-component'
type UtteranceSender = 'leon' | 'owner'
interface SendUtteranceWidgetEventMethodParams {
from: UtteranceSender
utterance: string
}
interface RunSkillActionWidgetEventMethodParams {
actionName: string
params: Record<string, unknown>
}
interface SendUtteranceOptions {
from?: UtteranceSender
data?: Record<string, unknown>
}
export interface WidgetEventMethod {
methodName: 'send_utterance' | 'run_skill_action'
methodParams:
| SendUtteranceWidgetEventMethodParams
| RunSkillActionWidgetEventMethodParams
}
export interface WidgetOptions<T = unknown> {
wrapperProps?: Omit<WidgetWrapperProps, 'children'>
onFetch?: {
widgetId?: string | undefined
actionName: string
}
params: T
}
export abstract class Widget<T = unknown> {
public actionName: string
public id: string
public widget: string
public onFetch: WidgetOptions<T>['onFetch'] | null = null
public wrapperProps: WidgetOptions<T>['wrapperProps']
public params: WidgetOptions<T>['params']
protected constructor(options: WidgetOptions<T>) {
if (options?.wrapperProps) {
this.wrapperProps = options.wrapperProps
}
this.actionName = `${INTENT_OBJECT.skill_name}:${INTENT_OBJECT.action_name}`
this.params = options.params
this.widget = this.constructor.name
if (options?.onFetch) {
this.onFetch = {
widgetId: options.onFetch.widgetId,
actionName: `${INTENT_OBJECT.skill_name}:${options.onFetch.actionName}`
}
}
this.id =
options.onFetch?.widgetId ||
`${this.widget.toLowerCase()}-${Math.random()
.toString(36)
.substring(2, 10)}`
}
/**
* Render the widget
*/
public abstract render(): WidgetComponent<unknown>
/**
* Indicate the core to send a given utterance
* @param key The key of the content
* @param options The options of the utterance
* @example content('provider_selected', { data: { provider: 'Spotify' } }) // 'I chose the Spotify provider'
*/
protected sendUtterance(
key: string,
options?: SendUtteranceOptions
): WidgetEventMethod {
const utteranceContent = this.content(key, options?.data)
const from = options?.from || 'owner'
return {
methodName: 'send_utterance',
methodParams: {
from,
utterance: utteranceContent
}
}
}
/**
* Indicate the core to run a given skill action
* @param actionName The name of the action
* @param params The parameters of the action
* @example runSkillAction('music_player_skill:next', { provider: 'Spotify' })
*/
protected runSkillAction(
actionName: string,
params: Record<string, unknown>
): WidgetEventMethod {
return {
methodName: 'run_skill_action',
methodParams: {
actionName,
params
}
}
}
/**
* Grab and compute the target content of the widget
* @param key The key of the content
* @param data The data to apply
* @example content('select_provider') // 'Please select a provider'
* @example content('provider_selected', { provider: 'Spotify' }) // 'I chose the Spotify provider'
*/
protected content(key: string, data?: Record<string, unknown>): string {
const { widget_contents: widgetContents } = SKILL_LOCALE_CONFIG
if (!widgetContents || !widgetContents[key]) {
return 'INVALID'
}
let content = widgetContents[key]
if (Array.isArray(content)) {
content = content[Math.floor(Math.random() * content.length)] as string
}
if (data) {
for (const key in data) {
content = content.replaceAll(`{{ ${key} }}`, String(data[key]))
}
}
return content
}
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Tool runtime for executing Node.js tools.
* This runtime exists only for Node.js because the core server is built on Node.js
* and the ReAct loop only needs a single bridge for now.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Tool } from '@sdk/base-tool'
import {
TOOLS_PATH
} from '@bridge/constants'
import { setToolReporter } from '@sdk/tool-reporter'
interface ToolRuntimeCliInput {
toolkitId: string
toolId: string
functionName: string
args: unknown[]
}
const parseArgs = (): ToolRuntimeCliInput => {
const args = process.argv.slice(2)
const getValue = (flag: string): string => {
const index = args.indexOf(flag)
if (index === -1 || index === args.length - 1) {
return ''
}
return args[index + 1] || ''
}
const toolkitId = getValue('--toolkit')
const toolId = getValue('--tool')
const functionName = getValue('--function')
const rawArgs = getValue('--args')
if (!toolkitId || !toolId || !functionName) {
throw new Error('Missing required arguments: --toolkit, --tool, --function')
}
let parsedArgs: unknown[] = []
if (rawArgs) {
const decoded = JSON.parse(rawArgs)
if (Array.isArray(decoded)) {
parsedArgs = decoded
} else if (decoded && typeof decoded === 'object') {
parsedArgs = Object.values(decoded)
}
}
return {
toolkitId,
toolId,
functionName,
args: parsedArgs
}
}
const resolveToolModulePath = (
toolkitId: string,
toolId: string
): string | null => {
const flatBuiltInToolPath = path.join(
TOOLS_PATH,
toolkitId,
toolId,
'src',
'nodejs',
'index.ts'
)
if (fs.existsSync(flatBuiltInToolPath)) {
return flatBuiltInToolPath
}
return null
}
const setProjectCwd = (): void => {
const runtimeDir = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.join(runtimeDir, '..', '..', '..')
if (process.cwd() !== projectRoot) {
process.chdir(projectRoot)
}
}
const setRuntimeToolReporter = (): void => {
setToolReporter(async (input) => {
process.stderr.write(`[LEON_TOOL_REPORT] ${JSON.stringify(input)}\n`)
})
}
const run = async (): Promise<void> => {
try {
setProjectCwd()
setRuntimeToolReporter()
const input = parseArgs()
const toolModulePath = resolveToolModulePath(
input.toolkitId,
input.toolId
)
if (!toolModulePath) {
throw new Error(`Tool module not found for ${input.toolId}.`)
}
const toolManagerModule = await import('@sdk/tool-manager')
const ToolManager = toolManagerModule.default
const isMissingToolSettingsError =
toolManagerModule.isMissingToolSettingsError
const toolModule = await import(pathToFileURL(toolModulePath).href)
const ToolClass = toolModule?.default
if (!ToolClass) {
throw new Error(`Tool ${input.toolId} has no default export.`)
}
let toolInstance: Tool
try {
toolInstance = (await ToolManager.initTool(
ToolClass as new () => Tool
)) as Tool
} catch (error) {
if (isMissingToolSettingsError(error)) {
process.stdout.write(
JSON.stringify({
success: false,
message: error.message,
output: {
missing_settings: error.missing,
settings_path: error.settingsPath
}
})
)
process.exitCode = 1
return
}
throw error
}
const method = (toolInstance as unknown as Record<string, unknown>)?.[
input.functionName
]
if (typeof method !== 'function') {
throw new Error(
`Function ${input.functionName} not found on ${input.toolId}.`
)
}
const result = await method.apply(toolInstance, input.args)
process.stdout.write(
JSON.stringify({
success: true,
message: 'Tool executed successfully.',
output: { result }
})
)
} catch (error) {
const message = (error as Error).message || 'Unknown tool runtime error.'
process.stdout.write(
JSON.stringify({
success: false,
message,
output: {}
})
)
process.exitCode = 1
}
}
void run()
+1
View File
@@ -0,0 +1 @@
export const VERSION = '1.3.0'
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist/bin",
"rootDir": "../../",
"baseUrl": ".",
"paths": {
"@@/*": ["../../*"],
"@/*": ["../../server/src/*"],
"@aurora/style.css": ["../../aurora/style.css"],
"@aurora": ["../../aurora/dist/index.d.ts"],
"@aurora/*": ["../../aurora/dist/*"],
"@server/*": ["../../server/src/*"],
"@bridge/*": ["./src/*"],
"@sdk/*": ["./src/sdk/*"],
"@tools/*": ["../../tools/*/src/nodejs/index.ts", "../../tools/*.ts"]
},
"exactOptionalPropertyTypes": false,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}