Compare commits

...

98 Commits

Author SHA1 Message Date
Louistiti 547c8d764a fix(tool/grok): use supported grok-4.3 model 2026-07-21 00:16:54 +08:00
louistiti 3677a82efb fix(scripts): reject corrupt native package installs 2026-07-19 23:51:02 +08:00
louistiti 00e23ae1ad build(agent mode): upgrade providers to AI SDK 7 2026-07-19 19:02:26 +08:00
louistiti 4823bffb37 feat: improved TypeScript 7 support; weekly fresh install workflow 2026-07-19 07:41:22 +08:00
louistiti bc2854c33f chore: merge feat/remote-multi-user-runtime 2026-07-18 08:55:00 +08:00
Louistiti 12749b8d03 chore: merge branch 'develop' into feat/remote-multi-user-runtime 2026-07-18 08:52:00 +08:00
Octopus e45e3d322b fix(provider/minimax): support endpoint variants (#580)
Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
Co-authored-by: Louis Grenard <louis.grenard@gmail.com>
2026-07-18 08:50:05 +08:00
Louistiti 9c465101f1 feat(scripts): add Kimi K3 as model suggestion on setup 2026-07-18 08:32:10 +08:00
louistiti 0e1cdc5641 chore: sync context files 2026-07-15 02:29:00 +08:00
louistiti 4e02224e33 fix(setup): honor tool build policies 2026-07-15 02:28:10 +08:00
louistiti fd6ca467ed fix(server): validate action parameters 2026-07-15 02:12:09 +08:00
louistiti 4633c50fb7 fix(server): restore conversation NLU context 2026-07-15 01:55:52 +08:00
louistiti 4ac896e08d fix(server): preserve Satellite progress profile 2026-07-15 01:09:37 +08:00
louistiti 22d4586b6e feat(server): forward skill tool progress 2026-07-15 01:01:00 +08:00
louistiti 78f08eede0 fix(server): release completed controlled turns 2026-07-15 00:35:16 +08:00
louistiti f8a46e1812 fix(server): preserve replacement Satellite connections 2026-07-15 00:10:55 +08:00
louistiti 9d6d4a5dfb fix(bridge/nodejs): release completed skill stdin 2026-07-14 23:56:58 +08:00
louistiti e6ae6bc446 fix: keep profile HTTP commands responsive 2026-07-14 22:23:37 +08:00
louistiti 6b5bab7dda refactor(server): remove legacy profile tokens 2026-07-14 21:54:21 +08:00
louistiti e480c72736 fix(web app): reveal profile authentication form 2026-07-14 21:09:22 +08:00
louistiti 8e8c6da0f4 chore(web app): revert init connection attempts 2026-07-14 20:40:27 +08:00
louistiti b3f6c08d4d fix(web app): preserve early initialization statuses 2026-07-14 20:26:10 +08:00
louistiti 97c0f2f4dc fix(web app): avoid unauthorized auth status checks 2026-07-14 20:19:23 +08:00
louistiti bab9813c5d fix: stabilize remote client and Satellite startup 2026-07-14 19:35:47 +08:00
louistiti 269655aa0e refactor(server): rename Link to Satellite 2026-07-14 18:13:43 +08:00
louistiti 2ddd662ce0 docs(server): document remote profile runtime 2026-07-14 18:08:42 +08:00
louistiti ff190ad63a refactor(server): rename Companion to Link 2026-07-14 17:59:27 +08:00
louistiti 304cf1a78a refactor(scripts): rename profile token generator 2026-07-14 17:14:09 +08:00
louistiti a2be039016 feat: add multi-profile remote runtime 2026-07-14 16:48:52 +08:00
Louistiti 5057f4cfc0 feat(agent mode): adopt continuous agent loop 2026-07-13 21:50:30 +08:00
louistiti 659077299a fix(agent mode): stop redundant plan steps 2026-07-13 02:06:11 +08:00
louistiti e8e7736448 fix(tool/yt-dlp): resolve moved subtitle files 2026-07-13 01:57:43 +08:00
Louistiti d0e85a664e chore(tool/yt-dlp): upgrade to 2026.07.04 2026-07-13 01:44:15 +08:00
Octopus 60fcb9a824 feat(provider/minimax): add MiniMax LLM support (#579)
* feat(provider/minimax): add MiniMax LLM support

* fix(provider/minimax): align model behavior

* fix(provider/minimax): recommend MiniMax-M3

---------

Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
Co-authored-by: kapelame <kape@kapedeMacBook-Air.local>
Co-authored-by: Louistiti <louis.grenard@gmail.com>
2026-07-13 00:38:50 +08:00
louistiti e40e282fee feat: support latest ecosystem models 2026-07-10 23:51:21 +08:00
Louistiti f9ac31a128 fix(server): context file static generation 2026-07-12 23:25:57 +08:00
Louistiti 8f7ec5fda3 feat(server): HTTP plugins support 2026-06-22 22:02:10 +08:00
louistiti d3be7b95ef chore(tool/yt-dlp): upgrade to 2026.06.09 2026-06-21 16:02:14 +08:00
Louistiti be1216830e Merge branch 'feat/web-app-2-0' into develop 2026-06-28 21:38:30 +08:00
Louistiti 316f926d0d Merge branch 'feat/web-app-2-0' into develop 2026-06-28 21:34:25 +08:00
Louistiti edc4026a2c Merge branch 'feat/web-app-2-0' into develop 2026-06-28 21:05:03 +08:00
Louistiti 86810a6260 Merge branch 'feat/custom-client-extension-endpoint' into develop 2026-06-27 11:14:48 +08:00
louistiti 00e601de6c fix(web app): tooltip shortcut hints default modifier key 2026-06-20 21:36:52 +08:00
louistiti 9be1631d8d feat(web app): add tooltip shortcut hints 2026-06-19 22:46:20 +08:00
Louistiti c0de69945a feat(scripts): add GLM 5.2 suggestion on setup 2026-06-19 05:53:50 +08:00
louistiti ba1316cbb9 feat(web app): search sessions dialog 2026-06-18 23:11:36 +08:00
louistiti fdc72ebf2d feat(web app): improve dialog pulse animation 2026-06-17 21:40:21 +08:00
louistiti ac46e97703 feat(web app): final modal window component 2026-06-16 22:51:02 +08:00
Louistiti 66fe3cd281 feat(server): add extension file read endpoint 2026-06-16 11:45:26 +08:00
louistiti 4e68fdb2a3 perf(web app): improve components based on portals 2026-06-15 22:02:10 +08:00
louistiti 318958e945 feat(web app): dialog backbone 2026-06-14 09:12:45 +08:00
louistiti f109bb115d chore(web app): remove unused sidebar action components 2026-06-13 20:11:46 +08:00
louistiti df4c704bbd feat(web app): hide session actions while editing 2026-06-13 15:19:26 +08:00
louistiti df85df8034 feat(web app): new selection style 2026-06-12 21:33:06 +08:00
louistiti 0d14cf82ec feat(web app): align input border style 2026-06-11 23:56:12 +08:00
louistiti e7af942f61 feat(web app): mute sidebar scrollbar by default 2026-06-19 06:46:06 +08:00
Louistiti 5c90740a48 Merge branch 'feat/web-app-2-0' into develop 2026-06-08 00:33:37 +08:00
Louistiti 4ef622a690 feat(web app): animate stacked toasts 2026-06-08 00:31:05 +08:00
Louistiti 1a9a84d2dd fix(web app): render info toast icon; better toast spacing 2026-06-07 23:38:41 +08:00
Louistiti 0247a24d3d feat(web app): toast component and session renaming kick off 2026-06-07 23:29:48 +08:00
Louistiti d93f209e8f feat(web app): add session item dropdown 2026-06-06 21:35:20 +08:00
Louistiti c8bf7f513d feat(web app): add session list item options button icon 2026-06-06 21:05:05 +08:00
Louistiti 00e5e1326d refactor(web app): add active surface states 2026-06-06 20:40:55 +08:00
Louistiti 0cb109cf5f fix(web app): smooth session list mask color transition 2026-06-06 18:09:09 +08:00
Louistiti 502f8c3cf7 refactor(web app): fade session list on sidebar fold 2026-06-06 18:01:23 +08:00
Louistiti d4225e8e5f refactor(web app): align session list item spacing 2026-06-06 17:45:29 +08:00
Louistiti be8b253ef8 feat(web app): add first session list version 2026-06-06 17:39:39 +08:00
Louistiti 1e3c75dc12 refactor(web app): adjust tooltip theme 2026-06-06 11:20:37 +08:00
Louistiti ac2dc1372c fix(web app): center search menu icon scale 2026-06-06 10:53:47 +08:00
Louistiti 0be61e1929 feat(web app): natural sidebar toggle transition 2026-06-06 10:46:50 +08:00
Louistiti 22fe5a35b5 fix(web app): blur tooltip trigger on click 2026-06-06 09:01:21 +08:00
Louistiti 82e8f150a5 feat(web app): persist sidebar control state 2026-06-06 08:39:25 +08:00
Louistiti 2cfbbaeeb7 fix(web app): render tooltips through portal 2026-06-06 08:35:04 +08:00
Louistiti f75ee6f91e refactor(web app): size shortcut keycaps by label 2026-06-06 08:28:53 +08:00
Louistiti 0d3a0460e6 Merge branch 'feat/web-app-2-0' into develop 2026-06-06 01:57:05 +08:00
Louistiti 680c992c55 feat(web app): sidebar header and menu 2026-06-06 01:56:42 +08:00
louistiti 0f88072736 feat(web app): add TanStack Virtual 2026-06-03 00:25:14 +08:00
louistiti 5e45a41964 feat(web app): prepare TanStack Query 2026-06-03 00:24:25 +08:00
louistiti 87f91f484f fix(web app): serve favicon from public assets 2026-06-03 00:22:40 +08:00
louistiti b28274ad76 feat(web app): all logo variants 2026-06-03 00:21:06 +08:00
Louistiti 1223421478 Merge branch 'develop' into feat/web-app-2-0 2026-06-03 09:20:04 +08:00
louistiti ad95eb9eb9 fix(agent mode): avoid stalled self-observation streams 2026-06-03 00:18:01 +08:00
Louistiti 1f5a116219 Merge branch 'develop' into feat/web-app-2-0 2026-06-02 10:07:58 +08:00
louistiti 03b7a9a429 perf(web app): split Vite vendor chunks 2026-06-02 00:11:35 +08:00
louistiti b6d69ef8c1 fix: replace deprecated Sass imports 2026-06-02 00:03:19 +08:00
Louistiti 6805dba502 Merge branch 'develop' into feat/web-app-2-0 2026-06-01 15:32:12 +08:00
Louistiti b20ef0ec1b feat(web app): 2.0 UI backbone 2026-05-31 23:18:48 +08:00
louistiti c717309647 test: Git ignore tests and benchmarks reports in skills 2026-05-31 18:02:26 +08:00
louistiti 5af0df23b0 refactor: default OpenRouter model to google/gemini-3.1-flash-liteacross usages 2026-05-31 16:15:23 +08:00
Louistiti 141cc105be build: update QMD to 2.5.3 2026-05-30 09:59:49 +08:00
louistiti 302e0154ae fix(provider/openrouter): include json hint for structured output 2026-05-29 01:07:11 +08:00
louistiti bd07cb604a feat: add support for Claude Opus 4.8 2026-05-29 01:01:32 +08:00
Louistiti c7a5fb39d2 fix(server): improve macOS free RAM check 2026-05-27 21:30:13 +08:00
Louistiti 5178cf1144 docs: document client interface boundary 2026-05-27 21:21:45 +08:00
Louistiti e3b65176ea Merge branch 'feat/leon-client-interface' into develop 2026-05-27 21:13:51 +08:00
Louistiti 88ef8320a2 feat: support Leon custom client interface 2026-05-25 23:56:08 +08:00
Louistiti 7803ca788f fix(provider/anthropic): show adaptive thinking summaries 2026-05-25 23:05:15 +08:00
louistiti 4216798c45 perf(provider/openai): tune response latency options 2026-05-24 18:51:06 +08:00
314 changed files with 19230 additions and 13687 deletions
+5 -2
View File
@@ -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=
+60
View File
@@ -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
+1
View File
@@ -8,6 +8,7 @@ __pycache__/
**/.venv/
**/.last-skill-deps-sync
**/.last-source-deps-sync
skills/**/reports/*
test/coverage/
**/tmp/*
core/config/**/*.json
+63 -4
View File
@@ -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 {
+8
View File
@@ -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
);
+4 -2
View File
@@ -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 {
+4 -2
View File
@@ -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 {
+5 -5
View File
@@ -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';
+3 -9
View File
@@ -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
View File
@@ -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,
+3
View File
@@ -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`)
+74
View File
@@ -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)
}
}
+3 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-button
position: relative
+1 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-card
position: relative
+1 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-checkbox
display: flex
+1 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-flexbox
position: relative
+1 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-icon
position: relative
+1 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-image
position: relative
+1 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-link
display: inline-block
+1 -1
View File
@@ -1,4 +1,4 @@
@import '../../../styles/main.sass'
@use '../../../styles/main.sass' as *
.aurora-list
position: relative
+1 -1
View File
@@ -1,4 +1,4 @@
@import '../../styles/main.sass'
@use '../../styles/main.sass' as *
.aurora-loader
width: var(--a-loader-size-md)
+1 -1
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+3 -3
View File
@@ -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
-1
View File
@@ -3,7 +3,6 @@
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"rootDir": "./src",
"outDir": "./dist",
"baseUrl": ".",
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ESNext",
+7 -5
View File
@@ -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]
}
}
+108
View File
@@ -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
}
}
-1
View File
@@ -3,7 +3,6 @@
"compilerOptions": {
"outDir": "./dist/bin",
"rootDir": "../../",
"baseUrl": ".",
"paths": {
"@@/*": ["../../*"],
"@/*": ["../../server/src/*"],
+45 -8
View File
@@ -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
+29 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+1232 -1746
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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
+362
View File
@@ -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)
})
+1 -1
View File
@@ -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')
-52
View File
@@ -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}`)
}
})()
+5
View File
@@ -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()
+1 -1
View File
@@ -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
}
: {})
},
+5 -13
View File
@@ -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 =
+11 -2
View File
@@ -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',
+18 -1
View File
@@ -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'
}
+32 -5
View File
@@ -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' }
]
+41 -10
View File
@@ -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) {
+3 -3
View File
@@ -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 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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'
*/
+16 -21
View File
@@ -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
}
}
}
+34 -2
View File
@@ -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()
+3 -7
View File
@@ -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)
+26 -107
View File
@@ -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)
+8 -12
View File
@@ -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
+20 -16
View File
@@ -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()
+36 -4
View File
@@ -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