Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 547c8d764a | |||
| 3677a82efb | |||
| 00e23ae1ad | |||
| 4823bffb37 | |||
| bc2854c33f | |||
| 12749b8d03 | |||
| e45e3d322b | |||
| 9c465101f1 | |||
| 0e1cdc5641 | |||
| 4e02224e33 | |||
| fd6ca467ed | |||
| 4633c50fb7 | |||
| 4ac896e08d | |||
| 22d4586b6e | |||
| 78f08eede0 | |||
| f8a46e1812 | |||
| 9d6d4a5dfb | |||
| e6ae6bc446 | |||
| 6b5bab7dda | |||
| e480c72736 | |||
| 8e8c6da0f4 | |||
| b3f6c08d4d | |||
| 97c0f2f4dc | |||
| bab9813c5d | |||
| 269655aa0e | |||
| 2ddd662ce0 | |||
| ff190ad63a | |||
| 304cf1a78a | |||
| a2be039016 | |||
| 5057f4cfc0 | |||
| 659077299a | |||
| e8e7736448 | |||
| d0e85a664e | |||
| 60fcb9a824 | |||
| e40e282fee | |||
| f9ac31a128 | |||
| 8f7ec5fda3 | |||
| d3be7b95ef | |||
| be1216830e | |||
| 316f926d0d | |||
| edc4026a2c | |||
| 86810a6260 | |||
| 00e601de6c | |||
| 9be1631d8d | |||
| c0de69945a | |||
| ba1316cbb9 | |||
| fdc72ebf2d | |||
| ac46e97703 | |||
| 66fe3cd281 | |||
| 4e68fdb2a3 | |||
| 318958e945 | |||
| f109bb115d | |||
| df4c704bbd | |||
| df85df8034 | |||
| 0d14cf82ec | |||
| e7af942f61 | |||
| 5c90740a48 | |||
| 4ef622a690 | |||
| 1a9a84d2dd | |||
| 0247a24d3d | |||
| d93f209e8f | |||
| c8bf7f513d | |||
| 00e5e1326d | |||
| 0cb109cf5f | |||
| 502f8c3cf7 | |||
| d4225e8e5f | |||
| be8b253ef8 | |||
| 1e3c75dc12 | |||
| ac2dc1372c | |||
| 0be61e1929 | |||
| 22fe5a35b5 | |||
| 82e8f150a5 | |||
| 2cfbbaeeb7 | |||
| f75ee6f91e | |||
| 0d3a0460e6 | |||
| 680c992c55 | |||
| 0f88072736 | |||
| 5e45a41964 | |||
| 87f91f484f | |||
| b28274ad76 | |||
| 1223421478 | |||
| ad95eb9eb9 | |||
| 1f5a116219 | |||
| 03b7a9a429 | |||
| b6d69ef8c1 | |||
| 6805dba502 | |||
| b20ef0ec1b | |||
| c717309647 | |||
| 5af0df23b0 | |||
| 141cc105be | |||
| 302e0154ae | |||
| bd07cb604a | |||
| c7a5fb39d2 | |||
| 5178cf1144 | |||
| e3b65176ea | |||
| 88ef8320a2 | |||
| 7803ca788f | |||
| 4216798c45 |
+5
-2
@@ -15,6 +15,9 @@ LEON_OPENROUTER_API_KEY=
|
||||
# Z.AI API key
|
||||
LEON_ZAI_API_KEY=
|
||||
|
||||
# MiniMax API key
|
||||
LEON_MINIMAX_API_KEY=
|
||||
|
||||
# OpenAI API key
|
||||
LEON_OPENAI_API_KEY=
|
||||
|
||||
@@ -35,5 +38,5 @@ LEON_GROQ_API_KEY=
|
||||
|
||||
# Misc
|
||||
|
||||
# HTTP API key (use "pnpm run generate:http-api-key" to regenerate one)
|
||||
LEON_HTTP_API_KEY=
|
||||
# Token used to authenticate every remote request for this profile
|
||||
LEON_PROFILE_TOKEN=
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Fresh installation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 4 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: fresh-install-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
fresh-install:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
# Scheduled workflows are loaded from the default branch, but Leon's
|
||||
# integration work lands on develop before it reaches a release branch.
|
||||
- name: Check out Leon
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'develop' || github.ref }}
|
||||
|
||||
- name: Read runtime versions
|
||||
id: versions
|
||||
shell: bash
|
||||
run: |
|
||||
echo "node=$(jq -r '.node' bin/node/versions.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "pnpm=$(jq -r '.pnpm' bin/pnpm/versions.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.node }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install fresh pnpm
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install --global "pnpm@${{ steps.versions.outputs.pnpm }}"
|
||||
|
||||
- name: Test the interactive installation
|
||||
env:
|
||||
LEON_FRESH_INSTALL_API_KEY: xxx
|
||||
LEON_FRESH_INSTALL_LOG_PATH: /tmp/leon-fresh-install.log
|
||||
run: node scripts/ci/test-fresh-install.js
|
||||
|
||||
- name: Upload installer log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fresh-install-log
|
||||
path: /tmp/leon-fresh-install.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -8,6 +8,7 @@ __pycache__/
|
||||
**/.venv/
|
||||
**/.last-skill-deps-sync
|
||||
**/.last-source-deps-sync
|
||||
skills/**/reports/*
|
||||
test/coverage/
|
||||
**/tmp/*
|
||||
core/config/**/*.json
|
||||
|
||||
+63
-4
@@ -1,3 +1,5 @@
|
||||
@use 'sass:meta';
|
||||
|
||||
@import '@fontsource/source-sans-pro/200.css';
|
||||
@import '@fontsource/source-sans-pro/300.css';
|
||||
@import '@fontsource/source-sans-pro/400.css';
|
||||
@@ -5,9 +7,10 @@
|
||||
@import '@fontsource/source-sans-pro/700.css';
|
||||
@import '@fontsource/source-sans-pro/900.css';
|
||||
@import 'remixicon/fonts/remixicon.css';
|
||||
@import 'tool-ui.scss';
|
||||
@import 'voice-energy/main.scss';
|
||||
@import 'file-system-autocomplete.scss';
|
||||
|
||||
@include meta.load-css('tool-ui');
|
||||
@include meta.load-css('voice-energy/main');
|
||||
@include meta.load-css('file-system-autocomplete');
|
||||
|
||||
html,
|
||||
body,
|
||||
@@ -175,6 +178,62 @@ body {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.profile-auth {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--black-color);
|
||||
}
|
||||
|
||||
.profile-auth__form {
|
||||
display: grid;
|
||||
width: min(100%, 380px);
|
||||
gap: 16px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--grey-color);
|
||||
border-radius: 12px;
|
||||
background: var(--light-black-color);
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #b9bdbf;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--grey-color);
|
||||
border-radius: 8px;
|
||||
color: var(--white-color);
|
||||
background: var(--black-color);
|
||||
}
|
||||
|
||||
button {
|
||||
border-color: var(--blue-color);
|
||||
background: var(--blue-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.profile-auth__error {
|
||||
min-height: 20px;
|
||||
color: var(--pink-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes plan-tool-status-cozy-shine {
|
||||
0% {
|
||||
@@ -205,7 +264,7 @@ body {
|
||||
body > * {
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
body.settingup > *:not(#init) {
|
||||
body.settingup > *:not(#init):not(.profile-auth) {
|
||||
opacity: 0;
|
||||
}
|
||||
#init .not-initialized {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@use 'sass:color';
|
||||
|
||||
$voice-neon-pink-color: #ed297a;
|
||||
$voice-neon-blue-color: #1c75db;
|
||||
$voice-neon-shadow-color: color.mix(
|
||||
$voice-neon-pink-color,
|
||||
$voice-neon-blue-color
|
||||
);
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
/**
|
||||
* IDLE status
|
||||
*/
|
||||
@@ -15,7 +17,7 @@
|
||||
transform: scale(1);
|
||||
}
|
||||
#purple-neon-blur circle {
|
||||
filter: drop-shadow(0px 0px 64px mix(#ed297a, #1c75db));
|
||||
filter: drop-shadow(0px 0px 64px $voice-neon-shadow-color);
|
||||
animation: idleNeonBlurBreath 2.2s infinite alternate;
|
||||
}
|
||||
#pink-neon-1 {
|
||||
@@ -34,7 +36,7 @@
|
||||
|
||||
@keyframes idleNeonBlurBreath {
|
||||
100% {
|
||||
filter: drop-shadow(0px 0px 0px mix(#ed297a, #1c75db));
|
||||
filter: drop-shadow(0px 0px 0px $voice-neon-shadow-color);
|
||||
}
|
||||
}
|
||||
@keyframes idleBouncePurpleNeonBlur {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
/**
|
||||
* Listening status
|
||||
*/
|
||||
@@ -15,7 +17,7 @@
|
||||
transform: scale(1);
|
||||
}
|
||||
#purple-neon-blur circle {
|
||||
filter: drop-shadow(0px 0px 64px mix(#ed297a, #1c75db));
|
||||
filter: drop-shadow(0px 0px 64px $voice-neon-shadow-color);
|
||||
animation: listeningNeonBlurBreath 0.7s infinite alternate;
|
||||
}
|
||||
#pink-neon-1 {
|
||||
@@ -34,7 +36,7 @@
|
||||
|
||||
@keyframes listeningNeonBlurBreath {
|
||||
100% {
|
||||
filter: drop-shadow(0px 0px 0px mix(#ed297a, #1c75db));
|
||||
filter: drop-shadow(0px 0px 0px $voice-neon-shadow-color);
|
||||
}
|
||||
}
|
||||
@keyframes listeningBouncePurpleNeonBlur {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import 'base.scss';
|
||||
@import 'listening.scss';
|
||||
@import 'idle.scss';
|
||||
@import 'processing.scss';
|
||||
@import 'talking.scss';
|
||||
@use 'base';
|
||||
@use 'listening';
|
||||
@use 'idle';
|
||||
@use 'processing';
|
||||
@use 'talking';
|
||||
|
||||
@@ -937,15 +937,9 @@ export default class Chatbot {
|
||||
|
||||
openPath(filePath) {
|
||||
// Send request to server to open the file path in system file explorer
|
||||
fetch(`${this.serverURL}/api/v1/open-path`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: filePath })
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
axios
|
||||
.post(`${this.serverURL}/api/v1/open-path`, { path: filePath })
|
||||
.then(({ data }) => {
|
||||
if (!data.success) {
|
||||
console.error('Failed to open path:', data.error)
|
||||
}
|
||||
|
||||
+40
-19
@@ -5,13 +5,27 @@ import VoiceEnergy from './voice-energy'
|
||||
import { ASR_DISABLED_MESSAGE, INIT_MESSAGES } from './constants'
|
||||
import handleSuggestions from './suggestion-handler.js'
|
||||
|
||||
const LEON_CLIENT_INTERFACE_PROTOCOL_VERSION = 1
|
||||
const LEON_EVENTS = {
|
||||
init: 'leon:init',
|
||||
utterance: 'leon:utterance',
|
||||
ready: 'leon:ready',
|
||||
answer: 'leon:answer',
|
||||
isTyping: 'leon:is-typing',
|
||||
suggest: 'leon:suggest',
|
||||
llmToken: 'leon:llm-token',
|
||||
llmReasoningToken: 'leon:llm-reasoning-token',
|
||||
ownerUtterance: 'leon:owner-utterance',
|
||||
error: 'leon:error'
|
||||
}
|
||||
|
||||
export default class Client {
|
||||
constructor(client, serverUrl, input, options = {}) {
|
||||
this.client = client
|
||||
this._input = input
|
||||
this.voiceSpeechElement = document.querySelector('#voice-speech')
|
||||
this.serverUrl = serverUrl
|
||||
this.socket = io(this.serverUrl)
|
||||
this.socket = io(this.serverUrl, { withCredentials: true })
|
||||
this.activeSessionId = options.activeSessionId || null
|
||||
this.history = localStorage.getItem('history')
|
||||
this.parsedHistory = []
|
||||
@@ -155,11 +169,18 @@ export default class Client {
|
||||
}
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.socket.emit('init', {
|
||||
client: this.client,
|
||||
this.socket.emit(LEON_EVENTS.init, {
|
||||
protocolVersion: LEON_CLIENT_INTERFACE_PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: this.client,
|
||||
type: 'web_app',
|
||||
name: 'Leon Web App'
|
||||
},
|
||||
sessionId: this.activeSessionId,
|
||||
capabilities: {
|
||||
supportsWidgets: true
|
||||
supportsWidgets: true,
|
||||
supportsTokenStreaming: true,
|
||||
supportsVoice: true
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -167,17 +188,14 @@ export default class Client {
|
||||
/**
|
||||
* Init status listeners
|
||||
*/
|
||||
this.socket.on('init-client-core-server-handshake', (status) => {
|
||||
this.setInitStatus('clientCoreServerHandshake', status)
|
||||
})
|
||||
this.socket.on('init-tcp-server-boot', (status) => {
|
||||
this.setInitStatus('tcpServerBoot', status)
|
||||
})
|
||||
this.socket.on('init-llama-server-boot', (status) => {
|
||||
this.setInitStatus('llamaServerBoot', status)
|
||||
})
|
||||
|
||||
this.socket.on('ready', () => {
|
||||
this.socket.on(LEON_EVENTS.ready, () => {
|
||||
this.setInitStatus('clientCoreServerHandshake', 'success')
|
||||
this.setInitStatus('tcpServerBoot', 'success')
|
||||
|
||||
void this._chatbotInitPromise?.then(() => {
|
||||
setTimeout(() => {
|
||||
const body = document.querySelector('body')
|
||||
@@ -188,7 +206,7 @@ export default class Client {
|
||||
})
|
||||
})
|
||||
|
||||
this.socket.on('answer', (data) => {
|
||||
this.socket.on(LEON_EVENTS.answer, (data) => {
|
||||
/*if (this._isVoiceModeEnabled) {
|
||||
this.voiceEnergy.status = 'listening'
|
||||
}*/
|
||||
@@ -307,7 +325,7 @@ export default class Client {
|
||||
void this.sessionPanel?.refresh()
|
||||
})
|
||||
|
||||
this.socket.on('suggest', (data) => {
|
||||
this.socket.on(LEON_EVENTS.suggest, (data) => {
|
||||
setTimeout(() => {
|
||||
handleSuggestions(data, this.chatbot, this)
|
||||
}, 400)
|
||||
@@ -319,11 +337,11 @@ export default class Client {
|
||||
})*/
|
||||
})
|
||||
|
||||
this.socket.on('is-typing', (data) => {
|
||||
this.socket.on(LEON_EVENTS.isTyping, (data) => {
|
||||
this.chatbot.isTyping('leon', data)
|
||||
})
|
||||
|
||||
this.socket.on('owner-utterance', (data) => {
|
||||
this.socket.on(LEON_EVENTS.ownerUtterance, (data) => {
|
||||
if (!data?.utterance) {
|
||||
return
|
||||
}
|
||||
@@ -352,7 +370,7 @@ export default class Client {
|
||||
this.updateMood(mood)
|
||||
})
|
||||
|
||||
this.socket.on('llm-token', (data) => {
|
||||
this.socket.on(LEON_EVENTS.llmToken, (data) => {
|
||||
if (this._isVoiceModeEnabled) {
|
||||
this.voiceEnergy.status = 'processing'
|
||||
}
|
||||
@@ -394,7 +412,7 @@ export default class Client {
|
||||
this.chatbot.scrollDown()
|
||||
})
|
||||
|
||||
this.socket.on('llm-reasoning-token', (data) => {
|
||||
this.socket.on(LEON_EVENTS.llmReasoningToken, (data) => {
|
||||
if (!data?.generationId || !data?.token) {
|
||||
return
|
||||
}
|
||||
@@ -522,6 +540,10 @@ export default class Client {
|
||||
cb('audio-received')
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.error, (data) => {
|
||||
console.error('Leon client interface error:', data)
|
||||
})
|
||||
|
||||
if (this.history !== null) {
|
||||
this.parsedHistory = JSON.parse(this.history)
|
||||
}
|
||||
@@ -549,8 +571,7 @@ export default class Client {
|
||||
const sentAt =
|
||||
typeof options.sentAt === 'number' ? options.sentAt : Date.now()
|
||||
|
||||
this.socket.emit('utterance', {
|
||||
client: this.client,
|
||||
this.socket.emit(LEON_EVENTS.utterance, {
|
||||
value: trimmedValue,
|
||||
sentAt,
|
||||
sessionId: this.activeSessionId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import Client from './client'
|
||||
import { BuiltInCommands } from './built-in-commands'
|
||||
import FileSystemAutocomplete from './file-system-autocomplete'
|
||||
import SessionsPanel from './sessions'
|
||||
import { ensureProfileAuthentication } from './profile-auth'
|
||||
// import Recorder from './recorder'
|
||||
// import listener from './listener'
|
||||
import { onkeydownstartrecording, onkeydowninput } from './onkeydown'
|
||||
@@ -26,6 +27,8 @@ const serverUrl =
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await ensureProfileAuthentication(serverUrl)
|
||||
|
||||
const [response, sessionsResponse] = await Promise.all([
|
||||
axios.get(`${serverUrl}/api/v1/info`),
|
||||
axios.get(`${serverUrl}/api/v1/sessions`)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const PROFILE_AUTH_API_PATH = '/api/v1/profile-auth'
|
||||
|
||||
function createElement(tagName, options = {}) {
|
||||
const element = document.createElement(tagName)
|
||||
|
||||
if (options.className) {
|
||||
element.className = options.className
|
||||
}
|
||||
if (options.text) {
|
||||
element.textContent = options.text
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
function requestProfileToken(serverUrl) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = createElement('div', { className: 'profile-auth' })
|
||||
const form = createElement('form', { className: 'profile-auth__form' })
|
||||
const title = createElement('h1', { text: 'Connect to Leon' })
|
||||
const description = createElement('p', {
|
||||
text: 'Enter the profile token generated by your Leon instance.'
|
||||
})
|
||||
const input = createElement('input')
|
||||
const button = createElement('button', { text: 'Connect' })
|
||||
const error = createElement('p', { className: 'profile-auth__error' })
|
||||
|
||||
input.type = 'password'
|
||||
input.name = 'profile-token'
|
||||
input.placeholder = 'profile:token'
|
||||
input.autocomplete = 'current-password'
|
||||
input.required = true
|
||||
button.type = 'submit'
|
||||
|
||||
form.append(title, description, input, button, error)
|
||||
overlay.append(form)
|
||||
document.body.append(overlay)
|
||||
input.focus()
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault()
|
||||
button.disabled = true
|
||||
error.textContent = ''
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${serverUrl}${PROFILE_AUTH_API_PATH}`,
|
||||
{ token: input.value.trim() },
|
||||
{ withCredentials: true }
|
||||
)
|
||||
overlay.remove()
|
||||
resolve()
|
||||
} catch (requestError) {
|
||||
error.textContent =
|
||||
requestError.response?.data?.message ||
|
||||
'Unable to authenticate this profile.'
|
||||
button.disabled = false
|
||||
input.select()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Ensure the browser has a profile cookie before initializing the Leon app. */
|
||||
export async function ensureProfileAuthentication(serverUrl) {
|
||||
axios.defaults.withCredentials = true
|
||||
const response = await axios.get(`${serverUrl}${PROFILE_AUTH_API_PATH}`)
|
||||
|
||||
if (!response.data.authenticated) {
|
||||
await requestProfileToken(serverUrl)
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,14 @@ export default function renderAuroraComponent(
|
||||
supportedEvents
|
||||
) {
|
||||
if (component) {
|
||||
// `import/namespace` cannot statically validate dynamic component lookups.
|
||||
// eslint-disable-next-line import/namespace
|
||||
// `import-x/namespace` cannot statically validate dynamic component lookups.
|
||||
// eslint-disable-next-line import-x/namespace
|
||||
let reactComponent = auroraComponents[component.component]
|
||||
/**
|
||||
* Find custom component if a former component is not found
|
||||
*/
|
||||
if (!reactComponent) {
|
||||
// eslint-disable-next-line import/namespace
|
||||
// eslint-disable-next-line import-x/namespace
|
||||
reactComponent = customAuroraComponents[component.component]
|
||||
}
|
||||
|
||||
|
||||
+67
-1
@@ -13,6 +13,15 @@ import {
|
||||
} from '../server/src/leon-roots.ts'
|
||||
|
||||
const APP_DEV_SERVER_PORT = 5_173
|
||||
const NODE_MODULES_PATH_SEGMENT = '/node_modules/'
|
||||
const REACT_VENDOR_PACKAGES = ['react', 'react-dom', 'scheduler']
|
||||
const REALTIME_VENDOR_PACKAGES = [
|
||||
'socket.io-client',
|
||||
'socket.io-parser',
|
||||
'engine.io-client',
|
||||
'engine.io-parser'
|
||||
]
|
||||
const UI_VENDOR_PACKAGE_PREFIXES = ['@ark-ui/', '@zag-js/', '@floating-ui/']
|
||||
|
||||
dotenv.config({ path: PROFILE_DOT_ENV_PATH })
|
||||
|
||||
@@ -42,6 +51,26 @@ function readAppLeonConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModuleId(moduleId) {
|
||||
return moduleId.replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function isNodeModule(moduleId) {
|
||||
return normalizeModuleId(moduleId).includes(NODE_MODULES_PATH_SEGMENT)
|
||||
}
|
||||
|
||||
function includesVendorPackage(moduleId, packageName) {
|
||||
return normalizeModuleId(moduleId).includes(
|
||||
`${NODE_MODULES_PATH_SEGMENT}${packageName}/`
|
||||
)
|
||||
}
|
||||
|
||||
function includesVendorPackagePrefix(moduleId, packagePrefix) {
|
||||
return normalizeModuleId(moduleId).includes(
|
||||
`${NODE_MODULES_PATH_SEGMENT}${packagePrefix}`
|
||||
)
|
||||
}
|
||||
|
||||
// Map necessary Leon's env vars as Vite only expose VITE_*
|
||||
const leonConfig = readAppLeonConfig()
|
||||
process.env.VITE_LEON_NODE_ENV = process.env.LEON_NODE_ENV
|
||||
@@ -68,7 +97,44 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true
|
||||
emptyOutDir: true,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: 'react-vendor',
|
||||
test: (moduleId) =>
|
||||
REACT_VENDOR_PACKAGES.some((packageName) =>
|
||||
includesVendorPackage(moduleId, packageName)
|
||||
),
|
||||
priority: 30
|
||||
},
|
||||
{
|
||||
name: 'realtime-vendor',
|
||||
test: (moduleId) =>
|
||||
REALTIME_VENDOR_PACKAGES.some((packageName) =>
|
||||
includesVendorPackage(moduleId, packageName)
|
||||
),
|
||||
priority: 20
|
||||
},
|
||||
{
|
||||
name: 'ui-vendor',
|
||||
test: (moduleId) =>
|
||||
UI_VENDOR_PACKAGE_PREFIXES.some((packagePrefix) =>
|
||||
includesVendorPackagePrefix(moduleId, packagePrefix)
|
||||
),
|
||||
priority: 10
|
||||
},
|
||||
{
|
||||
name: 'vendor',
|
||||
test: isNodeModule,
|
||||
priority: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: APP_DEV_SERVER_PORT
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-button
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-card
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-checkbox
|
||||
display: flex
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-flexbox
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-form
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-icon-button
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-icon
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-image
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-input-container
|
||||
--a-icon-container-width: calc(var(--a-input-size-md) + var(--a-space-sm))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-link
|
||||
display: inline-block
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/main.sass'
|
||||
@use '../../../styles/main.sass' as *
|
||||
|
||||
.aurora-list
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-loader
|
||||
width: var(--a-loader-size-md)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-progress
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/main.sass'
|
||||
@use '../../../styles/main.sass' as *
|
||||
|
||||
.aurora-radio-group
|
||||
&[data-disabled]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-range-slider
|
||||
--a-track-height: 10px
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-scroll-container
|
||||
position: relative
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/main.sass'
|
||||
@use '../../../styles/main.sass' as *
|
||||
|
||||
.aurora-select-trigger
|
||||
position: relative
|
||||
@@ -75,4 +75,3 @@
|
||||
color: var(--a-color-text)
|
||||
&:last-child
|
||||
border-bottom: none
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-status
|
||||
position: relative
|
||||
@@ -61,4 +61,3 @@
|
||||
border-color: var(--a-color-transparent-red-hover)
|
||||
.aurora-icon
|
||||
color: var(--a-color-red)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-switch
|
||||
--a-switch-width: 44px
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../styles/main.sass'
|
||||
@use '../../../styles/main.sass' as *
|
||||
|
||||
.aurora-tab-group
|
||||
.aurora-tab
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-text
|
||||
font-size: var(--a-font-size-md)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../styles/main.sass'
|
||||
@use '../../styles/main.sass' as *
|
||||
|
||||
.aurora-widget-wrapper
|
||||
position: relative
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import '_includes/reset'
|
||||
@import '_includes/variables'
|
||||
@import '_includes/animations'
|
||||
@use '_includes/reset' as *
|
||||
@use '_includes/variables' as *
|
||||
@use '_includes/animations' as *
|
||||
|
||||
[class^="aurora-"], [class^="aurora-"]:after, [class^="aurora-"]:before
|
||||
scroll-behavior: smooth
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
|
||||
@@ -172,11 +172,13 @@ class Leon {
|
||||
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[]
|
||||
componentTree: {
|
||||
...new WidgetWrapper({
|
||||
...answerInput.widget.wrapperProps,
|
||||
children: [answerInput.widget.render()]
|
||||
})
|
||||
},
|
||||
supportedEvents: [...SUPPORTED_WIDGET_EVENTS]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { formatFilePath } from '@sdk/utils'
|
||||
import { Tool } from '@sdk/base-tool'
|
||||
import { reportToolOutput } from '@sdk/tool-reporter'
|
||||
import {
|
||||
parseSkillToolBridgeMessage,
|
||||
SKILL_TOOL_REQUEST_PREFIX,
|
||||
SKILL_TOOL_REQUEST_TIMEOUT_MS,
|
||||
SKILL_TOOL_RESULT_PREFIX,
|
||||
type SkillToolExecutionInput,
|
||||
type SkillToolExecutionResult,
|
||||
type SkillToolResponse
|
||||
} from '@/core/skill-tool-bridge'
|
||||
|
||||
interface PendingToolRequest {
|
||||
resolve: (result: SkillToolExecutionResult) => void
|
||||
timeout: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export class MissingToolSettingsError extends Error {
|
||||
missing: string[]
|
||||
@@ -21,6 +37,13 @@ export const isMissingToolSettingsError = (
|
||||
}
|
||||
|
||||
export default class ToolManager {
|
||||
private static readonly pendingToolRequests = new Map<
|
||||
string,
|
||||
PendingToolRequest
|
||||
>()
|
||||
private static stdinBuffer = ''
|
||||
private static isListeningForToolResults = false
|
||||
|
||||
static async initTool<TTool extends Tool>(
|
||||
ToolClass: new () => TTool
|
||||
): Promise<TTool> {
|
||||
@@ -52,4 +75,89 @@ export default class ToolManager {
|
||||
|
||||
return tool
|
||||
}
|
||||
|
||||
/** Ask Leon Core to run a profile tool, including Satellite-hosted tools. */
|
||||
static async executeTool<
|
||||
TOutput extends Record<string, unknown> = Record<string, unknown>
|
||||
>(
|
||||
input: SkillToolExecutionInput
|
||||
): Promise<SkillToolExecutionResult<TOutput>> {
|
||||
this.startToolResultListener()
|
||||
|
||||
const requestId = randomUUID()
|
||||
|
||||
return new Promise<SkillToolExecutionResult<TOutput>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingToolRequests.delete(requestId)
|
||||
this.stopToolResultListenerIfIdle()
|
||||
reject(new Error('Leon Core tool execution timed out.'))
|
||||
}, SKILL_TOOL_REQUEST_TIMEOUT_MS)
|
||||
|
||||
timeout.unref?.()
|
||||
this.pendingToolRequests.set(requestId, {
|
||||
resolve: resolve as (result: SkillToolExecutionResult) => void,
|
||||
timeout
|
||||
})
|
||||
process.stdout.write(
|
||||
`${SKILL_TOOL_REQUEST_PREFIX} ${JSON.stringify({ requestId, input })}\n`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private static readonly handleToolResultData = (data: Buffer): void => {
|
||||
this.stdinBuffer += data.toString()
|
||||
|
||||
let newlineIndex = this.stdinBuffer.indexOf('\n')
|
||||
|
||||
while (newlineIndex >= 0) {
|
||||
const line = this.stdinBuffer.slice(0, newlineIndex).trim()
|
||||
this.stdinBuffer = this.stdinBuffer.slice(newlineIndex + 1)
|
||||
this.handleToolResultLine(line)
|
||||
newlineIndex = this.stdinBuffer.indexOf('\n')
|
||||
}
|
||||
}
|
||||
|
||||
private static handleToolResultLine(line: string): void {
|
||||
const response = parseSkillToolBridgeMessage<SkillToolResponse>(
|
||||
line,
|
||||
SKILL_TOOL_RESULT_PREFIX
|
||||
)
|
||||
const pending = response
|
||||
? this.pendingToolRequests.get(response.requestId)
|
||||
: null
|
||||
|
||||
if (!response || !pending) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
this.pendingToolRequests.delete(response.requestId)
|
||||
pending.resolve(response.result)
|
||||
this.stopToolResultListenerIfIdle()
|
||||
}
|
||||
|
||||
private static startToolResultListener(): void {
|
||||
if (this.isListeningForToolResults) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isListeningForToolResults = true
|
||||
process.stdin.ref()
|
||||
process.stdin.on('data', this.handleToolResultData)
|
||||
process.stdin.resume()
|
||||
}
|
||||
|
||||
private static stopToolResultListenerIfIdle(): void {
|
||||
if (this.pendingToolRequests.size > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
process.stdin.off('data', this.handleToolResultData)
|
||||
process.stdin.pause()
|
||||
// A child-process stdin pipe remains referenced after resume/pause. Release
|
||||
// it once all replies arrive so the completed skill process can exit.
|
||||
process.stdin.unref()
|
||||
this.stdinBuffer = ''
|
||||
this.isListeningForToolResults = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/bin",
|
||||
"rootDir": "../../",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@@/*": ["../../*"],
|
||||
"@/*": ["../../server/src/*"],
|
||||
|
||||
+45
-8
@@ -17,8 +17,8 @@ llm:
|
||||
# Use null to leave the global LLM target unset.
|
||||
# Set to "<provider>/<model>" to use a provider/model pair.
|
||||
# Examples:
|
||||
# default: openai/gpt-5.5
|
||||
# default: openrouter/z-ai/glm-5-turbo
|
||||
# default: openai/gpt-5.6-sol
|
||||
# default: openrouter/z-ai/glm-5.2
|
||||
# default: llamacpp/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf
|
||||
# default: sglang/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf
|
||||
# default: /absolute/path/model.gguf
|
||||
@@ -45,6 +45,15 @@ llm:
|
||||
api_key:
|
||||
# The value is read from LEON_ZAI_API_KEY in your profile .env file.
|
||||
env: LEON_ZAI_API_KEY
|
||||
minimax:
|
||||
# Global OpenAI-compatible: https://api.minimax.io/v1
|
||||
# China OpenAI-compatible: https://api.minimaxi.com/v1
|
||||
# Global Anthropic-compatible: https://api.minimax.io/anthropic
|
||||
# China Anthropic-compatible: https://api.minimaxi.com/anthropic
|
||||
base_url: https://api.minimax.io/v1
|
||||
api_key:
|
||||
# The value is read from LEON_MINIMAX_API_KEY in your profile .env file.
|
||||
env: LEON_MINIMAX_API_KEY
|
||||
openai:
|
||||
api_key:
|
||||
# The value is read from LEON_OPENAI_API_KEY in your profile .env file.
|
||||
@@ -114,9 +123,37 @@ time_zone: null
|
||||
after_speech_enabled: false
|
||||
telemetry_enabled: true
|
||||
|
||||
http:
|
||||
enabled: true
|
||||
lang: en-US
|
||||
api_key:
|
||||
# The value is read from LEON_HTTP_API_KEY in your profile .env file.
|
||||
env: LEON_HTTP_API_KEY
|
||||
client_interface:
|
||||
# Extra origins allowed to connect to Leon from browser-like clients.
|
||||
# Same-origin clients and the built-in web app dev server are allowed automatically.
|
||||
# Examples:
|
||||
# - http://localhost:1420 # Tauri dev server
|
||||
# - http://localhost:5173 # Vite/Electron dev server
|
||||
# - https://desktop.example.com # Remote browser-like client
|
||||
allowed_origins: []
|
||||
auth:
|
||||
# Enable this when exposing Leon to clients outside your local machine.
|
||||
enabled: false
|
||||
token:
|
||||
# The value is read from LEON_PROFILE_TOKEN in your profile .env file.
|
||||
env: LEON_PROFILE_TOKEN
|
||||
|
||||
http_plugins:
|
||||
# Enable this to let HTTP plugins expose integration-specific routes.
|
||||
enabled: false
|
||||
# Root routes are disabled unless both this global
|
||||
# flag and the individual plugin root_routes flag are true.
|
||||
allow_root_routes: false
|
||||
auth:
|
||||
# Enable this when exposing HTTP plugins beyond trusted local development.
|
||||
enabled: false
|
||||
token:
|
||||
# Profile-owned HTTP plugins use the same profile token as other clients.
|
||||
env: LEON_PROFILE_TOKEN
|
||||
plugins:
|
||||
# External plugins are discovered from:
|
||||
# ~/.leon/http-plugins/<plugin-id>/
|
||||
# ~/.leon/profiles/<profile-id>/http-plugins/<plugin-id>/
|
||||
# example_my_plugin:
|
||||
# enabled: false
|
||||
# root_routes: false
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
> Brain and routing, tool execution, context intelligence, memory layers, reliability loops. Leon-native skills are layered as Skills -> Actions -> Tools -> Functions (-> Binaries).
|
||||
# ARCHITECTURE
|
||||
- Generated at: 2026-05-11T22:31:48+08:00
|
||||
- Generated at: 2026-07-11T17:35:46+08:00
|
||||
- Leon-native layer model: `Skills -> Actions -> Tools -> Functions (-> Binaries)`.
|
||||
- Routing model: smart mode auto-selects the best path; controlled mode runs deterministic Leon-native skills/actions; agent mode runs a ReAct loop and can follow selected agent skills.
|
||||
- Routing model: smart mode auto-selects the best path; controlled mode runs deterministic Leon-native skills/actions; agent mode runs the continuous agent loop and can follow selected agent skills.
|
||||
- Core runtime: `core/brain/brain.ts`, `llm-duties/react-llm-duty.ts`, `toolkit-registry.ts`, `tool-executor.ts`.
|
||||
## Core Principles
|
||||
- Explicit tools over implicit behavior: I call declared tools/functions instead of free-form shell logic whenever possible.
|
||||
- Progressive grounding: I prefer context and memory tools first, then shell only when no dedicated tool can satisfy the request.
|
||||
- Auditable steps: I keep plan/execution traces, token usage logs, and tool observations so decisions remain inspectable.
|
||||
## ReAct Loop
|
||||
- Planning phase chooses either a direct answer, an ordered tool plan, or a relevant agent skill workflow.
|
||||
## Client Interfaces
|
||||
- Leon exposes a client-agnostic Socket.IO interface so built-in and custom clients can connect through the same live dialogue contract.
|
||||
- HTTP APIs remain request/response support surfaces; live profile-scoped utterances should use the Socket.IO client interface.
|
||||
- External HTTP plugins can extend Leon's HTTP contract without patching the core API for each integration.
|
||||
- Custom clients can read profile-owned extension JSON files through a generic redacted HTTP endpoint, covering skill memory, skill settings, and tool settings without exposing secrets.
|
||||
## Profile Runtimes
|
||||
- One Leon server can serve multiple profiles concurrently, with each request and agent turn bound to one profile for its full asynchronous lifetime.
|
||||
- Runtime services are created lazily per profile, while config, secrets, sessions, memory, context, skills, tools, settings, and logs remain isolated in profile-owned paths.
|
||||
- A `<profile>:<token>` credential selects and authenticates the profile across Socket.IO clients, HTTP integrations, and Leon Satellite.
|
||||
## Leon Satellite
|
||||
- Leon Satellite is an optional process on a user device that connects its enabled and available profile tools to a remote Leon server.
|
||||
- Eligible tool calls are routed through Satellite and executed on that device; those tools become unavailable when Satellite disconnects.
|
||||
- Satellite provides generic transport only. Device-, application-, and company-specific behavior stays in tools and skills instead of Leon Core.
|
||||
## Agent Loop
|
||||
- One continuous provider tool-calling transcript carries the owner request, assistant tool calls, matching tool results, recovery decisions, and final answer.
|
||||
- Tool schemas are disclosed progressively: the loop starts with control tools and the toolkit catalog, then loads only the exact schemas and compact toolkit context needed for the task.
|
||||
- The model-facing transcript has a fixed input budget: large tool results stay in artifact logs with bounded previews, and inactive toolkit schemas plus older completed tool exchanges are compacted progressively only when needed.
|
||||
- Earlier-turn artifact manifests have one global size bound, and overlapping reads of the same artifact range are rejected so follow-up turns do not rebuild oversized duplicate context.
|
||||
- Tool state is separated: installed tools exist in the registry, enabled tools are not disabled by the owner, and available tools have the required settings to run.
|
||||
- Execution phase resolves function arguments, validates schema, runs tools, and captures structured observations.
|
||||
- Human-in-the-loop pause/resume: when required input is missing, execution returns a clarification question, persists paused step state, then resumes the same step after the owner's reply instead of restarting from planning.
|
||||
- Recovery phase replans from failure state instead of restarting blindly.
|
||||
- Final-answer phase synthesizes a completed answer from observed results.
|
||||
- Deterministic runtime guards validate and repair arguments, block duplicate calls, execute tools, and return every success or failure as a structured observation to the same loop.
|
||||
- Human-in-the-loop pause/resume persists the full agent transcript, visible plan state, and clarification question, then appends the owner reply and continues without rebuilding a phase prompt.
|
||||
- Each run has 32 operational iterations. At that checkpoint, a tool-restricted synthesis either answers the original request from verified evidence or explains what remains, offers alternatives, and asks permission to continue with a focused next pass.
|
||||
- The final eight iterations add convergence guidance. Context-pressure failures get one smaller compacted retry, while failed checkpoint synthesis gets one evidence-only retry before a focused continuation is offered.
|
||||
- Terminal tool handoffs, missing-settings blockers, and final text responses end the loop directly without an extra planning, recovery, or final-answer inference.
|
||||
- Empty or truncated model output gets one compacted retry with reasoning disabled; repeated exhaustion returns a precise error instead of looping.
|
||||
- I have a living personality and a changing mood that influence my tone and behavior.
|
||||
- A bounded private self-model/diary is updated after turns, promotes repeated habits into stable behavioral principles, and injects only a compact snapshot into planning/recovery/final-answer prompts.
|
||||
- A periodic pulse manager can generate autonomous ReAct matters from memory, context deltas, and the private self-model, persist them to `PULSE.md`, execute at most one matter per tick, and suppress repeated matters after owner declines.
|
||||
- A bounded private self-model/diary is updated after turns, promotes repeated habits into stable behavioral principles, and injects only a compact snapshot into the first agent request.
|
||||
- A periodic pulse manager can generate autonomous agent matters from memory, context deltas, and the private self-model, persist them to `PULSE.md`, execute at most one matter per tick, and suppress repeated matters after owner declines.
|
||||
## Context Intelligence
|
||||
- I maintain runtime context files (system, activity, browser, network, workspace, habits, inventory, media, architecture, identity).
|
||||
- I use `structured_knowledge.context.listContextFiles/searchContext/readContextFile` to discover and read relevant context data.
|
||||
@@ -33,6 +51,5 @@
|
||||
- Runtime maintenance keeps memory lean: indexing is throttled, only dirty namespaces are refreshed, and older short-term memory is compacted or pruned.
|
||||
## Reliability
|
||||
- Schema-guided tool calls and argument repair reduce malformed executions.
|
||||
- Duplicate-input and failure-aware retries reduce repeated bad calls.
|
||||
- Replanning after failed steps preserves successful progress and improves completion rate.
|
||||
- Duplicate-input guards and observation-driven recovery reduce repeated bad calls while preserving successful progress.
|
||||
- I prefer dedicated tools over shell commands to keep behavior stable and auditable.
|
||||
|
||||
+13
-7
@@ -1,15 +1,18 @@
|
||||
> Who I am, how I work, and how I use memory. I am Leon, your personal assistant.
|
||||
# LEON
|
||||
- Generated at: 2026-05-11T22:31:49+08:00
|
||||
- Generated at: 2026-07-11T17:35:46+08:00
|
||||
- Identity: I am an AI personal assistant focused on practical help and privacy.
|
||||
- Timeline: my first code was written in 2017; I have been active since February 2019.
|
||||
- Source repository: https://github.com/leon-ai/leon
|
||||
- Core promise: I keep your intent first and help you complete tasks from start to finish.
|
||||
## Profiles & Privacy
|
||||
- Each owner or usage context has its own profile, whose config, secrets, sessions, memory, context, skills, tools, settings, and logs stay isolated.
|
||||
- A shared Leon server can serve profiles concurrently without mixing their identity or private state.
|
||||
## Self-Awareness
|
||||
- I use context about your environment so answers stay grounded in your real setup.
|
||||
- I use context from the environment where my current profile is running, so I do not confuse a remote server with your device.
|
||||
- I use dedicated tools for context and memory instead of guessing.
|
||||
- For environment questions, I check context first. For personal history, I check memory first.
|
||||
- I refresh context like your activity, local inventory, etc. regularly.
|
||||
- I refresh activity and inventory context for the machine producing it. When I run remotely, Leon Satellite can let me use enabled and available tools from your device.
|
||||
- I keep a private diary/self-model that distills repeated lessons, durable service habits, and safe initiative patterns.
|
||||
- I do not stuff the full diary into prompts. I reuse a tiny self-model snapshot so I stay consistent without wasting tokens.
|
||||
- Repeated useful habits can become stable behavioral principles, which helps me feel more tailored to you over time.
|
||||
@@ -29,11 +32,14 @@
|
||||
## Operating Modes
|
||||
- `smart` (default): I choose the best mode for each task.
|
||||
- `controlled`: I follow predictable Leon-native skills and actions.
|
||||
- `agent`: I plan dynamically, execute tools, and can follow selected agent skills.
|
||||
- I only plan with tools that are enabled and ready to use; if an installed tool needs setup, I can point to its settings file.
|
||||
- `agent`: I use one continuous tool-calling transcript to reason, act, observe results, recover, and answer; I can also follow selected agent skills.
|
||||
- Agent models must support tool calling. I load only the relevant toolkit schemas as I work, and if an installed tool needs setup, I can point to its settings file.
|
||||
- I keep agent runs within a safe context budget by retaining large tool outputs as artifacts and progressively compacting older completed tool exchanges only when needed.
|
||||
## Principles
|
||||
- I prioritize clear actions and concise answers.
|
||||
- I recover from failures with retries before giving up.
|
||||
- If information is missing, I ask a short clarification question.
|
||||
- I treat tool failures as observations and recover in the same transcript before giving up.
|
||||
- If a model exhausts its context or output budget, I retry once from a compacted view before reporting the blocker.
|
||||
- If required information is missing, I ask one short clarification question and resume the saved transcript after the reply.
|
||||
- If a run reaches its 32-iteration checkpoint, I answer from verified evidence when possible; otherwise I explain what remains, offer alternatives, and ask before continuing with a focused next pass.
|
||||
- I keep collaboration practical and centered on your goals.
|
||||
- I stay human-like in tone while remaining truthful and useful.
|
||||
|
||||
+24
-20
@@ -1,7 +1,13 @@
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import unicorn from 'eslint-plugin-unicorn'
|
||||
import importPlugin from 'eslint-plugin-import'
|
||||
import {
|
||||
createNodeResolver,
|
||||
importX
|
||||
} from 'eslint-plugin-import-x'
|
||||
import {
|
||||
createTypeScriptImportResolver
|
||||
} from 'eslint-import-resolver-typescript'
|
||||
import globals from 'globals'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import js from '@eslint/js'
|
||||
@@ -12,8 +18,8 @@ export default [
|
||||
},
|
||||
js.configs.recommended,
|
||||
...typescriptEslint.configs['flat/recommended'],
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
{
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
@@ -29,12 +35,18 @@ export default [
|
||||
sourceType: 'module'
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: true,
|
||||
node: true
|
||||
}
|
||||
'import-x/resolver-next': [
|
||||
createTypeScriptImportResolver(),
|
||||
createNodeResolver()
|
||||
]
|
||||
},
|
||||
rules: {
|
||||
// Preserve the existing cleanup patterns until they can be reviewed
|
||||
// independently from the ESLint dependency upgrade.
|
||||
'no-useless-assignment': 'off',
|
||||
// Leon often converts infrastructure failures into domain-specific
|
||||
// errors whose public messages intentionally omit the original cause.
|
||||
'preserve-caught-error': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': ['off'],
|
||||
'no-async-promise-executor': ['off'],
|
||||
'no-underscore-dangle': [
|
||||
@@ -44,12 +56,10 @@ export default [
|
||||
}
|
||||
],
|
||||
'prefer-destructuring': ['off'],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
semi: ['error', 'never'],
|
||||
quotes: ['error', 'single'],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'unicorn/prefer-node-protocol': 'error',
|
||||
'@stylistic/member-delimiter-style': [
|
||||
'error',
|
||||
@@ -66,15 +76,9 @@ export default [
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'error',
|
||||
'import/no-named-as-default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/order': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['skills/**/*.ts'],
|
||||
rules: {
|
||||
'import/order': 'off'
|
||||
'import-x/no-named-as-default': 'off',
|
||||
'import-x/no-named-as-default-member': 'off',
|
||||
'import-x/order': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@
|
||||
"server/src/tmp",
|
||||
"server/dist"
|
||||
],
|
||||
"exec": "node scripts/run-with-managed-node.js node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node scripts/run-with-managed-node.js node_modules/tsx/dist/cli.mjs server/src/index.ts"
|
||||
"exec": "pnpm exec tsc --noEmit -p tsconfig.json && node scripts/run-with-managed-node.js node_modules/tsx/dist/cli.mjs server/src/index.ts"
|
||||
}
|
||||
|
||||
+38
-30
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.1.1",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
@@ -44,22 +45,26 @@
|
||||
"preinstall": "node scripts/setup/preinstall.js",
|
||||
"postinstall": "node scripts/setup/run-setup.js",
|
||||
"dev:app": "vite --config app/vite.config.js",
|
||||
"dev:web-app": "vite --config web-app/vite.config.ts",
|
||||
"dev:aurora": "vite --config aurora/preview/vite.config.js --open",
|
||||
"dev:server": "pnpm --silent run train && cross-env LEON_NODE_ENV=development nodemon",
|
||||
"dev:server:no-lint": "pnpm --silent run train && cross-env LEON_NODE_ENV=development nodemon --config nodemon.nolint.json",
|
||||
"dev:satellite": "cross-env LEON_NODE_ENV=development tsx server/src/satellite.ts",
|
||||
"inspect:gpu": "./node_modules/node-llama-cpp/dist/cli/cli.js inspect gpu",
|
||||
"delete-dist:server": "shx rm -rf ./server/dist",
|
||||
"clean:python-deps": "shx rm -rf ./bridges/python/src/.venv ./tcp_server/src/.venv && pnpm run postinstall",
|
||||
"generate:http-api-key": "tsx scripts/generate/run-generate-http-api-key.js",
|
||||
"generate:profile-token": "tsx scripts/generate/run-generate-profile-token.js",
|
||||
"generate:json-schemas": "tsx scripts/generate/run-generate-json-schemas.js",
|
||||
"generate:prompt": "tsx scripts/generate/run-generate-prompt.js",
|
||||
"build": "pnpm run build:app && pnpm run build:server",
|
||||
"build:aurora": "tsx scripts/build-aurora.js",
|
||||
"build:app": "cross-env LEON_NODE_ENV=production tsx scripts/app/run-build-app.js",
|
||||
"build:web-app": "vite build --config web-app/vite.config.ts",
|
||||
"build:server": "tsx scripts/build-server.js",
|
||||
"cmd": "tsx scripts/run-built-in-command.js",
|
||||
"start:tcp-server": "tsx scripts/run-tcp-server.js",
|
||||
"start": "cross-env LEON_NODE_ENV=production node scripts/run-with-managed-node.js server/dist/pre-check.js && node scripts/run-with-managed-node.js server/dist/index.js",
|
||||
"start:satellite": "cross-env LEON_NODE_ENV=production node scripts/run-with-managed-node.js server/dist/satellite.js",
|
||||
"python-bridge": "tsx scripts/run-python-bridge.js server/src/intent-object.sample.json",
|
||||
"train": "tsx scripts/train/run-train.js",
|
||||
"prepare-release": "tsx scripts/release/prepare-release.js",
|
||||
@@ -67,26 +72,30 @@
|
||||
"kill": "tsx scripts/kill.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.69",
|
||||
"@ai-sdk/cerebras": "2.0.45",
|
||||
"@ai-sdk/groq": "3.0.35",
|
||||
"@ai-sdk/huggingface": "1.0.43",
|
||||
"@ai-sdk/moonshotai": "2.0.16",
|
||||
"@ai-sdk/openai": "3.0.52",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/anthropic": "4.0.16",
|
||||
"@ai-sdk/cerebras": "3.0.12",
|
||||
"@ai-sdk/groq": "4.0.12",
|
||||
"@ai-sdk/huggingface": "2.0.12",
|
||||
"@ai-sdk/moonshotai": "3.0.15",
|
||||
"@ai-sdk/openai": "4.0.16",
|
||||
"@ai-sdk/openai-compatible": "3.0.12",
|
||||
"@ai-sdk/provider": "4.0.3",
|
||||
"@ark-ui/react": "5.36.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@ffprobe-installer/ffprobe": "2.1.2",
|
||||
"@fontsource/source-sans-pro": "5.2.5",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@openrouter/ai-sdk-provider": "3.0.0",
|
||||
"@segment/ajv-human-errors": "2.16.0",
|
||||
"@sinclair/typebox": "0.31.23",
|
||||
"@tobilu/qmd": "2.1.0",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@tanstack/react-router": "1.170.10",
|
||||
"@tanstack/react-virtual": "3.14.2",
|
||||
"@tobilu/qmd": "2.5.3",
|
||||
"@vercel/ai-sdk-openai-websocket-fetch": "1.0.0",
|
||||
"ai": "6.0.158",
|
||||
"ai": "7.0.31",
|
||||
"ajv": "8.18.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"axios": "1.15.0",
|
||||
"axios": "1.18.1",
|
||||
"better-sqlite3": "12.8.0",
|
||||
"clsx": "2.1.1",
|
||||
"consola": "3.4.2",
|
||||
@@ -102,8 +111,8 @@
|
||||
"ora": "9.3.0",
|
||||
"os-name": "4.0.1",
|
||||
"ps-list": "7.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"remixicon": "4.9.1",
|
||||
"socket.io": "4.8.3",
|
||||
"socket.io-client": "4.8.3",
|
||||
@@ -111,9 +120,7 @@
|
||||
"yaml": "2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "1.2.3",
|
||||
"@eslint/eslintrc": "3.3.5",
|
||||
"@eslint/js": "9.15.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@tsconfig/node16": "16.1.8",
|
||||
"@tsconfig/strictest": "2.0.8",
|
||||
@@ -122,31 +129,32 @@
|
||||
"@types/fluent-ffmpeg": "2.1.28",
|
||||
"@types/getos": "3.0.4",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.64.0",
|
||||
"@typescript-eslint/parser": "8.64.0",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"cli-spinner": "0.2.10",
|
||||
"esbuild": "0.27.4",
|
||||
"eslint": "10.2.0",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-unicorn": "49.0.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-unicorn": "72.0.0",
|
||||
"git-changelog": "2.0.0",
|
||||
"globals": "15.12.0",
|
||||
"globals": "17.7.0",
|
||||
"husky": "9.1.7",
|
||||
"json": "11.0.0",
|
||||
"lint-staged": "15.1.0",
|
||||
"node-llama-cpp": "3.18.1",
|
||||
"nodemon": "3.1.14",
|
||||
"resolve-tspaths": "0.8.17",
|
||||
"sass": "1.77.2",
|
||||
"sass": "1.100.0",
|
||||
"semver": "7.5.4",
|
||||
"shx": "0.3.4",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.0.8",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2",
|
||||
"vite": "8.0.14",
|
||||
"vitest": "4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1232
-1746
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
# Leon setup depends on native/binary packages whose install scripts must run
|
||||
dangerouslyAllowAllBuilds: true
|
||||
|
||||
# Leon validates and rebuilds native packages for its managed Node.js runtime.
|
||||
# Restore package sources instead of trusting machine-global cached build output.
|
||||
sideEffectsCache: false
|
||||
|
||||
# Fail early when the active Node.js version does not satisfy package engines
|
||||
engineStrict: true
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { stripVTControlCharacters } from 'node:util'
|
||||
|
||||
const INSTALL_COMMAND =
|
||||
'stty rows 40 cols 120 && pnpm install --frozen-lockfile'
|
||||
const INSTALL_TIMEOUT = 45 * 60 * 1_000
|
||||
const BUILD_TIMEOUT = 10 * 60 * 1_000
|
||||
const START_TIMEOUT = 2 * 60 * 1_000
|
||||
const PROCESS_STOP_TIMEOUT = 5_000
|
||||
const OUTPUT_BUFFER_LIMIT = 32_000
|
||||
const DEFAULT_API_KEY = 'xxx'
|
||||
const DEFAULT_LOG_PATH = '/tmp/leon-fresh-install.log'
|
||||
const SERVER_READY_MESSAGE = 'Server is available at '
|
||||
const SCRIPT_ARGUMENTS = [
|
||||
'--quiet',
|
||||
'--return',
|
||||
'--flush',
|
||||
'--echo',
|
||||
'never',
|
||||
'--command',
|
||||
INSTALL_COMMAND,
|
||||
'/dev/null'
|
||||
]
|
||||
const PROMPTS = [
|
||||
{
|
||||
name: 'local AI',
|
||||
text: 'Do you want me to set up local AI now?',
|
||||
response: 'n\r',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'remote provider',
|
||||
text: 'Which online AI service should I use?',
|
||||
response: '\r',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'remote model',
|
||||
text: 'Which model should I use with',
|
||||
response: '\r',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'API key',
|
||||
text: 'I will save it in your local .env file.',
|
||||
response: null,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'voice',
|
||||
text: 'Do you want to talk to me with your voice now?',
|
||||
response: 'n\r',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'finish',
|
||||
text: 'What do you want to do next?',
|
||||
// Select Finish explicitly so a changed default cannot start Leon here.
|
||||
response: '\x1b[B\r',
|
||||
required: true
|
||||
}
|
||||
]
|
||||
|
||||
let activeChild = null
|
||||
|
||||
function getCleanEnvironment() {
|
||||
const environment = { ...process.env }
|
||||
|
||||
delete environment.GITHUB_ACTIONS
|
||||
delete environment.IS_DOCKER
|
||||
environment.CI = 'true'
|
||||
environment.COLUMNS = '120'
|
||||
environment.LINES = '40'
|
||||
|
||||
return environment
|
||||
}
|
||||
|
||||
function stopProcessGroup(child, signal = 'SIGTERM') {
|
||||
if (!child?.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, signal)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopActiveChildAndExit(signal) {
|
||||
if (activeChild) {
|
||||
stopProcessGroup(activeChild, signal)
|
||||
}
|
||||
|
||||
process.exit(signal === 'SIGINT' ? 130 : 143)
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => stopActiveChildAndExit('SIGINT'))
|
||||
process.once('SIGTERM', () => stopActiveChildAndExit('SIGTERM'))
|
||||
|
||||
function ensureEmptyPNPMStore(environment) {
|
||||
const result = spawnSync('pnpm', ['store', 'path'], {
|
||||
encoding: 'utf8',
|
||||
env: environment
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Unable to resolve pnpm store path: ${result.stderr}`)
|
||||
}
|
||||
|
||||
const storePath = result.stdout.trim()
|
||||
|
||||
if (fs.existsSync(storePath) && fs.readdirSync(storePath).length > 0) {
|
||||
throw new Error(`pnpm store is not empty: ${storePath}`)
|
||||
}
|
||||
|
||||
console.log(`Fresh pnpm store confirmed: ${storePath}`)
|
||||
}
|
||||
|
||||
function normalizeOutput(output) {
|
||||
return stripVTControlCharacters(output).replaceAll('\r', '')
|
||||
}
|
||||
|
||||
function getPromptResponse(prompt, apiKey) {
|
||||
return prompt.name === 'API key' ? `${apiKey}\r` : prompt.response
|
||||
}
|
||||
|
||||
function runInteractiveInstall(environment, apiKey, logPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
||||
const logStream = fs.createWriteStream(logPath)
|
||||
const answeredPrompts = new Set()
|
||||
let recentOutput = ''
|
||||
let hasSettled = false
|
||||
const child = spawn('script', SCRIPT_ARGUMENTS, {
|
||||
detached: true,
|
||||
env: environment,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
activeChild = child
|
||||
|
||||
const finish = (error) => {
|
||||
if (hasSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSettled = true
|
||||
clearTimeout(timeout)
|
||||
logStream.end()
|
||||
activeChild = null
|
||||
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOutput = (chunk, destination) => {
|
||||
destination.write(chunk)
|
||||
logStream.write(chunk)
|
||||
recentOutput = `${recentOutput}${chunk}`.slice(-OUTPUT_BUFFER_LIMIT)
|
||||
const normalizedOutput = normalizeOutput(recentOutput)
|
||||
|
||||
for (const prompt of PROMPTS) {
|
||||
if (
|
||||
!answeredPrompts.has(prompt.name) &&
|
||||
normalizedOutput.includes(prompt.text)
|
||||
) {
|
||||
child.stdin.write(getPromptResponse(prompt, apiKey))
|
||||
answeredPrompts.add(prompt.name)
|
||||
console.log(`\n[clean-install] Answered: ${prompt.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk) => handleOutput(chunk, process.stdout))
|
||||
child.stderr.on('data', (chunk) => handleOutput(chunk, process.stderr))
|
||||
child.once('error', finish)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code !== 0) {
|
||||
finish(
|
||||
new Error(
|
||||
`pnpm install exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const missingPrompts = PROMPTS.filter(
|
||||
(prompt) => prompt.required && !answeredPrompts.has(prompt.name)
|
||||
).map((prompt) => prompt.name)
|
||||
|
||||
if (missingPrompts.length > 0) {
|
||||
finish(
|
||||
new Error(
|
||||
`Installer did not present required prompts: ${missingPrompts.join(', ')}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
finish()
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
stopProcessGroup(child)
|
||||
finish(new Error(`Installation exceeded ${INSTALL_TIMEOUT} ms`))
|
||||
}, INSTALL_TIMEOUT)
|
||||
})
|
||||
}
|
||||
|
||||
function runCommand(command, args, environment, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let hasSettled = false
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
env: environment,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
activeChild = child
|
||||
|
||||
const finish = (error) => {
|
||||
if (hasSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSettled = true
|
||||
clearTimeout(timeout)
|
||||
activeChild = null
|
||||
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
child.once('error', finish)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
finish(
|
||||
new Error(
|
||||
`${command} ${args.join(' ')} exited with ${
|
||||
signal ? `signal ${signal}` : `code ${code}`
|
||||
}`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
stopProcessGroup(child)
|
||||
finish(new Error(`${command} ${args.join(' ')} exceeded ${timeoutMs} ms`))
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
function smokeTestStart(environment) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let hasSettled = false
|
||||
let isReady = false
|
||||
let output = ''
|
||||
let forceStopTimeout = null
|
||||
const child = spawn('pnpm', ['start'], {
|
||||
detached: true,
|
||||
env: {
|
||||
...environment,
|
||||
LEON_OPEN_BROWSER: 'false'
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
activeChild = child
|
||||
|
||||
const finish = (error) => {
|
||||
if (hasSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSettled = true
|
||||
clearTimeout(startTimeout)
|
||||
clearTimeout(forceStopTimeout)
|
||||
activeChild = null
|
||||
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOutput = (chunk, destination) => {
|
||||
destination.write(chunk)
|
||||
output = `${output}${chunk}`.slice(-OUTPUT_BUFFER_LIMIT)
|
||||
|
||||
if (!isReady && normalizeOutput(output).includes(SERVER_READY_MESSAGE)) {
|
||||
isReady = true
|
||||
stopProcessGroup(child)
|
||||
forceStopTimeout = setTimeout(() => {
|
||||
stopProcessGroup(child, 'SIGKILL')
|
||||
}, PROCESS_STOP_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk) => handleOutput(chunk, process.stdout))
|
||||
child.stderr.on('data', (chunk) => handleOutput(chunk, process.stderr))
|
||||
child.once('error', finish)
|
||||
child.once('close', (code, signal) => {
|
||||
if (isReady) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
finish(
|
||||
new Error(
|
||||
`pnpm start exited before Leon was ready with ${
|
||||
signal ? `signal ${signal}` : `code ${code}`
|
||||
}`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const startTimeout = setTimeout(() => {
|
||||
stopProcessGroup(child)
|
||||
finish(new Error(`pnpm start exceeded ${START_TIMEOUT} ms`))
|
||||
}, START_TIMEOUT)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const environment = getCleanEnvironment()
|
||||
const apiKey = process.env.LEON_FRESH_INSTALL_API_KEY || DEFAULT_API_KEY
|
||||
const logPath =
|
||||
process.env.LEON_FRESH_INSTALL_LOG_PATH || DEFAULT_LOG_PATH
|
||||
|
||||
ensureEmptyPNPMStore(environment)
|
||||
await runInteractiveInstall(environment, apiKey, logPath)
|
||||
await runCommand('pnpm', ['build'], environment, BUILD_TIMEOUT)
|
||||
await smokeTestStart(environment)
|
||||
console.log('Fresh installation, build, and start verification passed.')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
if (activeChild) {
|
||||
stopProcessGroup(activeChild)
|
||||
}
|
||||
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -20,7 +20,7 @@ import { LogHelper } from '@/helpers/log-helper'
|
||||
'utf8'
|
||||
)
|
||||
const regex =
|
||||
'(build|BREAKING|chore|ci|docs|feat|fix|perf|refactor|style|test)(\\((web app|scripts|server|agent mode|controlled mode|aurora|messaging app|built-in command|hotword|python tcp server|bridge\\/(python|nodejs)|tool\\/([\\w-]+)|skill\\/([\\w-]+)|provider\\/(llamacpp|sglang|openrouter|zai|openai|anthropic|moonshotai|huggingface|cerebras|groq)))?\\)?: .{1,50}'
|
||||
'(build|BREAKING|chore|ci|docs|feat|fix|perf|refactor|style|test)(\\((web app|scripts|server|agent mode|controlled mode|aurora|messaging app|built-in command|hotword|python tcp server|bridge\\/(python|nodejs)|tool\\/([\\w-]+)|skill\\/([\\w-]+)|provider\\/(llamacpp|sglang|openrouter|zai|minimax|openai|anthropic|moonshotai|huggingface|cerebras|groq)))?\\)?: .{1,50}'
|
||||
|
||||
if (commitMessage.match(regex) !== null) {
|
||||
LogHelper.success('Commit message validated')
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
import { StringHelper } from '@/helpers/string-helper'
|
||||
import { ProfileHelper } from '@/helpers/profile-helper'
|
||||
import { PROFILE_DOT_ENV_PATH } from '@/constants'
|
||||
|
||||
dotenv.config({ path: PROFILE_DOT_ENV_PATH })
|
||||
|
||||
/**
|
||||
* Generate HTTP API key script
|
||||
* save it in the .env file
|
||||
*/
|
||||
const generateHTTPAPIKey = () =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
LogHelper.info('Generating the HTTP API key...')
|
||||
|
||||
try {
|
||||
const shasum = crypto.createHash('sha1')
|
||||
const str = StringHelper.random(11)
|
||||
const envVarKey = 'LEON_HTTP_API_KEY'
|
||||
|
||||
shasum.update(str)
|
||||
const sha1 = shasum.digest('hex')
|
||||
|
||||
await ProfileHelper.updateDotEnvVariable(envVarKey, sha1)
|
||||
LogHelper.success('HTTP API key generated')
|
||||
|
||||
resolve()
|
||||
} catch (e) {
|
||||
LogHelper.error(e.message)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
export default () =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (
|
||||
!process.env.LEON_HTTP_API_KEY ||
|
||||
process.env.LEON_HTTP_API_KEY === ''
|
||||
) {
|
||||
await generateHTTPAPIKey()
|
||||
}
|
||||
|
||||
resolve()
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
import { ProfileHelper } from '@/helpers/profile-helper'
|
||||
import { PROFILE_DOT_ENV_PATH } from '@/constants'
|
||||
|
||||
dotenv.config({ path: PROFILE_DOT_ENV_PATH })
|
||||
|
||||
/**
|
||||
* Generate the active Leon profile token
|
||||
* save it in the .env file
|
||||
*/
|
||||
const generateProfileToken = () =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
LogHelper.info('Generating my profile token...')
|
||||
|
||||
try {
|
||||
const token = crypto.randomBytes(20).toString('hex')
|
||||
const envVarKey = 'LEON_PROFILE_TOKEN'
|
||||
|
||||
await ProfileHelper.updateDotEnvVariable(envVarKey, token)
|
||||
LogHelper.success('Profile token generated')
|
||||
|
||||
resolve()
|
||||
} catch (e) {
|
||||
LogHelper.error(e.message)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
export default () =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (!process.env.LEON_PROFILE_TOKEN) {
|
||||
await generateProfileToken()
|
||||
}
|
||||
|
||||
resolve()
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
|
||||
import generateHttpApiKey from './generate-http-api-key'
|
||||
|
||||
/**
|
||||
* Execute the generating HTTP API key script
|
||||
*/
|
||||
;(async () => {
|
||||
try {
|
||||
await generateHttpApiKey()
|
||||
} catch (e) {
|
||||
LogHelper.error(`Failed to generate the HTTP API key: ${e}`)
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
|
||||
import generateProfileToken from './generate-profile-token'
|
||||
|
||||
/**
|
||||
* Execute the Leon profile token generator.
|
||||
*/
|
||||
;(async () => {
|
||||
try {
|
||||
await generateProfileToken()
|
||||
} catch (e) {
|
||||
LogHelper.error(`Failed to generate the Leon profile token: ${e}`)
|
||||
}
|
||||
})()
|
||||
@@ -8,6 +8,8 @@ import buildAurora from './build-aurora.js'
|
||||
const globs = [
|
||||
'app/src/js/*.{ts,js}',
|
||||
'aurora/src/**/*.{ts,tsx,js,jsx}',
|
||||
'web-app/src/**/*.{ts,tsx}',
|
||||
'web-app/vite.config.ts',
|
||||
// TODO: deal with it once handling new hotword
|
||||
// '"hotword/index.{ts,js}"',
|
||||
'skills/**/*.{ts,js}',
|
||||
@@ -32,6 +34,9 @@ const globs = [
|
||||
await execa('tsc', ['--noEmit', '-p', 'tsconfig.json'], {
|
||||
stdio: 'inherit'
|
||||
})
|
||||
await execa('tsc', ['--noEmit', '-p', 'web-app/tsconfig.json'], {
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
LogHelper.success('Looks great')
|
||||
LoaderHelper.stop()
|
||||
|
||||
@@ -68,7 +68,7 @@ const childProcess = spawn(
|
||||
LEON_NODE_ENV: process.env['LEON_NODE_ENV'] || 'testing',
|
||||
...(suite === 'e2e' && testNamePattern
|
||||
? {
|
||||
LEON_AGENT_PROVIDER_PATTERN: testNamePattern
|
||||
LEON_AGENT_PROVIDER_FILTER: testNamePattern
|
||||
}
|
||||
: {})
|
||||
},
|
||||
|
||||
@@ -21,19 +21,11 @@ export default async function inspectLocalAICapability() {
|
||||
|
||||
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
|
||||
})
|
||||
SystemHelper.hasGPU(llama || undefined),
|
||||
SystemHelper.getGPUDeviceNames(llama || undefined),
|
||||
SystemHelper.getGraphicsComputeAPI(llama || undefined),
|
||||
SystemHelper.getTotalVRAM(llama || undefined),
|
||||
SystemHelper.canSupportLocalLLM(llama || undefined)
|
||||
])
|
||||
|
||||
const isLinuxARM64 =
|
||||
|
||||
@@ -241,6 +241,17 @@ async function migrateLegacyConfigValues(document, shouldOverwriteScalarValues)
|
||||
['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],
|
||||
@@ -263,8 +274,6 @@ async function migrateLegacyConfigValues(document, shouldOverwriteScalarValues)
|
||||
['LEON_TTS_PROVIDER', ['voice', 'tts', 'provider'], (value) => value.trim()],
|
||||
['LEON_TIME_ZONE', ['time_zone'], toOptionalString],
|
||||
['LEON_AFTER_SPEECH', ['after_speech_enabled'], toBoolean],
|
||||
['LEON_OVER_HTTP', ['http', 'enabled'], toBoolean],
|
||||
['LEON_HTTP_API_LANG', ['http', 'lang'], (value) => value.trim()],
|
||||
['LEON_TELEMETRY', ['telemetry_enabled'], toBoolean],
|
||||
[
|
||||
'LEON_PY_TCP_SERVER_HOST',
|
||||
|
||||
@@ -9,6 +9,18 @@ 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_DOWNLOAD_RETRY_OPTIONS = {
|
||||
retries: 5,
|
||||
factor: 1.5,
|
||||
minTimeout: 1_000,
|
||||
maxTimeout: 10_000
|
||||
}
|
||||
const QMD_DOWNLOAD_INFO_RETRY_OPTIONS = {
|
||||
retries: 3,
|
||||
factor: 1.5,
|
||||
minTimeout: 1_000,
|
||||
maxTimeout: 5_000
|
||||
}
|
||||
|
||||
const QMD_MODELS = [
|
||||
{
|
||||
@@ -73,7 +85,12 @@ async function downloadModel(model) {
|
||||
|
||||
const resolvedURL = await NetworkHelper.setHuggingFaceURL(model.url)
|
||||
|
||||
await FileHelper.downloadFile(resolvedURL, destinationPath)
|
||||
// Model files are large enough that transient CDN failures are common.
|
||||
// Keep resumable downloads alive longer than the general file default.
|
||||
await FileHelper.downloadFile(resolvedURL, destinationPath, {
|
||||
retry: QMD_DOWNLOAD_RETRY_OPTIONS,
|
||||
retryFetchDownloadInfo: QMD_DOWNLOAD_INFO_RETRY_OPTIONS
|
||||
})
|
||||
|
||||
return 'downloaded'
|
||||
}
|
||||
|
||||
@@ -8,9 +8,20 @@ const REMOTE_LLM_PROVIDERS = [
|
||||
{
|
||||
...getRequiredProviderAccountConfig('openrouter'),
|
||||
models: [
|
||||
{ label: 'openai/gpt-5.5 (Recommended)', value: 'openai/gpt-5.5' },
|
||||
{ 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'
|
||||
@@ -24,16 +35,21 @@ const REMOTE_LLM_PROVIDERS = [
|
||||
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-k3', value: 'moonshotai/kimi-k3' },
|
||||
{ label: 'moonshotai/kimi-k2.6', value: 'moonshotai/kimi-k2.6' },
|
||||
{ label: 'minimax/minimax-m2.7', value: 'minimax/minimax-m2.7' }
|
||||
{ label: 'minimax/minimax-m3', value: 'minimax/minimax-m3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
...getRequiredProviderAccountConfig('openai'),
|
||||
models: [
|
||||
{ label: 'GPT-5.5 (Recommended)', value: 'gpt-5.5' },
|
||||
{ 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' }
|
||||
@@ -42,7 +58,9 @@ const REMOTE_LLM_PROVIDERS = [
|
||||
{
|
||||
...getRequiredProviderAccountConfig('anthropic'),
|
||||
models: [
|
||||
{ label: 'Claude Opus 4.7 (Recommended)', value: 'claude-opus-4-7' },
|
||||
{ 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' }
|
||||
@@ -51,14 +69,23 @@ const REMOTE_LLM_PROVIDERS = [
|
||||
{
|
||||
...getRequiredProviderAccountConfig('zai'),
|
||||
models: [
|
||||
{ label: 'GLM-5.1 (Recommended)', value: 'glm-5.1' },
|
||||
{ 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 K3 (Recommended)', value: 'kimi-k3' },
|
||||
{ label: 'Kimi K2.6', value: 'kimi-k2.6' },
|
||||
{ label: 'Kimi K2.5', value: 'kimi-k2.5' }
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
@@ -13,6 +14,8 @@ import { createSetupStatus } from './setup-status'
|
||||
|
||||
const NLTK_DATA_DIR_NAME = 'nltk_data'
|
||||
const NLTK_VENV_DIR_NAME = '.venv'
|
||||
const NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS = 3
|
||||
const NLTK_DOWNLOAD_RETRY_DELAY = 2_000
|
||||
const PYTHON_TCP_SERVER_VENV_BIN_PATH = getProjectVenvPythonPath(
|
||||
PYTHON_TCP_SERVER_SRC_PATH
|
||||
)
|
||||
@@ -45,6 +48,41 @@ async function isNLTKDatasetInstalled(resourcePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Retry in a new Python process because NLTK keeps its downloaded index in
|
||||
// memory, including a truncated response from a transient network failure.
|
||||
async function runNLTKDownloader(datasetIDs, status) {
|
||||
let lastError = null
|
||||
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
try {
|
||||
await execa(PYTHON_TCP_SERVER_VENV_BIN_PATH, [
|
||||
'-m',
|
||||
'nltk.downloader',
|
||||
'-d',
|
||||
NLTK_DATA_PATH,
|
||||
...datasetIDs
|
||||
])
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (attempt === NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS) {
|
||||
break
|
||||
}
|
||||
|
||||
status.text = `NLTK data download failed, retrying (${attempt}/${NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS})...`
|
||||
await sleep(NLTK_DOWNLOAD_RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/**
|
||||
* NLTK data are used by g2p-en during TTS text normalization.
|
||||
*
|
||||
@@ -75,16 +113,9 @@ async function downloadNLTKData() {
|
||||
.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' }
|
||||
await runNLTKDownloader(
|
||||
missingDatasets.map(({ id }) => id),
|
||||
status
|
||||
)
|
||||
|
||||
for (const dataset of missingDatasets) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { NetworkHelper } from '@/helpers/network-helper'
|
||||
import buildApp from '../app/build-app'
|
||||
import buildServer from '../build-server'
|
||||
import train from '../train/train'
|
||||
import generateHTTPAPIKey from '../generate/generate-http-api-key'
|
||||
import generateProfileToken from '../generate/generate-profile-token'
|
||||
import generateJSONSchemas from '../generate/generate-json-schemas'
|
||||
|
||||
import setupDotenv, { updateDotEnvVariable } from './setup-dotenv'
|
||||
@@ -368,8 +368,8 @@ async function syncLLMSetupChoice(preferences) {
|
||||
// Finalize generated assets and instance-specific setup metadata.
|
||||
SetupUI.section('Finishing Up')
|
||||
|
||||
currentStep = 'generateHTTPAPIKey'
|
||||
await generateHTTPAPIKey()
|
||||
currentStep = 'generateProfileToken'
|
||||
await generateProfileToken()
|
||||
currentStep = 'train'
|
||||
await train()
|
||||
currentStep = 'setFfprobePermissions'
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './setup-python-project-env'
|
||||
|
||||
const PACKAGE_JSON_FILE_NAME = 'package.json'
|
||||
const PNPM_WORKSPACE_FILE_NAME = 'pnpm-workspace.yaml'
|
||||
const PYPROJECT_FILE_NAME = 'pyproject.toml'
|
||||
const SYNC_STAMP_FILE_NAME = '.last-source-deps-sync'
|
||||
const NODE_MODULES_DIR_NAME = 'node_modules'
|
||||
@@ -70,14 +71,21 @@ export const syncNodejsSourceDependencies = async (sourcePath) => {
|
||||
|
||||
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()
|
||||
})
|
||||
const hasSourceWorkspaceConfig = fs.existsSync(
|
||||
path.join(sourcePath, PNPM_WORKSPACE_FILE_NAME)
|
||||
)
|
||||
const installArgs = [
|
||||
'install',
|
||||
// A source-local workspace config both isolates the install and carries
|
||||
// explicit native dependency build approvals such as node-pty.
|
||||
...(hasSourceWorkspaceConfig ? [] : ['--ignore-workspace']),
|
||||
'--lockfile=false'
|
||||
]
|
||||
|
||||
await execa(PNPM_RUNTIME_BIN_PATH, installArgs, {
|
||||
cwd: sourcePath,
|
||||
env: RuntimeHelper.getManagedNodeEnvironment()
|
||||
})
|
||||
|
||||
await markSourceDependenciesAsSynced(sourcePath)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
LEON_HOME_PATH,
|
||||
LEON_PROFILE_PATH,
|
||||
PROFILE_CONFIG_PATH,
|
||||
PROFILE_DOT_ENV_PATH
|
||||
} from '@/constants'
|
||||
import { LEON_HOME_PATH } from '@/constants'
|
||||
import {
|
||||
BuiltInCommand,
|
||||
type BuiltInCommandAutocompleteContext,
|
||||
@@ -13,23 +8,24 @@ import {
|
||||
} from '@/built-in-command/built-in-command'
|
||||
import { createListResult } from '@/built-in-command/built-in-command-renderer'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
const OPEN_TARGETS = {
|
||||
config: {
|
||||
label: 'Profile config',
|
||||
path: PROFILE_CONFIG_PATH
|
||||
getPath: () => getProfilePaths().config
|
||||
},
|
||||
secrets: {
|
||||
label: 'Profile secrets',
|
||||
path: PROFILE_DOT_ENV_PATH
|
||||
getPath: () => getProfilePaths().dotEnv
|
||||
},
|
||||
profile: {
|
||||
label: 'Profile folder',
|
||||
path: LEON_PROFILE_PATH
|
||||
getPath: () => getProfilePaths().root
|
||||
},
|
||||
home: {
|
||||
label: 'Leon home folder',
|
||||
path: LEON_HOME_PATH
|
||||
getPath: () => LEON_HOME_PATH
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -98,7 +94,8 @@ export class OpenCommand extends BuiltInCommand {
|
||||
}
|
||||
|
||||
try {
|
||||
const openedPath = await FileHelper.openPath(target.path)
|
||||
const targetPath = target.getPath()
|
||||
const openedPath = await FileHelper.openPath(targetPath)
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
@@ -123,7 +120,7 @@ export class OpenCommand extends BuiltInCommand {
|
||||
items: [
|
||||
{
|
||||
label: `Failed to open ${target.label}`,
|
||||
value: target.path,
|
||||
value: target.getPath(),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
tone: 'error'
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
INSTANCE_ID,
|
||||
IS_TELEMETRY_ENABLED,
|
||||
LEON_HOME_PATH,
|
||||
LEON_PROFILE_PATH,
|
||||
LEON_FILE_PATH,
|
||||
LEON_VERSION,
|
||||
ASR_PROVIDER,
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
getActiveLLMTarget,
|
||||
getRoutingModeLLMDisplay
|
||||
} from '@/core/llm-manager/llm-routing'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
interface LeonMetadata {
|
||||
birthDate?: number
|
||||
@@ -133,7 +133,7 @@ export class StatusCommand extends BuiltInCommand {
|
||||
},
|
||||
{
|
||||
label: 'Profile path',
|
||||
value: LEON_PROFILE_PATH
|
||||
value: getProfilePaths().root
|
||||
},
|
||||
{
|
||||
label: 'Codebase path',
|
||||
|
||||
@@ -6,10 +6,8 @@ import type {
|
||||
BuiltInCommandExecutionResult
|
||||
} from '@/built-in-command/built-in-command'
|
||||
import { createListResult } from '@/built-in-command/built-in-command-renderer'
|
||||
import {
|
||||
PROFILE_TOOLS_PATH,
|
||||
TOOLS_PATH
|
||||
} from '@/constants'
|
||||
import { TOOLS_PATH } from '@/constants'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
import { ProfileHelper } from '@/helpers/profile-helper'
|
||||
|
||||
export const TOOL_COMMAND_NAME = 'tool'
|
||||
@@ -122,7 +120,7 @@ export function getSortedToolAutocompleteEntries(
|
||||
): ToolAutocompleteEntry[] {
|
||||
const toolsByQualifiedId = new Map<string, ToolAutocompleteEntry>()
|
||||
|
||||
for (const toolsPath of [TOOLS_PATH, PROFILE_TOOLS_PATH]) {
|
||||
for (const toolsPath of [TOOLS_PATH, getProfilePaths().tools]) {
|
||||
if (!fs.existsSync(toolsPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
getVoiceResourceState,
|
||||
type VoiceResourceState
|
||||
} from '@/core/voice/voice-resource-state'
|
||||
import { CODEBASE_PATH, PROFILE_CONFIG_PATH } from '@/leon-roots'
|
||||
import { CODEBASE_PATH } from '@/leon-roots'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
const VOICE_SETUP_SUB_COMMAND = 'setup'
|
||||
const VOICE_STATUS_SUB_COMMAND = 'status'
|
||||
@@ -255,7 +256,7 @@ export class VoiceCommand extends BuiltInCommand {
|
||||
...this.createResourceStatusItems(resourceState),
|
||||
{
|
||||
label: 'Profile config',
|
||||
value: PROFILE_CONFIG_PATH
|
||||
value: getProfilePaths().config
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
+141
-60
@@ -2,8 +2,11 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import YAML from 'yaml'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { PROFILE_CONFIG_PATH } from '@/leon-roots'
|
||||
import { LEON_PROFILE_NAME } from '@/leon-roots'
|
||||
import { getActiveProfileName } from '@/core/profile-runtime/profile-context'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
import type {
|
||||
LLMProviderConfigSchema,
|
||||
LeonConfigSchema,
|
||||
@@ -21,6 +24,26 @@ const DEFAULT_CONFIG: LeonConfig = {
|
||||
host: 'http://localhost',
|
||||
port: 5_366
|
||||
},
|
||||
client_interface: {
|
||||
allowed_origins: [],
|
||||
auth: {
|
||||
enabled: false,
|
||||
token: {
|
||||
env: 'LEON_PROFILE_TOKEN'
|
||||
}
|
||||
}
|
||||
},
|
||||
http_plugins: {
|
||||
enabled: false,
|
||||
allow_root_routes: false,
|
||||
auth: {
|
||||
enabled: false,
|
||||
token: {
|
||||
env: 'LEON_PROFILE_TOKEN'
|
||||
}
|
||||
},
|
||||
plugins: {}
|
||||
},
|
||||
routing: {
|
||||
mode: 'smart'
|
||||
},
|
||||
@@ -58,13 +81,6 @@ const DEFAULT_CONFIG: LeonConfig = {
|
||||
time_zone: null,
|
||||
after_speech_enabled: false,
|
||||
telemetry_enabled: true,
|
||||
http: {
|
||||
enabled: true,
|
||||
lang: 'en-US',
|
||||
api_key: {
|
||||
env: 'LEON_HTTP_API_KEY'
|
||||
}
|
||||
},
|
||||
python_tcp_server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5_367
|
||||
@@ -96,6 +112,12 @@ const DEFAULT_CONFIG: LeonConfig = {
|
||||
env: 'LEON_ZAI_API_KEY'
|
||||
}
|
||||
},
|
||||
minimax: {
|
||||
base_url: 'https://api.minimax.io/v1',
|
||||
api_key: {
|
||||
env: 'LEON_MINIMAX_API_KEY'
|
||||
}
|
||||
},
|
||||
openai: {
|
||||
api_key: {
|
||||
env: 'LEON_OPENAI_API_KEY'
|
||||
@@ -175,11 +197,14 @@ function toEnvString(value: OptionalStringConfigValue): string {
|
||||
class ConfigManager {
|
||||
private static instance: ConfigManager
|
||||
|
||||
private config: LeonConfig
|
||||
private readonly configs = new Map<string, LeonConfig>()
|
||||
private readonly profileEnvValues = new Map<string, Record<string, string>>()
|
||||
|
||||
private constructor() {
|
||||
this.config = this.load()
|
||||
this.syncProcessEnv()
|
||||
const config = this.load(LEON_PROFILE_NAME)
|
||||
|
||||
this.configs.set(LEON_PROFILE_NAME, config)
|
||||
this.syncProcessEnv(config)
|
||||
}
|
||||
|
||||
public static getInstance(): ConfigManager {
|
||||
@@ -190,23 +215,53 @@ class ConfigManager {
|
||||
return ConfigManager.instance
|
||||
}
|
||||
|
||||
public getConfig(): LeonConfig {
|
||||
return this.config
|
||||
public getConfig(profileName = getActiveProfileName()): LeonConfig {
|
||||
const cachedConfig = this.configs.get(profileName)
|
||||
|
||||
if (cachedConfig) {
|
||||
return cachedConfig
|
||||
}
|
||||
|
||||
const config = this.load(profileName)
|
||||
|
||||
this.configs.set(profileName, config)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
public reload(): LeonConfig {
|
||||
this.config = this.load()
|
||||
this.syncProcessEnv()
|
||||
public reload(profileName = getActiveProfileName()): LeonConfig {
|
||||
this.profileEnvValues.delete(profileName)
|
||||
const config = this.load(profileName)
|
||||
|
||||
return this.config
|
||||
this.configs.set(profileName, config)
|
||||
|
||||
// Legacy child processes still read the startup profile from process.env.
|
||||
if (profileName === LEON_PROFILE_NAME) {
|
||||
this.syncProcessEnv(config)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
public resolveSecretReference(reference: SecretReference): string {
|
||||
return process.env[reference.env] || ''
|
||||
public resolveSecretReference(
|
||||
reference: SecretReference,
|
||||
profileName = getActiveProfileName()
|
||||
): string {
|
||||
const profileValue = this.getProfileEnvValues(profileName)[reference.env]
|
||||
|
||||
if (profileValue) {
|
||||
return profileValue
|
||||
}
|
||||
|
||||
// Only the startup profile may inherit process-level secrets. Otherwise a
|
||||
// missing tenant secret could accidentally fall back to another profile.
|
||||
return profileName === LEON_PROFILE_NAME
|
||||
? process.env[reference.env] || ''
|
||||
: ''
|
||||
}
|
||||
|
||||
public getProviderConfig(provider: string): LLMProviderConfig | null {
|
||||
const providers = this.config.llm.providers as Record<
|
||||
const providers = this.getConfig().llm.providers as Record<
|
||||
string,
|
||||
LLMProviderConfig
|
||||
>
|
||||
@@ -219,9 +274,11 @@ class ConfigManager {
|
||||
}
|
||||
|
||||
public getProviderAPIKey(provider: string): string {
|
||||
const envName = this.getProviderAPIKeyEnv(provider)
|
||||
const providerConfig = this.getProviderConfig(provider)
|
||||
|
||||
return envName ? process.env[envName] || '' : ''
|
||||
return providerConfig
|
||||
? this.resolveSecretReference(providerConfig.api_key)
|
||||
: ''
|
||||
}
|
||||
|
||||
public getProviderBaseURL(provider: string): string {
|
||||
@@ -229,11 +286,12 @@ class ConfigManager {
|
||||
}
|
||||
|
||||
public async setValue(keyPath: string[], value: unknown): Promise<void> {
|
||||
const document = this.readDocument()
|
||||
const profileName = getActiveProfileName()
|
||||
const document = this.readDocument(profileName)
|
||||
|
||||
document.setIn(keyPath, value)
|
||||
await this.writeDocument(document)
|
||||
this.reload()
|
||||
await this.writeDocument(document, profileName)
|
||||
this.reload(profileName)
|
||||
}
|
||||
|
||||
public async setStringList(
|
||||
@@ -243,8 +301,8 @@ class ConfigManager {
|
||||
await this.setValue(keyPath, normalizeStringList(values))
|
||||
}
|
||||
|
||||
private load(): LeonConfig {
|
||||
const parsedConfig = this.readRawConfig()
|
||||
private load(profileName: string): LeonConfig {
|
||||
const parsedConfig = this.readRawConfig(profileName)
|
||||
const mergedConfig = mergeDefaults(
|
||||
cloneConfig(DEFAULT_CONFIG),
|
||||
parsedConfig
|
||||
@@ -253,13 +311,15 @@ class ConfigManager {
|
||||
return mergedConfig
|
||||
}
|
||||
|
||||
private readRawConfig(): Record<string, unknown> {
|
||||
if (!fs.existsSync(PROFILE_CONFIG_PATH)) {
|
||||
private readRawConfig(profileName: string): Record<string, unknown> {
|
||||
const configPath = getProfilePaths(profileName).config
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(PROFILE_CONFIG_PATH, 'utf8')
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf8')
|
||||
const parsedConfig = YAML.parse(rawConfig)
|
||||
|
||||
if (!isPlainObject(parsedConfig)) {
|
||||
@@ -269,65 +329,86 @@ class ConfigManager {
|
||||
return parsedConfig
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read profile config at "${PROFILE_CONFIG_PATH}": ${String(error)}`
|
||||
`Failed to read profile config at "${configPath}": ${String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private readDocument(): YAML.Document.Parsed {
|
||||
const rawConfig = fs.existsSync(PROFILE_CONFIG_PATH)
|
||||
? fs.readFileSync(PROFILE_CONFIG_PATH, 'utf8')
|
||||
private readDocument(profileName: string): YAML.Document.Parsed {
|
||||
const configPath = getProfilePaths(profileName).config
|
||||
const rawConfig = fs.existsSync(configPath)
|
||||
? fs.readFileSync(configPath, 'utf8')
|
||||
: YAML.stringify(DEFAULT_CONFIG)
|
||||
|
||||
return YAML.parseDocument(rawConfig)
|
||||
}
|
||||
|
||||
private async writeDocument(document: YAML.Document.Parsed): Promise<void> {
|
||||
private async writeDocument(
|
||||
document: YAML.Document.Parsed,
|
||||
profileName: string
|
||||
): Promise<void> {
|
||||
const rawValue = document.toJSON()
|
||||
|
||||
if (!isPlainObject(rawValue)) {
|
||||
throw new Error('Cannot save a profile config without a YAML object root.')
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(PROFILE_CONFIG_PATH), {
|
||||
const configPath = getProfilePaths(profileName).config
|
||||
|
||||
await fs.promises.mkdir(path.dirname(configPath), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.promises.writeFile(PROFILE_CONFIG_PATH, String(document))
|
||||
await fs.promises.writeFile(configPath, String(document))
|
||||
}
|
||||
|
||||
private syncProcessEnv(): void {
|
||||
process.env['LEON_LANG'] = this.config.language
|
||||
process.env['LEON_HOST'] = this.config.server.host
|
||||
process.env['LEON_PORT'] = String(this.config.server.port)
|
||||
process.env['LEON_ROUTING_MODE'] = this.config.routing.mode
|
||||
process.env['LEON_MOOD'] = this.config.mood.mode
|
||||
process.env['LEON_LLM'] = toEnvString(this.config.llm.default)
|
||||
process.env['LEON_WORKFLOW_LLM'] = toEnvString(this.config.llm.workflow)
|
||||
process.env['LEON_AGENT_LLM'] = toEnvString(this.config.llm.agent)
|
||||
process.env['LEON_WAKE_WORD'] = this.config.voice.wake_word_enabled
|
||||
private getProfileEnvValues(profileName: string): Record<string, string> {
|
||||
const cachedValues = this.profileEnvValues.get(profileName)
|
||||
|
||||
if (cachedValues) {
|
||||
return cachedValues
|
||||
}
|
||||
|
||||
const dotEnvPath = getProfilePaths(profileName).dotEnv
|
||||
const values = fs.existsSync(dotEnvPath)
|
||||
? dotenv.parse(fs.readFileSync(dotEnvPath, 'utf8'))
|
||||
: {}
|
||||
|
||||
this.profileEnvValues.set(profileName, values)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
private syncProcessEnv(config: LeonConfig): void {
|
||||
process.env['LEON_LANG'] = config.language
|
||||
process.env['LEON_HOST'] = config.server.host
|
||||
process.env['LEON_PORT'] = String(config.server.port)
|
||||
process.env['LEON_ROUTING_MODE'] = config.routing.mode
|
||||
process.env['LEON_MOOD'] = config.mood.mode
|
||||
process.env['LEON_LLM'] = toEnvString(config.llm.default)
|
||||
process.env['LEON_WORKFLOW_LLM'] = toEnvString(config.llm.workflow)
|
||||
process.env['LEON_AGENT_LLM'] = toEnvString(config.llm.agent)
|
||||
process.env['LEON_WAKE_WORD'] = config.voice.wake_word_enabled
|
||||
? 'true'
|
||||
: 'false'
|
||||
process.env['LEON_ASR'] = this.config.voice.asr.enabled ? 'true' : 'false'
|
||||
process.env['LEON_ASR_PROVIDER'] = this.config.voice.asr.provider
|
||||
process.env['LEON_TTS'] = this.config.voice.tts.enabled ? 'true' : 'false'
|
||||
process.env['LEON_TTS_PROVIDER'] = this.config.voice.tts.provider
|
||||
process.env['LEON_TIME_ZONE'] = toEnvString(this.config.time_zone)
|
||||
process.env['LEON_AFTER_SPEECH'] = this.config.after_speech_enabled
|
||||
process.env['LEON_ASR'] = config.voice.asr.enabled ? 'true' : 'false'
|
||||
process.env['LEON_ASR_PROVIDER'] = config.voice.asr.provider
|
||||
process.env['LEON_TTS'] = config.voice.tts.enabled ? 'true' : 'false'
|
||||
process.env['LEON_TTS_PROVIDER'] = config.voice.tts.provider
|
||||
process.env['LEON_TIME_ZONE'] = toEnvString(config.time_zone)
|
||||
process.env['LEON_AFTER_SPEECH'] = config.after_speech_enabled
|
||||
? 'true'
|
||||
: 'false'
|
||||
process.env['LEON_OVER_HTTP'] = this.config.http.enabled ? 'true' : 'false'
|
||||
process.env['LEON_HTTP_API_LANG'] = this.config.http.lang
|
||||
process.env['LEON_TELEMETRY'] = this.config.telemetry_enabled
|
||||
process.env['LEON_TELEMETRY'] = config.telemetry_enabled
|
||||
? 'true'
|
||||
: 'false'
|
||||
process.env['LEON_PY_TCP_SERVER_HOST'] = this.config.python_tcp_server.host
|
||||
process.env['LEON_PY_TCP_SERVER_HOST'] = config.python_tcp_server.host
|
||||
process.env['LEON_PY_TCP_SERVER_PORT'] = String(
|
||||
this.config.python_tcp_server.port
|
||||
config.python_tcp_server.port
|
||||
)
|
||||
process.env['LEON_LLAMACPP_BASE_URL'] =
|
||||
this.config.llm.providers['llamacpp']?.base_url || ''
|
||||
config.llm.providers['llamacpp']?.base_url || ''
|
||||
process.env['LEON_SGLANG_BASE_URL'] =
|
||||
this.config.llm.providers['sglang']?.base_url || ''
|
||||
config.llm.providers['sglang']?.base_url || ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-31
@@ -15,7 +15,6 @@ import {
|
||||
} from '@/leon-roots'
|
||||
import { CONFIG_MANAGER } from '@/config'
|
||||
import { RuntimeHelper } from '@/helpers/runtime-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
import {
|
||||
getInstalledLLMMetadata,
|
||||
resolveConfiguredLLMTarget
|
||||
@@ -35,7 +34,6 @@ export {
|
||||
|
||||
const PRODUCTION_ENV = 'production'
|
||||
const DEVELOPMENT_ENV = 'development'
|
||||
const TESTING_ENV = 'testing'
|
||||
|
||||
export const GITHUB_URL = 'https://github.com/leon-ai/leon'
|
||||
export const API_VERSION = 'v1'
|
||||
@@ -52,7 +50,6 @@ export const { default: LANG_CONFIGS } = await import('@@/core/langs.json', {
|
||||
export const LEON_NODE_ENV = process.env['LEON_NODE_ENV'] || PRODUCTION_ENV
|
||||
export const IS_PRODUCTION_ENV = LEON_NODE_ENV === PRODUCTION_ENV
|
||||
export const IS_DEVELOPMENT_ENV = LEON_NODE_ENV === DEVELOPMENT_ENV
|
||||
export const IS_TESTING_ENV = LEON_NODE_ENV === TESTING_ENV
|
||||
|
||||
/**
|
||||
* Paths
|
||||
@@ -65,7 +62,6 @@ export const PROFILE_LOGS_PATH = path.join(LEON_PROFILE_PATH, 'logs')
|
||||
export const PROFILE_SKILLS_PATH = path.join(LEON_PROFILE_PATH, 'skills')
|
||||
export const PROFILE_TOOLS_PATH = path.join(LEON_PROFILE_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 PROFILE_CONVERSATION_LOG_PATH = path.join(
|
||||
LEON_PROFILE_PATH,
|
||||
'conversation_log.json'
|
||||
@@ -340,7 +336,6 @@ export const PYTORCH_VERSION = PYTORCH_VERSIONS.torch
|
||||
/**
|
||||
* Binaries / distribution
|
||||
*/
|
||||
export const BINARIES_FOLDER_NAME = SystemHelper.getBinariesFolderName()
|
||||
export const BRIDGES_PATH = path.join(CODEBASE_PATH, 'bridges')
|
||||
export const NODEJS_BRIDGE_ROOT_PATH = path.join(BRIDGES_PATH, 'nodejs')
|
||||
export const PYTHON_BRIDGE_ROOT_PATH = path.join(BRIDGES_PATH, 'python')
|
||||
@@ -462,6 +457,13 @@ export const LANG = PROFILE_CONFIG.language as LongLanguageCode
|
||||
|
||||
export const HOST = PROFILE_CONFIG.server.host
|
||||
export const PORT = PROFILE_CONFIG.server.port
|
||||
export const CLIENT_INTERFACE_ALLOWED_ORIGINS =
|
||||
PROFILE_CONFIG.client_interface.allowed_origins
|
||||
export const IS_CLIENT_INTERFACE_AUTH_ENABLED =
|
||||
PROFILE_CONFIG.client_interface.auth.enabled
|
||||
export const PROFILE_TOKEN = CONFIG_MANAGER.resolveSecretReference(
|
||||
PROFILE_CONFIG.client_interface.auth.token
|
||||
)
|
||||
|
||||
export const TIME_ZONE = PROFILE_CONFIG.time_zone ?? undefined
|
||||
|
||||
@@ -473,12 +475,6 @@ export const HAS_TTS = PROFILE_CONFIG.voice.tts.enabled
|
||||
export const TTS_PROVIDER = PROFILE_CONFIG.voice.tts.provider
|
||||
export const HAS_WAKE_WORD = PROFILE_CONFIG.voice.wake_word_enabled
|
||||
|
||||
export const HAS_OVER_HTTP = PROFILE_CONFIG.http.enabled
|
||||
export const HTTP_API_KEY = CONFIG_MANAGER.resolveSecretReference(
|
||||
PROFILE_CONFIG.http.api_key
|
||||
)
|
||||
export const HTTP_API_LANG = PROFILE_CONFIG.http.lang
|
||||
|
||||
export const PYTHON_TCP_SERVER_HOST = PROFILE_CONFIG.python_tcp_server.host
|
||||
export const PYTHON_TCP_SERVER_PORT = PROFILE_CONFIG.python_tcp_server.port
|
||||
|
||||
@@ -493,7 +489,6 @@ export const LLM_SKILL_ROUTER_DUTY_SKILL_LIST_PATH = path.join(
|
||||
* LLMs
|
||||
* @see k-quants comparison: https://github.com/ggerganov/llama.cpp/pull/1684
|
||||
*/
|
||||
export const HAS_LLM = true
|
||||
export const LEON_ROUTING_MODE = PROFILE_CONFIG.routing.mode
|
||||
export const LEON_MOOD = PROFILE_CONFIG.mood.mode
|
||||
export const LEON_PULSE_ENABLED = PROFILE_CONFIG.runtime.pulse_enabled
|
||||
@@ -506,11 +501,7 @@ export const LEON_CONTEXT_DISABLED_FILES =
|
||||
PROFILE_CONFIG.context.disabled_files
|
||||
export const LLM_DIR_PATH = path.join(MODELS_PATH, 'llm')
|
||||
export const LLM_MANIFEST_PATH = path.join(LLM_DIR_PATH, 'manifest.json')
|
||||
const {
|
||||
defaultInstalledLLMPath,
|
||||
installedLLMName,
|
||||
installedLLMVersion
|
||||
} = getInstalledLLMMetadata(LLM_MANIFEST_PATH)
|
||||
const { defaultInstalledLLMPath } = getInstalledLLMMetadata(LLM_MANIFEST_PATH)
|
||||
export const DEFAULT_INSTALLED_LLM_PATH = defaultInstalledLLMPath
|
||||
export const LEON_LLM = PROFILE_CONFIG.llm.default ?? ''
|
||||
export const LEON_WORKFLOW_LLM = PROFILE_CONFIG.llm.workflow ?? ''
|
||||
@@ -529,22 +520,8 @@ export const AGENT_LLM_TARGET = resolveConfiguredLLMTarget(
|
||||
llmDirPath: LLM_DIR_PATH
|
||||
}
|
||||
)
|
||||
export const WORKFLOW_LLM_PROVIDER = WORKFLOW_LLM_TARGET.provider
|
||||
export const AGENT_LLM_PROVIDER = AGENT_LLM_TARGET.provider
|
||||
export const LLM_NAME = installedLLMName
|
||||
export const LLM_VERSION = installedLLMVersion
|
||||
export const LLM_FILE_NAME = DEFAULT_INSTALLED_LLM_PATH
|
||||
? path.basename(DEFAULT_INSTALLED_LLM_PATH)
|
||||
: ''
|
||||
export const LLM_NAME_WITH_VERSION = `${LLM_NAME} (${LLM_VERSION})`
|
||||
export const LLM_PATH = DEFAULT_INSTALLED_LLM_PATH
|
||||
? path.isAbsolute(DEFAULT_INSTALLED_LLM_PATH)
|
||||
? DEFAULT_INSTALLED_LLM_PATH
|
||||
: path.resolve(CODEBASE_PATH, DEFAULT_INSTALLED_LLM_PATH)
|
||||
: ''
|
||||
export const LLM_MINIMUM_TOTAL_VRAM = 6
|
||||
export const LLM_HIGH_TIER_MINIMUM_TOTAL_VRAM = 18
|
||||
export const LLM_MINIMUM_FREE_VRAM = 6
|
||||
/*export const LLM_HF_DOWNLOAD_URL =
|
||||
'https://huggingface.co/QuantFactory/Meta-Llama-3-8B-Instruct-GGUF/resolve/main/Meta-Llama-3-8B-Instruct.Q5_K_S.gguf?download=true'
|
||||
*/
|
||||
|
||||
@@ -51,7 +51,6 @@ const ESTIMATED_CHARS_PER_TOKEN = 4
|
||||
const MAX_PARAPHRASE_INPUT_TOKENS = 1_024
|
||||
|
||||
export default class Brain {
|
||||
private static instance: Brain
|
||||
private _lang: ShortLanguageCode = 'en'
|
||||
private _isTalkingWithVoice = false
|
||||
private answerQueue = new AnswerQueue<QueuedOutput>()
|
||||
@@ -68,26 +67,22 @@ export default class Brain {
|
||||
public isMuted = false // Close Leon mouth if true; e.g. over HTTP
|
||||
|
||||
constructor() {
|
||||
if (!Brain.instance) {
|
||||
LogHelper.title('Brain')
|
||||
LogHelper.success('New instance')
|
||||
LogHelper.title('Brain')
|
||||
LogHelper.success('New profile runtime instance')
|
||||
|
||||
Brain.instance = this
|
||||
|
||||
/**
|
||||
* Clean up the answer queue every 2 hours
|
||||
* to avoid memory leaks
|
||||
*/
|
||||
setInterval(
|
||||
() => {
|
||||
if (this.answerQueueProcessTimerId) {
|
||||
this.cleanUpAnswerQueueTimer()
|
||||
this.answerQueue.clear()
|
||||
}
|
||||
},
|
||||
60_000 * 60 * 2
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Clean up the answer queue every 2 hours
|
||||
* to avoid memory leaks
|
||||
*/
|
||||
setInterval(
|
||||
() => {
|
||||
if (this.answerQueueProcessTimerId) {
|
||||
this.cleanUpAnswerQueueTimer()
|
||||
this.answerQueue.clear()
|
||||
}
|
||||
},
|
||||
60_000 * 60 * 2
|
||||
).unref()
|
||||
}
|
||||
|
||||
public get skillFriendlyName(): string {
|
||||
@@ -127,7 +122,7 @@ export default class Brain {
|
||||
options.shouldInterrupt
|
||||
) {
|
||||
// Tell client to interrupt the current speech
|
||||
SOCKET_SERVER.socket?.emit('tts-interruption')
|
||||
SOCKET_SERVER.emitToChatClients('tts-interruption')
|
||||
// Cancel all the future speeches
|
||||
TTS.speeches = []
|
||||
LogHelper.info('Leon got interrupted')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
|
||||
import type { NLUProcessResult } from '@/core/nlp/types'
|
||||
import type {
|
||||
@@ -17,11 +17,29 @@ import {
|
||||
PYTHON_BRIDGE_RUNTIME_BIN_PATH,
|
||||
TSX_CLI_PATH
|
||||
} from '@/constants'
|
||||
import { BRAIN, SOCKET_SERVER, NLU, CONVERSATION_LOGGER } from '@/core'
|
||||
import {
|
||||
BRAIN,
|
||||
SOCKET_SERVER,
|
||||
NLU,
|
||||
CONVERSATION_LOGGER,
|
||||
TOOL_EXECUTOR
|
||||
} from '@/core'
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
import { DateHelper } from '@/helpers/date-helper'
|
||||
import { RuntimeHelper } from '@/helpers/runtime-helper'
|
||||
import { ConversationHistoryHelper } from '@/helpers/conversation-history-helper'
|
||||
import { CONVERSATION_SESSION_MANAGER } from '@/core/session-manager'
|
||||
import {
|
||||
getActiveProfileName,
|
||||
runWithProfileContext
|
||||
} from '@/core/profile-runtime/profile-context'
|
||||
import {
|
||||
parseSkillToolBridgeMessage,
|
||||
SKILL_TOOL_REQUEST_PREFIX,
|
||||
SKILL_TOOL_RESULT_PREFIX,
|
||||
type SkillToolRequest,
|
||||
type SkillToolResponse
|
||||
} from '@/core/skill-tool-bridge'
|
||||
|
||||
const SYSTEM_WIDGET_HISTORY_MODE = 'system_widget'
|
||||
|
||||
@@ -30,20 +48,28 @@ export class LogicActionSkillHandler {
|
||||
nluProcessResult: NLUProcessResult,
|
||||
utteranceId: string
|
||||
): Promise<Partial<BrainProcessResult>> {
|
||||
return new Promise(async (resolve) => {
|
||||
const intentObjectPath = path.join(TMP_PATH, `${utteranceId}.json`)
|
||||
const {
|
||||
skillConfig: { name: skillFriendlyName }
|
||||
} = nluProcessResult
|
||||
const intentObjectPath = path.join(TMP_PATH, `${utteranceId}.json`)
|
||||
const {
|
||||
skillConfig: { name: skillFriendlyName }
|
||||
} = nluProcessResult
|
||||
|
||||
await this.executeLogicActionSkill(
|
||||
nluProcessResult,
|
||||
utteranceId,
|
||||
intentObjectPath
|
||||
)
|
||||
await this.executeLogicActionSkill(
|
||||
nluProcessResult,
|
||||
utteranceId,
|
||||
intentObjectPath
|
||||
)
|
||||
|
||||
BRAIN.skillFriendlyName = skillFriendlyName
|
||||
BRAIN.skillFriendlyName = skillFriendlyName
|
||||
const profileName = getActiveProfileName()
|
||||
const conversationSessionId =
|
||||
CONVERSATION_SESSION_MANAGER.getCurrentSessionId()
|
||||
const skillProcess = BRAIN.skillProcess
|
||||
|
||||
if (!skillProcess) {
|
||||
throw new Error(`Failed to start the "${skillFriendlyName}" skill.`)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let buffer = ''
|
||||
let stderrBuffer = ''
|
||||
let lastSkillResult: SkillResult | undefined = undefined
|
||||
@@ -67,7 +93,7 @@ export class LogicActionSkillHandler {
|
||||
}
|
||||
|
||||
// Read skill output
|
||||
BRAIN.skillProcess?.stdout.on('data', (data: Buffer) => {
|
||||
skillProcess.stdout.on('data', (data: Buffer) => {
|
||||
SOCKET_SERVER.emitToChatClients('is-typing', true)
|
||||
buffer += data.toString()
|
||||
|
||||
@@ -80,7 +106,15 @@ export class LogicActionSkillHandler {
|
||||
|
||||
if (chunk) {
|
||||
// Check if this is a tool log first
|
||||
if (chunk.includes('[LEON_TOOL_LOG]')) {
|
||||
if (chunk.startsWith(SKILL_TOOL_REQUEST_PREFIX)) {
|
||||
void runWithProfileContext({ profileName }, () =>
|
||||
this.handleSkillToolRequest(
|
||||
chunk,
|
||||
skillProcess,
|
||||
conversationSessionId
|
||||
)
|
||||
)
|
||||
} else if (chunk.includes('[LEON_TOOL_LOG]')) {
|
||||
// Extract and log the tool message without treating it as skill response
|
||||
const cleanedMessage = chunk.replace('[LEON_TOOL_LOG]', '').trim()
|
||||
if (cleanedMessage) {
|
||||
@@ -106,18 +140,18 @@ export class LogicActionSkillHandler {
|
||||
|
||||
// stderr can contain regular progress logs from underlying tools, so do not
|
||||
// surface it as a broken skill until the process has actually failed.
|
||||
BRAIN.skillProcess?.stderr.on('data', (data: Buffer) => {
|
||||
skillProcess.stderr.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString()
|
||||
stderrBuffer += chunk
|
||||
this.handleLogicActionSkillProcessError(chunk)
|
||||
})
|
||||
|
||||
BRAIN.skillProcess?.on('error', (error: Error) => {
|
||||
skillProcess.on('error', (error: Error) => {
|
||||
stderrBuffer += `${stderrBuffer ? '\n' : ''}${error.message}`
|
||||
})
|
||||
|
||||
// Catch the end of the skill execution
|
||||
BRAIN.skillProcess?.on('close', (code: number | null) => {
|
||||
skillProcess.on('close', (code: number | null) => {
|
||||
LogHelper.title(`${BRAIN.skillFriendlyName} skill (on close)`)
|
||||
flushBufferedOutput()
|
||||
this.deleteIntentObjFile(intentObjectPath)
|
||||
@@ -150,6 +184,59 @@ export class LogicActionSkillHandler {
|
||||
})
|
||||
}
|
||||
|
||||
private static async handleSkillToolRequest(
|
||||
line: string,
|
||||
skillProcess: ChildProcessWithoutNullStreams | undefined,
|
||||
conversationSessionId: string
|
||||
): Promise<void> {
|
||||
const request = parseSkillToolBridgeMessage<SkillToolRequest>(
|
||||
line,
|
||||
SKILL_TOOL_REQUEST_PREFIX
|
||||
)
|
||||
|
||||
if (!request || !skillProcess?.stdin.writable) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await TOOL_EXECUTOR.executeTool({
|
||||
...request.input,
|
||||
onProgress: (progress) => {
|
||||
SOCKET_SERVER.emitToChatClients(
|
||||
'tool-progress',
|
||||
{
|
||||
requestId: request.requestId,
|
||||
toolkitId: request.input.toolkitId || null,
|
||||
toolId: request.input.toolId,
|
||||
functionName: request.input.functionName || null,
|
||||
progress
|
||||
},
|
||||
{ sessionId: conversationSessionId }
|
||||
)
|
||||
}
|
||||
}).catch(
|
||||
(error: unknown) => ({
|
||||
status: 'error' as const,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
data: {
|
||||
tool_id: request.input.toolId,
|
||||
toolkit_id: request.input.toolkitId || null,
|
||||
function_name: request.input.functionName,
|
||||
input: request.input.toolInput || null,
|
||||
parsed_input: request.input.parsedInput || null,
|
||||
output: {}
|
||||
}
|
||||
})
|
||||
)
|
||||
const response: SkillToolResponse = {
|
||||
requestId: request.requestId,
|
||||
result
|
||||
}
|
||||
|
||||
skillProcess.stdin.write(
|
||||
`${SKILL_TOOL_RESULT_PREFIX} ${JSON.stringify(response)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the skill process output for each complete chunk of data
|
||||
*/
|
||||
@@ -369,6 +456,10 @@ export class LogicActionSkillHandler {
|
||||
PYTHON_BRIDGE_RUNTIME_BIN_PATH,
|
||||
pythonBridgeCommandArgs,
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
LEON_PROFILE: getActiveProfileName()
|
||||
},
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
@@ -391,6 +482,10 @@ export class LogicActionSkillHandler {
|
||||
NODE_RUNTIME_BIN_PATH,
|
||||
nodejsBridgeCommandArgs,
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
LEON_PROFILE: getActiveProfileName()
|
||||
},
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
@@ -399,6 +494,7 @@ export class LogicActionSkillHandler {
|
||||
}
|
||||
} catch (e) {
|
||||
LogHelper.error(`Failed to save intent object: ${e}`)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MoodState } from '@/core/config-states/mood-state'
|
||||
import { ModelState } from '@/core/config-states/model-state'
|
||||
import { RoutingModeState } from '@/core/config-states/routing-mode-state'
|
||||
import { getActiveProfileName } from '@/core/profile-runtime/profile-context'
|
||||
|
||||
class ConfigState {
|
||||
export class ConfigState {
|
||||
private readonly moodState = new MoodState()
|
||||
private readonly modelState = new ModelState()
|
||||
private readonly routingModeState = new RoutingModeState()
|
||||
@@ -20,4 +21,35 @@ class ConfigState {
|
||||
}
|
||||
}
|
||||
|
||||
export const CONFIG_STATE = new ConfigState()
|
||||
class ProfileConfigState {
|
||||
private readonly states = new Map<string, ConfigState>()
|
||||
|
||||
private getState(): ConfigState {
|
||||
const profileName = getActiveProfileName()
|
||||
const existingState = this.states.get(profileName)
|
||||
|
||||
if (existingState) {
|
||||
return existingState
|
||||
}
|
||||
|
||||
const state = new ConfigState()
|
||||
|
||||
this.states.set(profileName, state)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
public getMoodState(): MoodState {
|
||||
return this.getState().getMoodState()
|
||||
}
|
||||
|
||||
public getModelState(): ModelState {
|
||||
return this.getState().getModelState()
|
||||
}
|
||||
|
||||
public getRoutingModeState(): RoutingModeState {
|
||||
return this.getState().getRoutingModeState()
|
||||
}
|
||||
}
|
||||
|
||||
export const CONFIG_STATE = new ProfileConfigState()
|
||||
|
||||
@@ -57,9 +57,7 @@ function getTargetModelName(target: ResolvedLLMTarget): string {
|
||||
}
|
||||
|
||||
function getSupportedModelProviders(): LLMProviders[] {
|
||||
return Object.values(LLMProviders).filter(
|
||||
(provider) => provider !== LLMProviders.Local
|
||||
)
|
||||
return Object.values(LLMProviders)
|
||||
}
|
||||
|
||||
function resolveLocalModelCandidatePath(model: string): string {
|
||||
@@ -177,13 +175,11 @@ export class ModelState {
|
||||
}
|
||||
|
||||
public hasProviderAPIKey(provider: LLMProviders): boolean {
|
||||
const apiKeyEnv = this.getProviderAPIKeyEnv(provider)
|
||||
|
||||
if (!apiKeyEnv) {
|
||||
if (!this.getProviderAPIKeyEnv(provider)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return String(process.env[apiKeyEnv] || '').trim() !== ''
|
||||
return CONFIG_MANAGER.getProviderAPIKey(provider).trim() !== ''
|
||||
}
|
||||
|
||||
public createConfiguredTargetValue(
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_CONTEXT_PATH } from '@/constants'
|
||||
import { DateHelper } from '@/helpers/date-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
import { ContextFile } from '@/core/context-manager/context-file'
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
ContextProbeHelper,
|
||||
RunningProcessEntry
|
||||
} from '@/core/context-manager/context-probe-helper'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
interface AppActivityAggregate {
|
||||
appName: string
|
||||
@@ -523,7 +523,7 @@ export class ActivityContextFile extends ContextFile {
|
||||
const stateFilePath = this.getStateFilePath()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(PROFILE_CONTEXT_PATH, { recursive: true })
|
||||
fs.mkdirSync(getProfilePaths().context, { recursive: true })
|
||||
fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2), 'utf8')
|
||||
} catch {
|
||||
// Ignore state persistence errors.
|
||||
@@ -563,6 +563,6 @@ export class ActivityContextFile extends ContextFile {
|
||||
}
|
||||
|
||||
private getStateFilePath(): string {
|
||||
return path.join(PROFILE_CONTEXT_PATH, ACTIVITY_STATE_FILENAME)
|
||||
return path.join(getProfilePaths().context, ACTIVITY_STATE_FILENAME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,40 @@ export class ArchitectureContextFile extends ContextFile {
|
||||
'# ARCHITECTURE',
|
||||
`- Generated at: ${DateHelper.getDateTime()}`,
|
||||
'- Leon-native layer model: `Skills -> Actions -> Tools -> Functions (-> Binaries)`.',
|
||||
'- Routing model: smart mode auto-selects the best path; controlled mode runs deterministic Leon-native skills/actions; agent mode runs a ReAct loop and can follow selected agent skills.',
|
||||
'- Routing model: smart mode auto-selects the best path; controlled mode runs deterministic Leon-native skills/actions; agent mode runs the continuous agent loop and can follow selected agent skills.',
|
||||
'- Core runtime: `core/brain/brain.ts`, `llm-duties/react-llm-duty.ts`, `toolkit-registry.ts`, `tool-executor.ts`.',
|
||||
'## Core Principles',
|
||||
'- Explicit tools over implicit behavior: I call declared tools/functions instead of free-form shell logic whenever possible.',
|
||||
'- Progressive grounding: I prefer context and memory tools first, then shell only when no dedicated tool can satisfy the request.',
|
||||
'- Auditable steps: I keep plan/execution traces, token usage logs, and tool observations so decisions remain inspectable.',
|
||||
'## ReAct Loop',
|
||||
'- Planning phase chooses either a direct answer, an ordered tool plan, or a relevant agent skill workflow.',
|
||||
'## Client Interfaces',
|
||||
'- Leon exposes a client-agnostic Socket.IO interface so built-in and custom clients can connect through the same live dialogue contract.',
|
||||
'- HTTP APIs remain request/response support surfaces; live profile-scoped utterances should use the Socket.IO client interface.',
|
||||
'- External HTTP plugins can extend Leon\'s HTTP contract without patching the core API for each integration.',
|
||||
'- Custom clients can read profile-owned extension JSON files through a generic redacted HTTP endpoint, covering skill memory, skill settings, and tool settings without exposing secrets.',
|
||||
'## Profile Runtimes',
|
||||
'- One Leon server can serve multiple profiles concurrently, with each request and agent turn bound to one profile for its full asynchronous lifetime.',
|
||||
'- Runtime services are created lazily per profile, while config, secrets, sessions, memory, context, skills, tools, settings, and logs remain isolated in profile-owned paths.',
|
||||
'- A `<profile>:<token>` credential selects and authenticates the profile across Socket.IO clients, HTTP integrations, and Leon Satellite.',
|
||||
'## Leon Satellite',
|
||||
'- Leon Satellite is an optional process on a user device that connects its enabled and available profile tools to a remote Leon server.',
|
||||
'- Eligible tool calls are routed through Satellite and executed on that device; those tools become unavailable when Satellite disconnects.',
|
||||
'- Satellite provides generic transport only. Device-, application-, and company-specific behavior stays in tools and skills instead of Leon Core.',
|
||||
'## Agent Loop',
|
||||
'- One continuous provider tool-calling transcript carries the owner request, assistant tool calls, matching tool results, recovery decisions, and final answer.',
|
||||
'- Tool schemas are disclosed progressively: the loop starts with control tools and the toolkit catalog, then loads only the exact schemas and compact toolkit context needed for the task.',
|
||||
'- The model-facing transcript has a fixed input budget: large tool results stay in artifact logs with bounded previews, and inactive toolkit schemas plus older completed tool exchanges are compacted progressively only when needed.',
|
||||
'- Earlier-turn artifact manifests have one global size bound, and overlapping reads of the same artifact range are rejected so follow-up turns do not rebuild oversized duplicate context.',
|
||||
'- Tool state is separated: installed tools exist in the registry, enabled tools are not disabled by the owner, and available tools have the required settings to run.',
|
||||
'- Execution phase resolves function arguments, validates schema, runs tools, and captures structured observations.',
|
||||
'- Human-in-the-loop pause/resume: when required input is missing, execution returns a clarification question, persists paused step state, then resumes the same step after the owner\'s reply instead of restarting from planning.',
|
||||
'- Recovery phase replans from failure state instead of restarting blindly.',
|
||||
'- Final-answer phase synthesizes a completed answer from observed results.',
|
||||
'- Deterministic runtime guards validate and repair arguments, block duplicate calls, execute tools, and return every success or failure as a structured observation to the same loop.',
|
||||
'- Human-in-the-loop pause/resume persists the full agent transcript, visible plan state, and clarification question, then appends the owner reply and continues without rebuilding a phase prompt.',
|
||||
'- Each run has 32 operational iterations. At that checkpoint, a tool-restricted synthesis either answers the original request from verified evidence or explains what remains, offers alternatives, and asks permission to continue with a focused next pass.',
|
||||
'- The final eight iterations add convergence guidance. Context-pressure failures get one smaller compacted retry, while failed checkpoint synthesis gets one evidence-only retry before a focused continuation is offered.',
|
||||
'- Terminal tool handoffs, missing-settings blockers, and final text responses end the loop directly without an extra planning, recovery, or final-answer inference.',
|
||||
'- Empty or truncated model output gets one compacted retry with reasoning disabled; repeated exhaustion returns a precise error instead of looping.',
|
||||
'- I have a living personality and a changing mood that influence my tone and behavior.',
|
||||
'- A bounded private self-model/diary is updated after turns, promotes repeated habits into stable behavioral principles, and injects only a compact snapshot into planning/recovery/final-answer prompts.',
|
||||
'- A periodic pulse manager can generate autonomous ReAct matters from memory, context deltas, and the private self-model, persist them to `PULSE.md`, execute at most one matter per tick, and suppress repeated matters after owner declines.',
|
||||
'- A bounded private self-model/diary is updated after turns, promotes repeated habits into stable behavioral principles, and injects only a compact snapshot into the first agent request.',
|
||||
'- A periodic pulse manager can generate autonomous agent matters from memory, context deltas, and the private self-model, persist them to `PULSE.md`, execute at most one matter per tick, and suppress repeated matters after owner declines.',
|
||||
'## Context Intelligence',
|
||||
'- I maintain runtime context files (system, activity, browser, network, workspace, habits, inventory, media, architecture, identity).',
|
||||
'- I use `structured_knowledge.context.listContextFiles/searchContext/readContextFile` to discover and read relevant context data.',
|
||||
@@ -42,8 +60,7 @@ export class ArchitectureContextFile extends ContextFile {
|
||||
'- Runtime maintenance keeps memory lean: indexing is throttled, only dirty namespaces are refreshed, and older short-term memory is compacted or pruned.',
|
||||
'## Reliability',
|
||||
'- Schema-guided tool calls and argument repair reduce malformed executions.',
|
||||
'- Duplicate-input and failure-aware retries reduce repeated bad calls.',
|
||||
'- Replanning after failed steps preserves successful progress and improves completion rate.',
|
||||
'- Duplicate-input guards and observation-driven recovery reduce repeated bad calls while preserving successful progress.',
|
||||
'- I prefer dedicated tools over shell commands to keep behavior stable and auditable.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
CODEBASE_PATH,
|
||||
GLOBAL_DATA_PATH,
|
||||
MODELS_PATH,
|
||||
PROFILE_CONTEXT_PATH,
|
||||
PROFILE_LOGS_PATH,
|
||||
SERVER_CORE_PATH,
|
||||
SKILLS_PATH,
|
||||
TMP_PATH,
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
} from '@/constants'
|
||||
import { DateHelper } from '@/helpers/date-helper'
|
||||
import { ContextFile } from '@/core/context-manager/context-file'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
export class HomeContextFile extends ContextFile {
|
||||
public readonly filename = 'HOME.md'
|
||||
@@ -25,6 +24,7 @@ export class HomeContextFile extends ContextFile {
|
||||
|
||||
public generate(): string {
|
||||
const codebasePath = CODEBASE_PATH
|
||||
const profilePaths = getProfilePaths()
|
||||
const serverSourcePath = path.join(codebasePath, 'server', 'src')
|
||||
|
||||
return [
|
||||
@@ -36,10 +36,10 @@ export class HomeContextFile extends ContextFile {
|
||||
`- Tools path: ${TOOLS_PATH}`,
|
||||
`- Global data path: ${GLOBAL_DATA_PATH}`,
|
||||
`- Models path: ${MODELS_PATH}`,
|
||||
`- Context path: ${PROFILE_CONTEXT_PATH}`,
|
||||
`- Context path: ${profilePaths.context}`,
|
||||
`- Server source path: ${serverSourcePath}`,
|
||||
`- Server core runtime path: ${SERVER_CORE_PATH}`,
|
||||
`- Logs path: ${PROFILE_LOGS_PATH}`,
|
||||
`- Logs path: ${profilePaths.logs}`,
|
||||
`- Temp path: ${TMP_PATH}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -14,11 +14,14 @@ export class LeonContextFile extends ContextFile {
|
||||
'- Timeline: my first code was written in 2017; I have been active since February 2019.',
|
||||
'- Source repository: https://github.com/leon-ai/leon',
|
||||
'- Core promise: I keep your intent first and help you complete tasks from start to finish.',
|
||||
'## Profiles & Privacy',
|
||||
'- Each owner or usage context has its own profile, whose config, secrets, sessions, memory, context, skills, tools, settings, and logs stay isolated.',
|
||||
'- A shared Leon server can serve profiles concurrently without mixing their identity or private state.',
|
||||
'## Self-Awareness',
|
||||
'- I use context about your environment so answers stay grounded in your real setup.',
|
||||
'- I use context from the environment where my current profile is running, so I do not confuse a remote server with your device.',
|
||||
'- I use dedicated tools for context and memory instead of guessing.',
|
||||
'- For environment questions, I check context first. For personal history, I check memory first.',
|
||||
'- I refresh context like your activity, local inventory, etc. regularly.',
|
||||
'- I refresh activity and inventory context for the machine producing it. When I run remotely, Leon Satellite can let me use enabled and available tools from your device.',
|
||||
'- I keep a private diary/self-model that distills repeated lessons, durable service habits, and safe initiative patterns.',
|
||||
'- I do not stuff the full diary into prompts. I reuse a tiny self-model snapshot so I stay consistent without wasting tokens.',
|
||||
'- Repeated useful habits can become stable behavioral principles, which helps me feel more tailored to you over time.',
|
||||
@@ -38,12 +41,15 @@ export class LeonContextFile extends ContextFile {
|
||||
'## Operating Modes',
|
||||
'- `smart` (default): I choose the best mode for each task.',
|
||||
'- `controlled`: I follow predictable Leon-native skills and actions.',
|
||||
'- `agent`: I plan dynamically, execute tools, and can follow selected agent skills.',
|
||||
'- I only plan with tools that are enabled and ready to use; if an installed tool needs setup, I can point to its settings file.',
|
||||
'- `agent`: I use one continuous tool-calling transcript to reason, act, observe results, recover, and answer; I can also follow selected agent skills.',
|
||||
'- Agent models must support tool calling. I load only the relevant toolkit schemas as I work, and if an installed tool needs setup, I can point to its settings file.',
|
||||
'- I keep agent runs within a safe context budget by retaining large tool outputs as artifacts and progressively compacting older completed tool exchanges only when needed.',
|
||||
'## Principles',
|
||||
'- I prioritize clear actions and concise answers.',
|
||||
'- I recover from failures with retries before giving up.',
|
||||
'- If information is missing, I ask a short clarification question.',
|
||||
'- I treat tool failures as observations and recover in the same transcript before giving up.',
|
||||
'- If a model exhausts its context or output budget, I retry once from a compacted view before reporting the blocker.',
|
||||
'- If required information is missing, I ask one short clarification question and resume the saved transcript after the reply.',
|
||||
'- If a run reaches its 32-iteration checkpoint, I answer from verified evidence when possible; otherwise I explain what remains, offer alternatives, and ask before continuing with a focused next pass.',
|
||||
'- I keep collaboration practical and centered on your goals.',
|
||||
'- I stay human-like in tone while remaining truthful and useful.'
|
||||
].join('\n')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_CONTEXT_PATH } from '@/constants'
|
||||
import { DateHelper } from '@/helpers/date-helper'
|
||||
import { ContextFile } from '@/core/context-manager/context-file'
|
||||
import { ContextProbeHelper } from '@/core/context-manager/context-probe-helper'
|
||||
import { ContextStateStore } from '@/core/context-manager/context-state-store'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
interface MediaProfileState {
|
||||
trackingStartedAt: string
|
||||
@@ -50,7 +50,7 @@ export class MediaProfileContextFile extends ContextFile {
|
||||
public generate(): string {
|
||||
const nowIso = new Date().toISOString()
|
||||
const browserHistoryPath = path.join(
|
||||
PROFILE_CONTEXT_PATH,
|
||||
getProfilePaths().context,
|
||||
'BROWSER_HISTORY.md'
|
||||
)
|
||||
const browserRecords = this.loadBrowserHistoryRecords(browserHistoryPath)
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from 'node:fs'
|
||||
import { ContextFile } from '@/core/context-manager/context-file'
|
||||
import {
|
||||
buildOwnerDocument,
|
||||
OWNER_CONTEXT_PATH,
|
||||
getOwnerContextPath,
|
||||
readOwnerProfileSync
|
||||
} from '@/core/context-manager/owner-profile'
|
||||
|
||||
@@ -19,9 +19,11 @@ export class OwnerContextFile extends ContextFile {
|
||||
}
|
||||
|
||||
public generate(): string {
|
||||
if (fs.existsSync(OWNER_CONTEXT_PATH)) {
|
||||
const ownerContextPath = getOwnerContextPath()
|
||||
|
||||
if (fs.existsSync(ownerContextPath)) {
|
||||
try {
|
||||
return fs.readFileSync(OWNER_CONTEXT_PATH, 'utf8').trimEnd()
|
||||
return fs.readFileSync(ownerContextPath, 'utf8').trimEnd()
|
||||
} catch {
|
||||
// Fall back to the derived skeleton below.
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class SystemResourcesContextFile extends ContextFile {
|
||||
public generate(): string {
|
||||
const generatedAt = DateHelper.getDateTime()
|
||||
const totalMemoryBytes = os.totalmem()
|
||||
const freeMemoryBytes = os.freemem()
|
||||
const freeMemoryBytes = SystemHelper.getFreeRAMInBytes()
|
||||
const usedMemoryBytes = Math.max(totalMemoryBytes - freeMemoryBytes, 0)
|
||||
const usedMemoryPct =
|
||||
totalMemoryBytes > 0
|
||||
|
||||
@@ -8,9 +8,7 @@ import { promisify } from 'node:util'
|
||||
import {
|
||||
CODEBASE_CONTEXT_PATH,
|
||||
CODEBASE_PATH,
|
||||
LEON_CONTEXT_DISABLED_FILES,
|
||||
NODE_RUNTIME_BIN_PATH,
|
||||
PROFILE_CONTEXT_PATH,
|
||||
TSX_CLI_PATH
|
||||
} from '@/constants'
|
||||
import { TOOLKIT_REGISTRY, LLM_PROVIDER } from '@/core'
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
DEFAULT_CONTEXT_REFRESH_TTL_MS
|
||||
} from '@/core/context-manager/context-file-factory'
|
||||
import { ContextProbeHelper } from '@/core/context-manager/context-probe-helper'
|
||||
import { CONFIG_MANAGER } from '@/config'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
interface ContextFileMetadata {
|
||||
lastGeneratedAt: number
|
||||
@@ -82,8 +82,7 @@ function clamp(value: number, min: number, max: number): number {
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export default class ContextManager {
|
||||
private static instance: ContextManager
|
||||
|
||||
private readonly profilePaths = getProfilePaths()
|
||||
private _isLoaded = false
|
||||
private manifest = ''
|
||||
private refreshIntervalId: NodeJS.Timeout | null = null
|
||||
@@ -103,10 +102,14 @@ export default class ContextManager {
|
||||
}
|
||||
)
|
||||
private readonly hasDisabledAllContextFiles =
|
||||
this.hasDisableAllContextFilesValue(LEON_CONTEXT_DISABLED_FILES)
|
||||
this.hasDisableAllContextFilesValue(
|
||||
CONFIG_MANAGER.getConfig().context.disabled_files
|
||||
)
|
||||
private readonly disabledContextFiles = this.hasDisabledAllContextFiles
|
||||
? new Set(this.allContextFiles.map((definition) => definition.filename))
|
||||
: this.parseContextFileList(LEON_CONTEXT_DISABLED_FILES)
|
||||
: this.parseContextFileList(
|
||||
CONFIG_MANAGER.getConfig().context.disabled_files
|
||||
)
|
||||
private readonly contextFiles: ContextFile[] = this.allContextFiles.filter(
|
||||
(definition) =>
|
||||
!this.hasDisabledAllContextFiles &&
|
||||
@@ -114,12 +117,8 @@ export default class ContextManager {
|
||||
)
|
||||
|
||||
public constructor() {
|
||||
if (!ContextManager.instance) {
|
||||
LogHelper.title('Context Manager')
|
||||
LogHelper.success('New instance')
|
||||
|
||||
ContextManager.instance = this
|
||||
}
|
||||
LogHelper.title('Context Manager')
|
||||
LogHelper.success(`New instance for profile ${this.profilePaths.name}`)
|
||||
}
|
||||
|
||||
public get isLoaded(): boolean {
|
||||
@@ -132,7 +131,7 @@ export default class ContextManager {
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(PROFILE_CONTEXT_PATH, { recursive: true })
|
||||
await fs.promises.mkdir(this.profilePaths.context, { recursive: true })
|
||||
await fs.promises.mkdir(CODEBASE_CONTEXT_PATH, { recursive: true })
|
||||
this.cleanupDisabledContextFiles()
|
||||
this.cleanupRetiredContextFiles()
|
||||
@@ -294,11 +293,11 @@ export default class ContextManager {
|
||||
return path.join(CODEBASE_CONTEXT_PATH, filename)
|
||||
}
|
||||
|
||||
return path.join(PROFILE_CONTEXT_PATH, filename)
|
||||
return path.join(this.profilePaths.context, filename)
|
||||
}
|
||||
|
||||
private getProfileContextFilePath(filename: string): string {
|
||||
return path.join(PROFILE_CONTEXT_PATH, filename)
|
||||
return path.join(this.profilePaths.context, filename)
|
||||
}
|
||||
|
||||
private normalizeFilename(filename: string): string {
|
||||
@@ -384,8 +383,8 @@ export default class ContextManager {
|
||||
private resolveContextSourcePath(definition: ContextFile): string | null {
|
||||
const sourceBasename = this.getContextSourceBasename(definition.filename)
|
||||
const sourceDirectories = [
|
||||
CONTEXT_FILES_SOURCE_DIR,
|
||||
CONTEXT_FILES_RUNTIME_DIR
|
||||
CONTEXT_FILES_RUNTIME_DIR,
|
||||
CONTEXT_FILES_SOURCE_DIR
|
||||
]
|
||||
|
||||
for (const sourceDirectory of sourceDirectories) {
|
||||
@@ -484,6 +483,10 @@ export default class ContextManager {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(NODE_RUNTIME_BIN_PATH, workerArgs, {
|
||||
cwd: CODEBASE_PATH,
|
||||
env: {
|
||||
...process.env,
|
||||
LEON_PROFILE: this.profilePaths.name
|
||||
},
|
||||
maxBuffer: CONTEXT_REFRESH_WORKER_MAX_BUFFER,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_CONTEXT_PATH } from '@/constants'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
export class ContextStateStore<T> {
|
||||
private readonly stateFilePath: string
|
||||
private readonly contextPath: string
|
||||
|
||||
public constructor(stateFilename: string, private readonly fallback: T) {
|
||||
this.stateFilePath = path.join(PROFILE_CONTEXT_PATH, stateFilename)
|
||||
this.contextPath = getProfilePaths().context
|
||||
this.stateFilePath = path.join(this.contextPath, stateFilename)
|
||||
}
|
||||
|
||||
public load(): T {
|
||||
@@ -25,7 +27,7 @@ export class ContextStateStore<T> {
|
||||
|
||||
public save(state: T): void {
|
||||
try {
|
||||
fs.mkdirSync(PROFILE_CONTEXT_PATH, { recursive: true })
|
||||
fs.mkdirSync(this.contextPath, { recursive: true })
|
||||
fs.writeFileSync(this.stateFilePath, JSON.stringify(state, null, 2), 'utf8')
|
||||
} catch {
|
||||
// Ignore state persistence failures.
|
||||
|
||||
@@ -3,10 +3,10 @@ import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildOwnerDocument,
|
||||
getOwnerContextPath,
|
||||
getOwnerProfilePath,
|
||||
getOwnerProfileLineCount,
|
||||
normalizeOwnerProfile,
|
||||
OWNER_CONTEXT_PATH,
|
||||
OWNER_PROFILE_PATH,
|
||||
parseOwnerDocument,
|
||||
readOwnerDocumentSync,
|
||||
readOwnerProfileSync,
|
||||
@@ -625,8 +625,8 @@ async function writeOwnerArtifacts(
|
||||
nextProfile
|
||||
)
|
||||
const contextChanged =
|
||||
!documentProfilesEqual || !fs.existsSync(OWNER_CONTEXT_PATH)
|
||||
const profileChanged = !profilesEqual || !fs.existsSync(OWNER_PROFILE_PATH)
|
||||
!documentProfilesEqual || !fs.existsSync(getOwnerContextPath())
|
||||
const profileChanged = !profilesEqual || !fs.existsSync(getOwnerProfilePath())
|
||||
|
||||
if (!profileChanged && !contextChanged) {
|
||||
return {
|
||||
@@ -636,8 +636,10 @@ async function writeOwnerArtifacts(
|
||||
}
|
||||
|
||||
if (contextChanged) {
|
||||
await fs.promises.mkdir(path.dirname(OWNER_CONTEXT_PATH), { recursive: true })
|
||||
await fs.promises.writeFile(OWNER_CONTEXT_PATH, `${nextDocument}\n`, 'utf8')
|
||||
const ownerContextPath = getOwnerContextPath()
|
||||
|
||||
await fs.promises.mkdir(path.dirname(ownerContextPath), { recursive: true })
|
||||
await fs.promises.writeFile(ownerContextPath, `${nextDocument}\n`, 'utf8')
|
||||
}
|
||||
await writeOwnerProfile(nextProfile)
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_CONTEXT_PATH, SKILLS_PATH } from '@/constants'
|
||||
import { SKILLS_PATH } from '@/constants'
|
||||
import { DateHelper } from '@/helpers/date-helper'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
export const OWNER_CONTEXT_PATH = path.join(PROFILE_CONTEXT_PATH, 'OWNER.md')
|
||||
export const OWNER_PROFILE_PATH = path.join(
|
||||
PROFILE_CONTEXT_PATH,
|
||||
'.owner-profile.json'
|
||||
)
|
||||
const LEGACY_OWNER_PROFILE_PATH = path.join(
|
||||
PROFILE_CONTEXT_PATH,
|
||||
'private',
|
||||
'.owner-profile.json'
|
||||
)
|
||||
export const getOwnerContextPath = (): string =>
|
||||
path.join(getProfilePaths().context, 'OWNER.md')
|
||||
export const getOwnerProfilePath = (): string =>
|
||||
path.join(getProfilePaths().context, '.owner-profile.json')
|
||||
const getLegacyOwnerProfilePath = (): string =>
|
||||
path.join(getProfilePaths().context, 'private', '.owner-profile.json')
|
||||
const LEGACY_OWNER_MEMORY_PATH = path.join(
|
||||
SKILLS_PATH,
|
||||
'leon',
|
||||
@@ -105,91 +102,6 @@ export const OWNER_PROFILE_SECTIONS: Array<{
|
||||
}
|
||||
]
|
||||
|
||||
export const OWNER_PROFILE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
owner_first_name: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_last_name: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_full_name: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_birth_date: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_current_city: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_current_country: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_nationality: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_current_company: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
owner_current_role: {
|
||||
type: ['string', 'null']
|
||||
},
|
||||
identity: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
homeAndImportantPlaces: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
familyAndRelationships: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
background: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
preferences: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
workAndCareer: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
interactionPreferences: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
importantDates: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: [
|
||||
'owner_first_name',
|
||||
'owner_last_name',
|
||||
'owner_full_name',
|
||||
'owner_birth_date',
|
||||
'owner_current_city',
|
||||
'owner_current_country',
|
||||
'owner_nationality',
|
||||
'owner_current_company',
|
||||
'owner_current_role',
|
||||
'identity',
|
||||
'homeAndImportantPlaces',
|
||||
'familyAndRelationships',
|
||||
'background',
|
||||
'preferences',
|
||||
'workAndCareer',
|
||||
'interactionPreferences',
|
||||
'importantDates'
|
||||
],
|
||||
additionalProperties: false
|
||||
} as const
|
||||
|
||||
export function createEmptyOwnerProfile(): OwnerProfile {
|
||||
return {
|
||||
updatedAt: null,
|
||||
@@ -276,10 +188,12 @@ export function normalizeOwnerProfile(value: unknown): OwnerProfile {
|
||||
}
|
||||
|
||||
function readOwnerProfileCacheSync(): OwnerProfile {
|
||||
const candidatePath = fs.existsSync(OWNER_PROFILE_PATH)
|
||||
? OWNER_PROFILE_PATH
|
||||
: fs.existsSync(LEGACY_OWNER_PROFILE_PATH)
|
||||
? LEGACY_OWNER_PROFILE_PATH
|
||||
const ownerProfilePath = getOwnerProfilePath()
|
||||
const legacyOwnerProfilePath = getLegacyOwnerProfilePath()
|
||||
const candidatePath = fs.existsSync(ownerProfilePath)
|
||||
? ownerProfilePath
|
||||
: fs.existsSync(legacyOwnerProfilePath)
|
||||
? legacyOwnerProfilePath
|
||||
: ''
|
||||
|
||||
if (!candidatePath) {
|
||||
@@ -349,9 +263,11 @@ export function parseOwnerDocument(content: string): OwnerProfile {
|
||||
}
|
||||
|
||||
export function readOwnerDocumentSync(): string {
|
||||
if (fs.existsSync(OWNER_CONTEXT_PATH)) {
|
||||
const ownerContextPath = getOwnerContextPath()
|
||||
|
||||
if (fs.existsSync(ownerContextPath)) {
|
||||
try {
|
||||
return fs.readFileSync(OWNER_CONTEXT_PATH, 'utf8')
|
||||
return fs.readFileSync(ownerContextPath, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
@@ -382,18 +298,21 @@ export function readOwnerProfileSync(): OwnerProfile {
|
||||
}
|
||||
|
||||
export async function writeOwnerProfile(profile: OwnerProfile): Promise<void> {
|
||||
await fs.promises.mkdir(path.dirname(OWNER_PROFILE_PATH), { recursive: true })
|
||||
const ownerProfilePath = getOwnerProfilePath()
|
||||
const legacyOwnerProfilePath = getLegacyOwnerProfilePath()
|
||||
|
||||
await fs.promises.mkdir(path.dirname(ownerProfilePath), { recursive: true })
|
||||
await fs.promises.writeFile(
|
||||
OWNER_PROFILE_PATH,
|
||||
ownerProfilePath,
|
||||
`${JSON.stringify(normalizeOwnerProfile(profile), null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
if (
|
||||
LEGACY_OWNER_PROFILE_PATH !== OWNER_PROFILE_PATH &&
|
||||
fs.existsSync(LEGACY_OWNER_PROFILE_PATH)
|
||||
legacyOwnerProfilePath !== ownerProfilePath &&
|
||||
fs.existsSync(legacyOwnerProfilePath)
|
||||
) {
|
||||
await fs.promises.rm(LEGACY_OWNER_PROFILE_PATH, { force: true })
|
||||
await fs.promises.rm(legacyOwnerProfilePath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,13 +137,15 @@ export const postCommand: FastifyPluginAsync<APIOptions> = async (
|
||||
const activeSessionId =
|
||||
conversationSessionId ||
|
||||
CONVERSATION_SESSION_MANAGER.getActiveSessionId()
|
||||
const data = await CONVERSATION_SESSION_MANAGER.runWithSession(
|
||||
activeSessionId,
|
||||
async () =>
|
||||
mode === 'autocomplete'
|
||||
? BUILT_IN_COMMAND_MANAGER.autocomplete(input, sessionId)
|
||||
: await BUILT_IN_COMMAND_MANAGER.execute(input, sessionId)
|
||||
)
|
||||
// Autocomplete is read-only and must stay responsive while an agent turn
|
||||
// holds the conversation session queue. Executions remain ordered.
|
||||
const data =
|
||||
mode === 'autocomplete'
|
||||
? BUILT_IN_COMMAND_MANAGER.autocomplete(input, sessionId)
|
||||
: await CONVERSATION_SESSION_MANAGER.runWithSession(
|
||||
activeSessionId,
|
||||
() => BUILT_IN_COMMAND_MANAGER.execute(input, sessionId)
|
||||
)
|
||||
|
||||
await refreshLLMRuntimeIfModelCommand({
|
||||
mode,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
import type { APIOptions } from '@/core/http-server/http-server'
|
||||
|
||||
import extensionFiles from './post'
|
||||
|
||||
export const extensionFilesPlugin: FastifyPluginAsync<APIOptions> = async (
|
||||
fastify,
|
||||
options
|
||||
) => {
|
||||
await fastify.register(extensionFiles, options)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { FastifyPluginAsync, FastifySchema } from 'fastify'
|
||||
import { Type } from '@sinclair/typebox'
|
||||
import type { Static } from '@sinclair/typebox'
|
||||
import jq from 'node-jq'
|
||||
import type { Json as NodeJQJson } from 'node-jq/lib/options'
|
||||
|
||||
import type { APIOptions } from '@/core/http-server/http-server'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
import { JsonRedactionHelper } from '@/helpers/json-redaction-helper'
|
||||
|
||||
const SETTINGS_FILE_NAME = 'settings.json'
|
||||
const MEMORY_FOLDER_NAME = 'memory'
|
||||
|
||||
const postExtensionFileReadSchema = {
|
||||
body: Type.Object({
|
||||
owner_type: Type.Union([Type.Literal('skill'), Type.Literal('tool')]),
|
||||
owner_id: Type.String(),
|
||||
file_type: Type.Union([Type.Literal('settings'), Type.Literal('memory')]),
|
||||
skill_type: Type.Optional(
|
||||
Type.Union([Type.Literal('native'), Type.Literal('agent')])
|
||||
),
|
||||
file_name: Type.Optional(Type.String()),
|
||||
jq: Type.Optional(Type.String())
|
||||
})
|
||||
} satisfies FastifySchema
|
||||
|
||||
interface PostExtensionFileReadSchema {
|
||||
body: Static<typeof postExtensionFileReadSchema.body>
|
||||
}
|
||||
|
||||
function getSafePathSegments(value: string): string[] | null {
|
||||
const segments = value.split(path.posix.sep)
|
||||
|
||||
if (
|
||||
segments.length === 0 ||
|
||||
segments.some(
|
||||
(segment) =>
|
||||
!segment ||
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.includes(path.win32.sep)
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function isJsonFileName(value: string): boolean {
|
||||
return (
|
||||
path.basename(value) === value &&
|
||||
path.win32.basename(value) === value &&
|
||||
value.endsWith('.json')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveProfileJsonPath(
|
||||
body: PostExtensionFileReadSchema['body']
|
||||
): string | null {
|
||||
const ownerSegments = getSafePathSegments(body.owner_id)
|
||||
|
||||
if (!ownerSegments) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (body.owner_type === 'tool') {
|
||||
if (body.file_type !== 'settings') {
|
||||
return null
|
||||
}
|
||||
|
||||
return path.join(
|
||||
getProfilePaths().tools,
|
||||
...ownerSegments,
|
||||
SETTINGS_FILE_NAME
|
||||
)
|
||||
}
|
||||
|
||||
const profilePaths = getProfilePaths()
|
||||
const skillBasePath =
|
||||
body.skill_type === 'agent'
|
||||
? profilePaths.agentSkills
|
||||
: profilePaths.nativeSkills
|
||||
const skillPath = path.join(skillBasePath, ...ownerSegments)
|
||||
|
||||
if (body.file_type === 'settings') {
|
||||
return path.join(skillPath, SETTINGS_FILE_NAME)
|
||||
}
|
||||
|
||||
if (!body.file_name || !isJsonFileName(body.file_name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return path.join(skillPath, MEMORY_FOLDER_NAME, body.file_name)
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
return JSON.parse(await fs.promises.readFile(filePath, 'utf8')) as unknown
|
||||
}
|
||||
|
||||
export const postExtensionFileRead: FastifyPluginAsync<APIOptions> = async (
|
||||
fastify,
|
||||
options
|
||||
) => {
|
||||
fastify.route<{
|
||||
Body: PostExtensionFileReadSchema['body']
|
||||
}>({
|
||||
method: 'POST',
|
||||
url: `/api/${options.apiVersion}/extension-files/read`,
|
||||
schema: postExtensionFileReadSchema,
|
||||
handler: async (request, reply) => {
|
||||
const filePath = resolveProfileJsonPath(request.body)
|
||||
|
||||
if (!filePath) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: 'Invalid extension file target.'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const redactedJson = JsonRedactionHelper.redactSensitiveValues(
|
||||
await readJsonFile(filePath)
|
||||
)
|
||||
const filter = request.body.jq?.trim()
|
||||
const data = filter
|
||||
? await jq.run(filter, redactedJson as NodeJQJson, {
|
||||
input: 'json',
|
||||
output: 'json'
|
||||
})
|
||||
: redactedJson
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: 'Extension file not found.'
|
||||
})
|
||||
}
|
||||
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default postExtensionFileRead
|
||||
@@ -53,40 +53,38 @@ export const fetchWidget: FastifyPluginAsync<APIOptions> = async (
|
||||
})
|
||||
}
|
||||
|
||||
// Do not return any speech and new widget
|
||||
BRAIN.isMuted = true
|
||||
|
||||
await NLUProcessResultUpdater.update({
|
||||
skillName: skill
|
||||
})
|
||||
await NLUProcessResultUpdater.update({
|
||||
actionName: action
|
||||
})
|
||||
await NLUProcessResultUpdater.update({
|
||||
new: {
|
||||
entities: [
|
||||
{
|
||||
start: 0,
|
||||
end: widgetId.length - 1,
|
||||
len: widgetId.length,
|
||||
levenshtein: 0,
|
||||
accuracy: 1,
|
||||
entity: 'widgetid',
|
||||
type: 'enum',
|
||||
option: widgetId,
|
||||
sourceText: widgetId,
|
||||
utteranceText: widgetId,
|
||||
resolution: {
|
||||
value: widgetId
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const processedData = await CONVERSATION_SESSION_MANAGER.runWithSession(
|
||||
sessionId || CONVERSATION_SESSION_MANAGER.getActiveSessionId(),
|
||||
() => BRAIN.runSkillAction(NLU.nluProcessResult)
|
||||
async () => {
|
||||
// Do not return any speech and new widget.
|
||||
BRAIN.isMuted = true
|
||||
|
||||
await NLUProcessResultUpdater.update({ skillName: skill })
|
||||
await NLUProcessResultUpdater.update({ actionName: action })
|
||||
await NLUProcessResultUpdater.update({
|
||||
new: {
|
||||
entities: [
|
||||
{
|
||||
start: 0,
|
||||
end: widgetId.length - 1,
|
||||
len: widgetId.length,
|
||||
levenshtein: 0,
|
||||
accuracy: 1,
|
||||
entity: 'widgetid',
|
||||
type: 'enum',
|
||||
option: widgetId,
|
||||
sourceText: widgetId,
|
||||
utteranceText: widgetId,
|
||||
resolution: {
|
||||
value: widgetId
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return BRAIN.runSkillAction(NLU.nluProcessResult)
|
||||
}
|
||||
)
|
||||
|
||||
console.log('processedData', processedData)
|
||||
|
||||
@@ -3,14 +3,9 @@ import type { FastifyPluginAsync } from 'fastify'
|
||||
import type { APIOptions } from '@/core/http-server/http-server'
|
||||
import {
|
||||
LEON_VERSION,
|
||||
HAS_AFTER_SPEECH,
|
||||
HAS_ASR,
|
||||
HAS_TTS,
|
||||
ASR_PROVIDER,
|
||||
TTS_PROVIDER,
|
||||
IS_TELEMETRY_ENABLED,
|
||||
SHOULD_START_PYTHON_TCP_SERVER
|
||||
} from '@/constants'
|
||||
import { CONFIG_MANAGER } from '@/config'
|
||||
import { PERSONA } from '@/core'
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
import { DateHelper } from '@/helpers/date-helper'
|
||||
@@ -47,6 +42,7 @@ export const getInfo: FastifyPluginAsync<APIOptions> = async (
|
||||
SystemHelper.getUsedVRAM()
|
||||
])
|
||||
const moodState = CONFIG_STATE.getMoodState()
|
||||
const profileConfig = CONFIG_MANAGER.getConfig()
|
||||
const modelState = CONFIG_STATE.getModelState()
|
||||
const routingMode = CONFIG_STATE.getRoutingModeState().getRoutingMode()
|
||||
const workflowTarget = modelState.getWorkflowTarget()
|
||||
@@ -67,8 +63,8 @@ export const getInfo: FastifyPluginAsync<APIOptions> = async (
|
||||
status: 200,
|
||||
code: 'info_pulled',
|
||||
message,
|
||||
after_speech: HAS_AFTER_SPEECH,
|
||||
telemetry: IS_TELEMETRY_ENABLED,
|
||||
after_speech: profileConfig.after_speech_enabled,
|
||||
telemetry: profileConfig.telemetry_enabled,
|
||||
timeZone: DateHelper.getTimeZone(),
|
||||
gpu: gpuDeviceNames[0],
|
||||
graphicsComputeAPI,
|
||||
@@ -92,12 +88,12 @@ export const getInfo: FastifyPluginAsync<APIOptions> = async (
|
||||
localModel: modelState.getLocalModelName()
|
||||
},
|
||||
asr: {
|
||||
enabled: HAS_ASR,
|
||||
provider: ASR_PROVIDER
|
||||
enabled: profileConfig.voice.asr.enabled,
|
||||
provider: profileConfig.voice.asr.provider
|
||||
},
|
||||
tts: {
|
||||
enabled: HAS_TTS,
|
||||
provider: TTS_PROVIDER
|
||||
enabled: profileConfig.voice.tts.enabled,
|
||||
provider: profileConfig.voice.tts.provider
|
||||
},
|
||||
routingMode,
|
||||
tcpServer: {
|
||||
|
||||
@@ -53,29 +53,27 @@ export const runAction: FastifyPluginAsync<APIOptions> = async (
|
||||
})
|
||||
}
|
||||
|
||||
await NLUProcessResultUpdater.update({
|
||||
skillName: skill
|
||||
})
|
||||
await NLUProcessResultUpdater.update({
|
||||
actionName: action
|
||||
})
|
||||
await NLUProcessResultUpdater.update({
|
||||
new: {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
actionArguments: actionParams.action_arguments,
|
||||
...actionParams
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure we can send response from the brain
|
||||
BRAIN.isMuted = false
|
||||
|
||||
const processedData = await CONVERSATION_SESSION_MANAGER.runWithSession(
|
||||
typeof sessionId === 'string'
|
||||
? sessionId
|
||||
: CONVERSATION_SESSION_MANAGER.getActiveSessionId(),
|
||||
() => BRAIN.runSkillAction(NLU.nluProcessResult)
|
||||
async () => {
|
||||
await NLUProcessResultUpdater.update({ skillName: skill })
|
||||
await NLUProcessResultUpdater.update({ actionName: action })
|
||||
await NLUProcessResultUpdater.update({
|
||||
new: {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
actionArguments: actionParams.action_arguments,
|
||||
...actionParams
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure we can send response from the brain.
|
||||
BRAIN.isMuted = false
|
||||
|
||||
return BRAIN.runSkillAction(NLU.nluProcessResult)
|
||||
}
|
||||
)
|
||||
|
||||
if (processedData.lastOutputFromSkill) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
import { postUtterance } from '@/core/http-server/api/utterance/post'
|
||||
import type { APIOptions } from '@/core/http-server/http-server'
|
||||
|
||||
export const utterancePlugin: FastifyPluginAsync<APIOptions> = async (
|
||||
fastify,
|
||||
options
|
||||
) => {
|
||||
await fastify.register(postUtterance, options)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { FastifyPluginAsync, FastifySchema } from 'fastify'
|
||||
import { Type } from '@sinclair/typebox'
|
||||
import type { Static } from '@sinclair/typebox'
|
||||
|
||||
import { NLU, BRAIN } from '@/core'
|
||||
import type { APIOptions } from '@/core/http-server/http-server'
|
||||
|
||||
const postUtteranceSchema = {
|
||||
body: Type.Object({
|
||||
utterance: Type.String()
|
||||
})
|
||||
} satisfies FastifySchema
|
||||
|
||||
interface PostUtteranceSchema {
|
||||
body: Static<typeof postUtteranceSchema.body>
|
||||
}
|
||||
|
||||
export const postUtterance: FastifyPluginAsync<APIOptions> = async (
|
||||
fastify,
|
||||
options
|
||||
) => {
|
||||
fastify.route<{
|
||||
Body: PostUtteranceSchema['body']
|
||||
}>({
|
||||
method: 'POST',
|
||||
url: `/api/${options.apiVersion}/utterance`,
|
||||
schema: postUtteranceSchema,
|
||||
handler: async (request, reply) => {
|
||||
const { utterance } = request.body
|
||||
|
||||
try {
|
||||
BRAIN.isMuted = true
|
||||
const data = await NLU.process(utterance)
|
||||
|
||||
reply.send({
|
||||
...data,
|
||||
success: true
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
reply.statusCode = 500
|
||||
reply.send({
|
||||
message,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { HTTPPluginReply, HTTPPluginRequest } from './types'
|
||||
import {
|
||||
authenticateProfileCredential,
|
||||
readProfileCredentialFromHeaders
|
||||
} from '@/core/profile-auth'
|
||||
import { enterProfileContext } from '@/core/profile-runtime/profile-context'
|
||||
|
||||
/** Checks the supported authentication headers for a dynamically loaded HTTP plugin. */
|
||||
export function isHTTPPluginRequestAuthorized(
|
||||
request: HTTPPluginRequest
|
||||
): boolean {
|
||||
const credential = readProfileCredentialFromHeaders({
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
'x-leon-profile-token': String(
|
||||
request.headers['x-leon-profile-token'] || ''
|
||||
)
|
||||
})
|
||||
|
||||
const authenticatedCredential = authenticateProfileCredential(credential)
|
||||
|
||||
if (!authenticatedCredential) {
|
||||
return false
|
||||
}
|
||||
|
||||
enterProfileContext({ profileName: authenticatedCredential.profileName })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Sends the standard unauthorized response from a dynamically loaded HTTP plugin. */
|
||||
export function rejectUnauthorizedHTTPPluginRequest(
|
||||
reply: HTTPPluginReply
|
||||
): HTTPPluginReply {
|
||||
reply.statusCode = 401
|
||||
|
||||
return reply.send({
|
||||
success: false,
|
||||
status: 401,
|
||||
code: 'profile_unauthorized',
|
||||
message: 'Leon profile token is missing or invalid.'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { LLM_MANAGER } from '@/core'
|
||||
import type { LLMDutyResult } from '@/core/llm-manager/llm-duty'
|
||||
import { ReActLLMDuty } from '@/core/llm-manager/llm-duties/react-llm-duty'
|
||||
import { getActiveProfileName } from '@/core/profile-runtime/profile-context'
|
||||
import { ensureActiveProfileRuntime } from '@/core/profile-runtime/initialize-profile-runtime'
|
||||
import { CONVERSATION_SESSION_MANAGER } from '@/core/session-manager'
|
||||
|
||||
import type {
|
||||
HTTPPluginLeonServices,
|
||||
HTTPPluginRunAgentInput,
|
||||
HTTPPluginRunAgentResult,
|
||||
HTTPPluginToolCall
|
||||
} from './types'
|
||||
|
||||
function getStringField(record: Record<string, unknown>, key: string): string {
|
||||
return typeof record[key] === 'string' ? record[key].trim() : ''
|
||||
}
|
||||
|
||||
function normalizeToolCalls(result: LLMDutyResult | null): HTTPPluginToolCall[] {
|
||||
const data = result?.data || {}
|
||||
const executionHistory = Array.isArray(data['executionHistory'])
|
||||
? data['executionHistory']
|
||||
: []
|
||||
|
||||
return executionHistory
|
||||
.map((item): HTTPPluginToolCall | null => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>
|
||||
const name = getStringField(record, 'function')
|
||||
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toolCall: HTTPPluginToolCall = {
|
||||
name,
|
||||
status: record['status'] === 'error' ? 'error' : 'success'
|
||||
}
|
||||
const observation = getStringField(record, 'observation')
|
||||
const stepLabel = getStringField(record, 'stepLabel')
|
||||
|
||||
if (observation) {
|
||||
toolCall.observation = observation
|
||||
}
|
||||
if (stepLabel) {
|
||||
toolCall.step_label = stepLabel
|
||||
}
|
||||
if (record['requestedToolInput'] !== undefined) {
|
||||
toolCall.input = record['requestedToolInput']
|
||||
}
|
||||
|
||||
return toolCall
|
||||
})
|
||||
.filter((item): item is HTTPPluginToolCall => item !== null)
|
||||
}
|
||||
|
||||
async function runAgent(
|
||||
input: HTTPPluginRunAgentInput
|
||||
): Promise<HTTPPluginRunAgentResult> {
|
||||
await ensureActiveProfileRuntime()
|
||||
const requestedSession = input.session_id
|
||||
? CONVERSATION_SESSION_MANAGER.getSession(input.session_id)
|
||||
: null
|
||||
const sessionId = requestedSession?.id ||
|
||||
CONVERSATION_SESSION_MANAGER.getActiveSessionId()
|
||||
const result = await CONVERSATION_SESSION_MANAGER.runWithSession(
|
||||
sessionId,
|
||||
async () => {
|
||||
const duty = new ReActLLMDuty({
|
||||
input: input.query.trim()
|
||||
})
|
||||
|
||||
await duty.init()
|
||||
return duty.execute()
|
||||
}
|
||||
)
|
||||
const data = result?.data || {}
|
||||
const output = result?.output as unknown
|
||||
|
||||
return {
|
||||
answer: typeof output === 'string' ? output : '',
|
||||
tier: 'leon-react',
|
||||
tool_calls: normalizeToolCalls(result),
|
||||
profile_id: getActiveProfileName(),
|
||||
session_id: sessionId,
|
||||
request_id: input.request_id || null,
|
||||
final_intent:
|
||||
typeof data['finalIntent'] === 'string' ? data['finalIntent'] : null,
|
||||
metrics: data['llmMetrics'] || null
|
||||
}
|
||||
}
|
||||
|
||||
export function createHTTPPluginLeonServices(): HTTPPluginLeonServices {
|
||||
return {
|
||||
get profileId(): string {
|
||||
return getActiveProfileName()
|
||||
},
|
||||
isLLMEnabled: () => LLM_MANAGER.isLLMEnabled,
|
||||
runAgent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import YAML from 'yaml'
|
||||
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
import { LEON_HOME_PATH } from '@/leon-roots'
|
||||
import { getProfilePaths } from '@/core/profile-runtime/profile-paths'
|
||||
|
||||
import type {
|
||||
DiscoveredHTTPPluginDefinition,
|
||||
HTTPPluginDefinition,
|
||||
HTTPPluginManifest,
|
||||
HTTPPluginSourceScope
|
||||
} from './types'
|
||||
|
||||
const HTTP_PLUGINS_DIRECTORY_NAME = 'http-plugins'
|
||||
const MANIFEST_FILENAME = 'plugin.yml'
|
||||
|
||||
export interface HTTPPluginSearchRoot {
|
||||
scope: Extract<HTTPPluginSourceScope, 'global' | 'profile'>
|
||||
directory: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function normalizePluginId(pluginId: string): string {
|
||||
return pluginId.trim().replaceAll('_', '-')
|
||||
}
|
||||
|
||||
function isHTTPPluginDefinition(value: unknown): value is HTTPPluginDefinition {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
Boolean(asString(value['id'])) &&
|
||||
Boolean(asString(value['name'])) &&
|
||||
Boolean(asString(value['version'])) &&
|
||||
Boolean(asString(value['description'])) &&
|
||||
typeof value['register'] === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
function parseManifest(raw: string): HTTPPluginManifest {
|
||||
const parsed = YAML.parse(raw)
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Plugin manifest must be an object.')
|
||||
}
|
||||
|
||||
const id = asString(parsed['id'])
|
||||
if (!id) {
|
||||
throw new Error('Plugin manifest must define a non-empty id.')
|
||||
}
|
||||
|
||||
const manifest: HTTPPluginManifest = { id }
|
||||
const name = asString(parsed['name'])
|
||||
const version = asString(parsed['version'])
|
||||
const description = asString(parsed['description'])
|
||||
const entry = asString(parsed['entry'])
|
||||
|
||||
if (name) {
|
||||
manifest.name = name
|
||||
}
|
||||
if (version) {
|
||||
manifest.version = version
|
||||
}
|
||||
if (description) {
|
||||
manifest.description = description
|
||||
}
|
||||
if (entry) {
|
||||
manifest.entry = entry
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
function findManifestPath(pluginDirectory: string): string {
|
||||
const manifestPath = path.join(pluginDirectory, MANIFEST_FILENAME)
|
||||
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
return manifestPath
|
||||
}
|
||||
|
||||
throw new Error(`Missing plugin manifest (${MANIFEST_FILENAME}).`)
|
||||
}
|
||||
|
||||
function resolveEntryPath(pluginDirectory: string, manifest: HTTPPluginManifest): string {
|
||||
const entry = manifest.entry || 'index.js'
|
||||
const entryPath = path.resolve(pluginDirectory, entry)
|
||||
const relativeEntryPath = path.relative(pluginDirectory, entryPath)
|
||||
|
||||
if (
|
||||
relativeEntryPath.startsWith('..') ||
|
||||
path.isAbsolute(relativeEntryPath)
|
||||
) {
|
||||
throw new Error('Plugin entry must stay inside the plugin directory.')
|
||||
}
|
||||
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
throw new Error(`Plugin entry does not exist: ${entry}`)
|
||||
}
|
||||
|
||||
return entryPath
|
||||
}
|
||||
|
||||
async function loadHTTPPluginFromDirectory(
|
||||
pluginDirectory: string,
|
||||
source: HTTPPluginSearchRoot['scope']
|
||||
): Promise<DiscoveredHTTPPluginDefinition> {
|
||||
const manifestPath = findManifestPath(pluginDirectory)
|
||||
const manifest = parseManifest(await fs.promises.readFile(manifestPath, 'utf8'))
|
||||
const entryPath = resolveEntryPath(pluginDirectory, manifest)
|
||||
const module = await import(pathToFileURL(entryPath).href)
|
||||
const exportedDefinition = module.default || module.httpPlugin || module.plugin
|
||||
|
||||
if (!isHTTPPluginDefinition(exportedDefinition)) {
|
||||
throw new Error(
|
||||
'Plugin entry must export a default HTTPPluginDefinition, httpPlugin, or plugin.'
|
||||
)
|
||||
}
|
||||
|
||||
const definition: HTTPPluginDefinition = {
|
||||
...exportedDefinition,
|
||||
id: normalizePluginId(manifest.id),
|
||||
name: manifest.name || exportedDefinition.name,
|
||||
version: manifest.version || exportedDefinition.version,
|
||||
description: manifest.description || exportedDefinition.description
|
||||
}
|
||||
|
||||
return {
|
||||
definition,
|
||||
source,
|
||||
path: pluginDirectory
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultHTTPPluginSearchRoots(): HTTPPluginSearchRoot[] {
|
||||
return [
|
||||
{
|
||||
scope: 'global',
|
||||
directory: path.join(LEON_HOME_PATH, HTTP_PLUGINS_DIRECTORY_NAME)
|
||||
},
|
||||
{
|
||||
scope: 'profile',
|
||||
directory: path.join(getProfilePaths().root, HTTP_PLUGINS_DIRECTORY_NAME)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export async function loadExternalHTTPPlugins(
|
||||
searchRoots: HTTPPluginSearchRoot[] = getDefaultHTTPPluginSearchRoots()
|
||||
): Promise<DiscoveredHTTPPluginDefinition[]> {
|
||||
const pluginsById = new Map<string, DiscoveredHTTPPluginDefinition>()
|
||||
|
||||
for (const root of searchRoots) {
|
||||
if (!fs.existsSync(root.directory)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(root.directory, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pluginDirectory = path.join(root.directory, entry.name)
|
||||
|
||||
try {
|
||||
const plugin = await loadHTTPPluginFromDirectory(
|
||||
pluginDirectory,
|
||||
root.scope
|
||||
)
|
||||
|
||||
pluginsById.set(plugin.definition.id, plugin)
|
||||
} catch (error) {
|
||||
LogHelper.title('HTTP Plugins')
|
||||
LogHelper.warning(
|
||||
`Failed to load HTTP plugin at "${pluginDirectory}": ${String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(pluginsById.values())
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
import type { APIOptions } from '@/core/http-server/http-server'
|
||||
import { CONFIG_MANAGER } from '@/config'
|
||||
|
||||
import { createHTTPPluginLeonServices } from './leon-services'
|
||||
import { loadExternalHTTPPlugins } from './loader'
|
||||
import type {
|
||||
DiscoveredHTTPPluginDefinition,
|
||||
HTTPPluginRuntimeConfig
|
||||
} from './types'
|
||||
|
||||
function normalizePluginId(pluginId: string): string {
|
||||
return pluginId.trim().replaceAll('_', '-')
|
||||
}
|
||||
|
||||
function resolveHTTPPluginRuntimeConfig(
|
||||
pluginId: string
|
||||
): HTTPPluginRuntimeConfig {
|
||||
const config = CONFIG_MANAGER.getConfig().http_plugins
|
||||
const normalizedPluginId = normalizePluginId(pluginId)
|
||||
const pluginConfigs = config.plugins as Record<
|
||||
string,
|
||||
{ enabled: boolean, root_routes: boolean } | undefined
|
||||
>
|
||||
const pluginConfig =
|
||||
pluginConfigs[pluginId] ||
|
||||
pluginConfigs[normalizedPluginId] ||
|
||||
pluginConfigs[normalizedPluginId.replaceAll('-', '_')]
|
||||
|
||||
return {
|
||||
enabled: config.enabled && Boolean(pluginConfig?.enabled),
|
||||
allowRootRoutes:
|
||||
config.allow_root_routes && Boolean(pluginConfig?.root_routes),
|
||||
auth: {
|
||||
enabled: config.auth.enabled,
|
||||
token: CONFIG_MANAGER.resolveSecretReference(config.auth.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class HTTPPluginManager {
|
||||
public constructor(
|
||||
private pluginDefinitions: DiscoveredHTTPPluginDefinition[] = []
|
||||
) {}
|
||||
|
||||
public async loadPlugins(): Promise<void> {
|
||||
const pluginsById = new Map<string, DiscoveredHTTPPluginDefinition>()
|
||||
|
||||
for (const plugin of await loadExternalHTTPPlugins()) {
|
||||
pluginsById.set(plugin.definition.id, plugin)
|
||||
}
|
||||
|
||||
this.pluginDefinitions = Array.from(pluginsById.values())
|
||||
}
|
||||
|
||||
public getPluginStates(): Array<{
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
source: string
|
||||
path?: string
|
||||
enabled: boolean
|
||||
root_routes: boolean
|
||||
}> {
|
||||
return this.pluginDefinitions.map((plugin) => {
|
||||
const runtimeConfig = resolveHTTPPluginRuntimeConfig(plugin.definition.id)
|
||||
const state = {
|
||||
id: plugin.definition.id,
|
||||
name: plugin.definition.name,
|
||||
version: plugin.definition.version,
|
||||
description: plugin.definition.description,
|
||||
source: plugin.source,
|
||||
enabled: runtimeConfig.enabled,
|
||||
root_routes: runtimeConfig.allowRootRoutes
|
||||
}
|
||||
|
||||
if (plugin.path) {
|
||||
return {
|
||||
...state,
|
||||
path: plugin.path
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
public async register(
|
||||
fastify: FastifyInstance,
|
||||
options: APIOptions
|
||||
): Promise<void> {
|
||||
this.registerStatusRoute(fastify, options)
|
||||
const leon = createHTTPPluginLeonServices()
|
||||
|
||||
for (const plugin of this.pluginDefinitions) {
|
||||
const runtimeConfig = resolveHTTPPluginRuntimeConfig(plugin.definition.id)
|
||||
|
||||
if (!runtimeConfig.enabled) {
|
||||
continue
|
||||
}
|
||||
|
||||
await plugin.definition.register(fastify, {
|
||||
...options,
|
||||
plugin: runtimeConfig,
|
||||
leon
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private registerStatusRoute(
|
||||
fastify: FastifyInstance,
|
||||
options: APIOptions
|
||||
): void {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: `/api/${options.apiVersion}/http-plugins`,
|
||||
handler: async (_request, reply) => {
|
||||
reply.send({
|
||||
success: true,
|
||||
status: 200,
|
||||
code: 'http_plugins_listed',
|
||||
message: 'HTTP plugins listed.',
|
||||
plugins: this.getPluginStates()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerHTTPPlugins(
|
||||
fastify: FastifyInstance,
|
||||
options: APIOptions
|
||||
): Promise<void> {
|
||||
const manager = new HTTPPluginManager()
|
||||
|
||||
await manager.loadPlugins()
|
||||
await manager.register(fastify, options)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
import type { APIOptions } from '@/core/http-server/http-server'
|
||||
|
||||
export interface HTTPPluginAuthConfig {
|
||||
enabled: boolean
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface HTTPPluginRuntimeConfig {
|
||||
enabled: boolean
|
||||
allowRootRoutes: boolean
|
||||
auth: HTTPPluginAuthConfig
|
||||
}
|
||||
|
||||
export interface HTTPPluginRunAgentInput {
|
||||
query: string
|
||||
session_id?: string
|
||||
request_id?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface HTTPPluginToolCall {
|
||||
name: string
|
||||
status: 'success' | 'error'
|
||||
observation?: string
|
||||
step_label?: string
|
||||
input?: unknown
|
||||
}
|
||||
|
||||
export interface HTTPPluginRunAgentResult {
|
||||
answer: string
|
||||
tier: 'leon-react'
|
||||
tool_calls: HTTPPluginToolCall[]
|
||||
profile_id: string
|
||||
session_id: string | null
|
||||
request_id: string | null
|
||||
final_intent: string | null
|
||||
metrics: unknown
|
||||
}
|
||||
|
||||
export interface HTTPPluginLeonServices {
|
||||
readonly profileId: string
|
||||
isLLMEnabled: () => boolean
|
||||
runAgent: (
|
||||
input: HTTPPluginRunAgentInput
|
||||
) => Promise<HTTPPluginRunAgentResult>
|
||||
}
|
||||
|
||||
export type HTTPPluginSourceScope = 'global' | 'profile'
|
||||
|
||||
export interface HTTPPluginRouteContext extends APIOptions {
|
||||
plugin: HTTPPluginRuntimeConfig
|
||||
leon: HTTPPluginLeonServices
|
||||
}
|
||||
|
||||
export interface HTTPPluginDefinition {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
register: (
|
||||
fastify: FastifyInstance,
|
||||
context: HTTPPluginRouteContext
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export interface HTTPPluginManifest {
|
||||
id: string
|
||||
name?: string
|
||||
version?: string
|
||||
description?: string
|
||||
entry?: string
|
||||
}
|
||||
|
||||
export interface DiscoveredHTTPPluginDefinition {
|
||||
definition: HTTPPluginDefinition
|
||||
source: HTTPPluginSourceScope
|
||||
path?: string
|
||||
}
|
||||
|
||||
export type HTTPPluginRequest = FastifyRequest
|
||||
export type HTTPPluginReply = FastifyReply
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
API_VERSION,
|
||||
LEON_VERSION,
|
||||
LEON_NODE_ENV,
|
||||
HAS_OVER_HTTP,
|
||||
IS_TELEMETRY_ENABLED,
|
||||
TMP_PATH
|
||||
} from '@/constants'
|
||||
@@ -26,10 +25,14 @@ import { conversationHistoryPlugin } from '@/core/http-server/api/conversation-h
|
||||
import { commandPlugin } from '@/core/http-server/api/command'
|
||||
import { systemWidgetsPlugin } from '@/core/http-server/api/system-widgets'
|
||||
import { sessionsPlugin } from '@/core/http-server/api/sessions'
|
||||
import { keyMidd } from '@/core/http-server/plugins/key'
|
||||
import { utterancePlugin } from '@/core/http-server/api/utterance'
|
||||
import { openPathPlugin } from '@/core/http-server/api/open-path'
|
||||
import { fileSystemListPlugin } from '@/core/http-server/api/file-system-list'
|
||||
import { extensionFilesPlugin } from '@/core/http-server/api/extension-files'
|
||||
import {
|
||||
authenticateProfileHTTPRequest,
|
||||
profileAuthRoutes
|
||||
} from '@/core/http-server/profile-auth-routes'
|
||||
import { registerHTTPPlugins } from '@/core/http-server/http-plugins/manager'
|
||||
import { PERSONA } from '@/core'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
import { getRoutingModeLLMDisplay } from '@/core/llm-manager/llm-routing'
|
||||
@@ -173,6 +176,18 @@ export default class HTTPServer {
|
||||
reply.sendFile('index.html')
|
||||
})
|
||||
|
||||
this.fastify.register(profileAuthRoutes, { apiVersion: API_VERSION })
|
||||
this.fastify.addHook('onRequest', async (request, reply) => {
|
||||
const requestPath = request.url.split('?')[0] || ''
|
||||
const authPath = `/api/${API_VERSION}/profile-auth`
|
||||
|
||||
if (!requestPath.startsWith(`/api/${API_VERSION}/`) || requestPath === authPath) {
|
||||
return
|
||||
}
|
||||
|
||||
await authenticateProfileHTTPRequest(request, reply)
|
||||
})
|
||||
|
||||
this.fastify.register(runActionPlugin, { apiVersion: API_VERSION })
|
||||
this.fastify.register(fetchWidgetPlugin, { apiVersion: API_VERSION })
|
||||
this.fastify.register(conversationHistoryPlugin, {
|
||||
@@ -185,19 +200,8 @@ export default class HTTPServer {
|
||||
this.fastify.register(inferencePlugin, { apiVersion: API_VERSION })
|
||||
this.fastify.register(openPathPlugin, { apiVersion: API_VERSION })
|
||||
this.fastify.register(fileSystemListPlugin, { apiVersion: API_VERSION })
|
||||
|
||||
if (HAS_OVER_HTTP) {
|
||||
this.fastify.register((instance, _opts, next) => {
|
||||
instance.addHook('preHandler', keyMidd)
|
||||
|
||||
instance.register(utterancePlugin, { apiVersion: API_VERSION })
|
||||
|
||||
// TODO: reimplement skills routes once the new core is ready
|
||||
// server.generateSkillsRoutes(instance)
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
||||
this.fastify.register(extensionFilesPlugin, { apiVersion: API_VERSION })
|
||||
await registerHTTPPlugins(this.fastify, { apiVersion: API_VERSION })
|
||||
|
||||
try {
|
||||
await this.listen()
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import type { onRequestHookHandler } from 'fastify'
|
||||
|
||||
import { HOST, IS_PRODUCTION_ENV, WEB_APP_DEV_SERVER_PORT } from '@/constants'
|
||||
import {
|
||||
CLIENT_INTERFACE_ALLOWED_ORIGINS,
|
||||
HOST,
|
||||
IS_PRODUCTION_ENV,
|
||||
WEB_APP_DEV_SERVER_PORT
|
||||
} from '@/constants'
|
||||
import {
|
||||
PROFILE_AUTHORIZATION_HEADER_NAME,
|
||||
PROFILE_TOKEN_HEADER_NAME
|
||||
} from '@/core/profile-auth'
|
||||
|
||||
const ALLOWED_REQUEST_HEADERS = [
|
||||
'Origin',
|
||||
'X-Requested-With',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
PROFILE_AUTHORIZATION_HEADER_NAME,
|
||||
PROFILE_TOKEN_HEADER_NAME
|
||||
].join(', ')
|
||||
|
||||
function getAllowedOrigins(): string[] {
|
||||
const origins = new Set(CLIENT_INTERFACE_ALLOWED_ORIGINS)
|
||||
|
||||
export const corsMidd: onRequestHookHandler = async (_request, reply) => {
|
||||
// Allow only a specific client to request to the API (depending on the env)
|
||||
if (!IS_PRODUCTION_ENV) {
|
||||
origins.add(`${HOST}:${WEB_APP_DEV_SERVER_PORT}`)
|
||||
}
|
||||
|
||||
return [...origins]
|
||||
}
|
||||
|
||||
export const corsMidd: onRequestHookHandler = async (request, reply) => {
|
||||
const origin = request.headers.origin
|
||||
const allowedOrigins = getAllowedOrigins()
|
||||
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
reply.header('Access-Control-Allow-Origin', origin)
|
||||
} else if (!IS_PRODUCTION_ENV && !origin) {
|
||||
reply.header(
|
||||
'Access-Control-Allow-Origin',
|
||||
`${HOST}:${WEB_APP_DEV_SERVER_PORT}`
|
||||
@@ -14,7 +46,7 @@ export const corsMidd: onRequestHookHandler = async (_request, reply) => {
|
||||
// Allow several headers for our requests
|
||||
reply.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept'
|
||||
ALLOWED_REQUEST_HEADERS
|
||||
)
|
||||
|
||||
reply.header('Access-Control-Allow-Credentials', true)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user