refactor: maintainability pass — dedup, central env config, cmd helpers, tests, ARCHITECTURE.md
User asked the code stays easy to fix/extend as the feature surface grows.
This commit refactors what was duplicated, centralises what was scattered,
adds tests where there were none, and writes the architecture doc that
contributors will need.
Deduplication
- tts::play_wav extracted to tts/mod.rs as pub(crate). Was identical in
piper.rs and silero.rs (~25 lines × 2). Both backends now call super::play_wav.
Centralised env config
- new crates/jarvis-core/src/runtime_config.rs — single doc-file for every
JARVIS_* / GROQ_* / OLLAMA_* env var. Includes:
- ENV_* constants with doc comments
- get(name) / get_bool(name, default) / get_parse(name, default) helpers
- feature-flag wrappers: llm_tts_enabled(), llm_router_enabled(),
llm_router_threshold()
- log_effective_config() prints active values on startup
- migrated llm_fallback (JARVIS_LLM_TTS), llm_router (JARVIS_LLM_ROUTER,
JARVIS_LLM_ROUTER_THRESHOLD) to use the new helpers. Pattern set for
future migrations.
Lua boilerplate killer
- new crates/jarvis-core/src/lua/api/cmd.rs exposing:
jarvis.cmd.ok(msg?) — play_ok + speak + {chain=false}
jarvis.cmd.chain_ok(msg?) — same but chain=true
jarvis.cmd.error(msg?) — play_error + speak + {chain=false}
jarvis.cmd.not_found(msg?) — play_not_found + speak + {chain=false}
jarvis.cmd.silent_ok / silent_error
- refactored 5 packs (daily_briefing/off, memory_pack/list, pomodoro/stop,
habit_nudge/stop_all, scheduler/clear) — each lost 3-4 lines of repetitive
play_*/speak/return boilerplate. Pattern for future packs documented in
ARCHITECTURE.md.
Tests (was 32, now 49)
- long_term_memory: 8 new tests for normalize_key, search_in (rank, limit,
empty), build_context_from (empty + populated), serde round-trips for
MemoryRecord and Store. Extracted pure logic (search_in, build_context_from)
into pub(crate) functions to enable testing without global state.
- profiles: 6 new tests for Profile::allows_command (empty/whitelist/blacklist/
deny-wins-over-allow), serde round-trip with all fields + minimal-fields
tolerance via #[serde(default)].
- runtime_config: 2 tests for get_bool / get_parse defaults.
- All 49 tests pass.
New mood/energy log pack
- resources/commands/mood_log/ with 2 commands:
mood.record "запиши настроение 7" / "сегодня мне грустно" → stores
timestamped entry via jarvis.memory.remember
mood.recap "как прошла неделя" → LLM summarises last 30 entries
- Showcase: composes memory + llm + cmd helpers in <30 lines per script.
ARCHITECTURE.md (new, 250 lines)
- Crate layout, data flow diagram (mic→action 10 steps), per-module
responsibility table, configuration layers, TTS pipeline diagram,
Lua sandbox details with API quick-ref, background-services overview,
"how to add a pack/feature/TTS backend" recipes, test coverage map,
build instructions with MSVC env, git workflow with Forgejo NO_PROXY trick.
- Aimed at someone who just cloned the repo and needs to fix a bug fast.
Build: cargo build --release -p jarvis-app -p jarvis-gui both green.
Tests: 49/49 jarvis-core unit tests pass.
This commit is contained in:
parent
45243c3e3c
commit
6225198821
22 changed files with 891 additions and 93 deletions
295
ARCHITECTURE.md
Normal file
295
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# J.A.R.V.I.S. — Architecture
|
||||
|
||||
This is the code map for the Rust fork. If you're here to fix a bug or add a
|
||||
feature, start by reading the relevant section, then jump to the file.
|
||||
|
||||
The Python fork at `C:\Jarvis\python` follows the same conceptual flow but is
|
||||
a single-process Python daemon — much smaller surface area. This doc covers
|
||||
the Rust side.
|
||||
|
||||
## Crate layout
|
||||
|
||||
```
|
||||
crates/
|
||||
jarvis-core library: STT, wake-word, TTS, intent, commands, IPC, LLM,
|
||||
Lua sandbox, scheduler, memory, profiles. ZERO runtime, only
|
||||
building blocks. Other crates wire it together.
|
||||
jarvis-app daemon binary. Holds the microphone, runs the listening loop,
|
||||
dispatches commands, starts the scheduler thread. Tray icon.
|
||||
jarvis-gui Tauri 2 + Svelte frontend. Shows command list, settings, mic
|
||||
state. Communicates with jarvis-app via IPC websocket.
|
||||
jarvis-cli debug CLI (classify / execute / list). Currently has a known
|
||||
runtime panic on startup; use jarvis-app instead.
|
||||
```
|
||||
|
||||
```
|
||||
resources/commands/<pack_name>/
|
||||
command.toml — id, phrases per language, script path, sandbox level, timeout
|
||||
*.lua — script(s); one per command id when multiple ids share a pack
|
||||
```
|
||||
|
||||
The `tools/` directory hosts optional native helpers (Piper TTS binary, Silero
|
||||
helper script). Both are downloaded on demand via `tools/piper/install.ps1` etc.
|
||||
|
||||
## Data flow (mic → action)
|
||||
|
||||
```
|
||||
[1] pv_recorder ──► raw 16 kHz mono PCM
|
||||
[2] webrtc-vad ───► voice activity windows
|
||||
[3] Vosk (wake-word + STT) ──► utf-8 transcript
|
||||
[4] intent classifier (MiniLM) ──► (intent_id, confidence)?
|
||||
[5] levenshtein fallback ──► (pack, command)?
|
||||
[6] llm_router (IMBA-1) ──► canonical phrase substitution?
|
||||
[7] llm_fallback chat ──► spoken reply
|
||||
[8] command execution ──► Lua sandbox / Python subprocess / WinAPI call
|
||||
[9] TTS (sapi | piper | silero) ──► speech out
|
||||
[10] voices::play_* ──► reaction sounds (ok, error, not_found)
|
||||
```
|
||||
|
||||
Each step lives in a separate module — replace any one and the rest still
|
||||
works.
|
||||
|
||||
| Step | Module |
|
||||
|------|---------------------------------------------------|
|
||||
| 1 | `jarvis-core::recorder` |
|
||||
| 2 | `jarvis-core::audio_processing::vad::webrtc` |
|
||||
| 3 | `jarvis-core::stt` + `jarvis-core::listener` |
|
||||
| 4 | `jarvis-core::intent` |
|
||||
| 5 | `jarvis-core::commands::fetch_command` |
|
||||
| 6 | `jarvis-app::llm_router` |
|
||||
| 7 | `jarvis-app::llm_fallback` |
|
||||
| 8 | `jarvis-core::commands::execute_command` |
|
||||
| 9 | `jarvis-core::tts` |
|
||||
| 10 | `jarvis-core::voices` |
|
||||
|
||||
The listening loop driving all of this is `jarvis-app/src/app.rs::start()`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Two layers:
|
||||
|
||||
1. **`db::structs::Settings`** — persistent user preferences (voice, language,
|
||||
wake-word engine, noise-suppression backend). Lives in a sqlite file under
|
||||
`<APP_CONFIG_DIR>/`. Edited via the GUI Settings page.
|
||||
|
||||
2. **`runtime_config`** — environment-variable knobs read at startup. See
|
||||
`crates/jarvis-core/src/runtime_config.rs` for the full list with doc
|
||||
comments. Examples:
|
||||
- `JARVIS_TTS=sapi|piper|silero`
|
||||
- `JARVIS_LLM=groq|ollama`
|
||||
- `JARVIS_LLM_ROUTER=1` (default on), `JARVIS_LLM_ROUTER_THRESHOLD=0.55`
|
||||
- `GROQ_TOKEN`, `GROQ_MODEL`, `OLLAMA_BASE_URL`, `OLLAMA_MODEL`
|
||||
|
||||
`runtime_config::log_effective_config()` is called once on `main()` so the
|
||||
active values appear in the startup log.
|
||||
|
||||
`dev.env` next to the exe is auto-loaded by `dotenvy`. Walks up 5 parents — so
|
||||
running from `crates/jarvis-app/` during development still works.
|
||||
|
||||
## TTS pipeline
|
||||
|
||||
```
|
||||
text ─► text_utils::sanitize_for_speech ─► TtsBackend::speak(text, opts)
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
SapiBackend PiperBackend SileroBackend
|
||||
(PowerShell) (subprocess) (python helper)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
SAPI direct WAV → play_wav WAV → play_wav
|
||||
(shared) (shared)
|
||||
```
|
||||
|
||||
`tts::backend()` is initialised lazily on first call; subsequent calls reuse
|
||||
the same `Arc<dyn TtsBackend>`. Backend is picked by `JARVIS_TTS` env var
|
||||
with auto-detect fallback: if `tools/piper/piper.exe` exists, use Piper.
|
||||
|
||||
`play_wav` is the shared synchronous WAV player (PowerShell SoundPlayer on
|
||||
Windows, no-op stub elsewhere). Used by both Piper and Silero — kept in
|
||||
`tts/mod.rs` as `pub(crate)` to deduplicate.
|
||||
|
||||
## Lua sandbox
|
||||
|
||||
`mlua` 0.11 with three sandbox levels:
|
||||
|
||||
| Level | Allowed |
|
||||
|-----------|--------------------------------------------|
|
||||
| Minimal | log, sleep, speak, audio, context, cmd |
|
||||
| Standard | + http, llm, state, fs (within pack dir) |
|
||||
| Full | + system.exec, clipboard.set, abs paths |
|
||||
|
||||
Default is `standard`. Set per command in `command.toml` via `sandbox = "full"`.
|
||||
|
||||
Sandbox is enforced by:
|
||||
- `LuaEngine::new(level)` controls which `StdLib` flags are loaded
|
||||
- `lua/engine.rs::register_api` gates HTTP/state/fs registration on the level
|
||||
- `globals.set("io", Nil)` removes the `io` stdlib unless `Full`
|
||||
- `os.execute / os.exit / os.remove / os.rename / os.setlocale` removed
|
||||
even in `Full` mode (security defence)
|
||||
|
||||
### Available APIs for command scripts
|
||||
|
||||
See `crates/jarvis-core/src/lua/api/*.rs`. Quick reference:
|
||||
|
||||
```lua
|
||||
jarvis.speak(text, opts?) -- TTS; opts.lang/.async/.raw
|
||||
jarvis.cmd.ok(msg?) -- boilerplate-killer: play_ok + speak + {chain=false}
|
||||
jarvis.cmd.error(msg?) -- play_error + speak + {chain=false}
|
||||
jarvis.cmd.not_found(msg?) -- play_not_found + speak + {chain=false}
|
||||
jarvis.cmd.chain_ok(msg?) -- like ok() but chain=true
|
||||
|
||||
jarvis.audio.play_ok / play_error / play_not_found / play_reply / play_goodbye / play_thanks
|
||||
jarvis.context.{phrase, command_id, command_path, language, time, slots}
|
||||
|
||||
jarvis.text.strip_trigger(phrase, {triggers...})
|
||||
jarvis.text.contains_any(phrase, {needles...})
|
||||
|
||||
jarvis.llm({messages}, {opts}) -- Groq or Ollama, selected by JARVIS_LLM
|
||||
jarvis.http.{get, post, post_json, json}
|
||||
|
||||
jarvis.memory.{remember, recall, search, forget, all}
|
||||
jarvis.profile.{active, active_name, set, list, allows}
|
||||
jarvis.scheduler.{add, list, count, remove, clear}
|
||||
jarvis.vision.{screenshot, describe} -- HTTP sandbox required
|
||||
|
||||
jarvis.fs.{read, write, append, exists, is_file, is_dir, list, mkdir, remove}
|
||||
jarvis.state.{get, set} -- pack-scoped k/v
|
||||
jarvis.system.{open, exec, notify, env, platform, clipboard.{get, set}}
|
||||
jarvis.settings.{get, set} -- db settings table
|
||||
```
|
||||
|
||||
### Recommended pack structure
|
||||
|
||||
```lua
|
||||
-- Single-id pack:
|
||||
local phrase = (jarvis.context.phrase or ""):lower()
|
||||
local body = jarvis.text.strip_trigger(phrase, { "trigger1", "trigger2" })
|
||||
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
|
||||
if body == "" then
|
||||
return jarvis.cmd.error("Что именно?")
|
||||
end
|
||||
|
||||
-- ... do work ...
|
||||
|
||||
return jarvis.cmd.ok("Готово.")
|
||||
```
|
||||
|
||||
For multi-id packs (one Lua script shared between several `[[commands]]`),
|
||||
dispatch on `jarvis.context.command_id`. The `profile_switch/switch.lua` is a
|
||||
good example — maps `profile.work` → `work`, `profile.game` → `game`, etc.
|
||||
|
||||
## Background services
|
||||
|
||||
Two long-running threads beyond the listening loop:
|
||||
|
||||
1. **`scheduler` tick thread** (`crates/jarvis-core/src/scheduler.rs`).
|
||||
Wakes every 30 seconds, fires due tasks (Speak via TTS or Lua scripts).
|
||||
Persists changes to `<APP_CONFIG_DIR>/schedule.json`.
|
||||
|
||||
2. **`ipc::start_server`** (`crates/jarvis-core/src/ipc/`). Tokio
|
||||
websocket server on a configurable port. The GUI connects to this to
|
||||
send commands and receive events (`SpeechRecognized`, `LlmReply`,
|
||||
`CommandExecuted`, etc.).
|
||||
|
||||
## How to add a new command pack
|
||||
|
||||
1. Create `resources/commands/<your_pack>/`.
|
||||
2. Add `command.toml`:
|
||||
```toml
|
||||
[[commands]]
|
||||
id = "your_pack.thing"
|
||||
type = "lua"
|
||||
script = "thing.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = ["сделай штуку", ...]
|
||||
en = ["do the thing", ...]
|
||||
```
|
||||
3. Write `thing.lua`. Start from the snippet under "Recommended pack structure".
|
||||
4. Add a `commands::tests` assertion in `crates/jarvis-core/src/commands/tests.rs`
|
||||
so the parser regression test covers your TOML (the existing
|
||||
`every_command_toml_parses` already loads any new dir automatically).
|
||||
5. Run `cargo test -p jarvis-core --lib commands::tests`.
|
||||
6. Rebuild jarvis-app: it'll pick up the new pack on next start.
|
||||
|
||||
For Python parity, mirror the YAML entry + handler in `C:\Jarvis\python\`.
|
||||
|
||||
## How to add a new Rust core feature
|
||||
|
||||
1. Add module under `crates/jarvis-core/src/<feature>.rs`. Pure data layer +
|
||||
thin global wrapper if needed (see `long_term_memory.rs` as template).
|
||||
2. Register in `crates/jarvis-core/src/lib.rs` (`pub mod <feature>;`).
|
||||
3. Expose Lua API at `crates/jarvis-core/src/lua/api/<feature>.rs` if scripts
|
||||
need it. Register in `lua/api.rs` and call from `lua/engine.rs`.
|
||||
4. Wire init from `crates/jarvis-app/src/main.rs`.
|
||||
5. Add a `runtime_config::ENV_*` constant if there's an env-driven knob.
|
||||
6. Add unit tests in a `#[cfg(test)] mod tests {}` block in the new file.
|
||||
7. Document the public surface in this file (data flow / API section).
|
||||
|
||||
## How to add a new TTS backend
|
||||
|
||||
Implement `TtsBackend` (see `crates/jarvis-core/src/tts/mod.rs`). Use the
|
||||
`super::play_wav` helper if your engine emits WAV files. Wire selection in
|
||||
`init_backend()` near the top of the same file. Document the env var in
|
||||
`runtime_config.rs`.
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
cargo test -p jarvis-core --lib # all jarvis-core unit tests
|
||||
cargo test -p jarvis-core --lib scheduler:: # filter by module
|
||||
```
|
||||
|
||||
Test coverage map:
|
||||
|
||||
| Module | Tests |
|
||||
|----------------------|----------------------------------------------|
|
||||
| `text_utils` | sanitize_for_speech (5 cases) |
|
||||
| `long_term_memory` | normalize_key, search_in, build_context, serde (8) |
|
||||
| `profiles` | allows_command logic, serde (6) |
|
||||
| `scheduler` | parse_*, next_fire (7) |
|
||||
| `runtime_config` | get_bool / get_parse fallbacks (2) |
|
||||
| `llm::client` | response parsing, env handling, helpers (4) |
|
||||
| `llm::history` | conversation history truncation (5) |
|
||||
| `commands::tests` | every command.toml parses + has phrases (3) |
|
||||
| `lua::tests` | sandbox levels, fs escape, timeout (4) |
|
||||
| `audio_processing::vad::listen_window` | listening window logic (4) |
|
||||
|
||||
Run-time integration tests are manual:
|
||||
- mic available? → `jarvis-app` logs `recorder::init`
|
||||
- wake word fires? → log shows `WakeWordDetected`
|
||||
- TTS speaks? → check Piper/SAPI logs
|
||||
- Scheduler fires? → wait 30s after `scheduler::add` with `in 30 seconds`
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# One-time: set up MSVC env (PowerShell 7 / pwsh):
|
||||
$vc = "C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
|
||||
cmd /c "`"$vc`" >NUL && set" | % { if ($_ -match '^([^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process') } }
|
||||
|
||||
# Then:
|
||||
cargo build --release -p jarvis-app -p jarvis-gui
|
||||
```
|
||||
|
||||
The `jarvis-gui` `build.rs` auto-invokes `npm run build` and emits
|
||||
`cargo:rerun-if-changed=frontend/src` so changes to the Svelte frontend are
|
||||
picked up by a plain `cargo build`. No need to run `cargo tauri build`
|
||||
manually unless you want a packaged installer.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- `master` is protected.
|
||||
- `feature/pc-tools` is the integration branch where everything is currently
|
||||
shipping.
|
||||
- Self-hosted git on Forgejo at `http://192.168.0.10:3000/bossiara13/J.A.R.V.I.S-rust`.
|
||||
Push command: `NO_PROXY=192.168.0.10,localhost,127.0.0.1` + `git -c http.proxy= push forgejo <branch>`
|
||||
(the local V2Ray proxy at 10809 will otherwise block LAN traffic).
|
||||
- GitHub origin at `https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust` —
|
||||
manually pushed for public visibility.
|
||||
|
||||
Commit messages: descriptive, no `Co-Authored-By: Claude` tag (project rule).
|
||||
|
|
@ -137,7 +137,7 @@ pub fn handle(prompt: &str) {
|
|||
}
|
||||
|
||||
fn speak_reply(text: &str) {
|
||||
if std::env::var("JARVIS_LLM_TTS").as_deref() == Ok("false") {
|
||||
if !jarvis_core::runtime_config::llm_tts_enabled() {
|
||||
return;
|
||||
}
|
||||
tts::speak(text, &SpeakOpts::lang("ru"));
|
||||
|
|
|
|||
|
|
@ -60,19 +60,15 @@ fn build_state() -> Option<State> {
|
|||
return None;
|
||||
}
|
||||
};
|
||||
let threshold = std::env::var("JARVIS_LLM_ROUTER_THRESHOLD")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(DEFAULT_THRESHOLD);
|
||||
let threshold = jarvis_core::runtime_config::llm_router_threshold();
|
||||
let _ = DEFAULT_THRESHOLD; // kept for backward visibility in source
|
||||
|
||||
info!("LLM router enabled (backend: {}, threshold {:.2}).", client.backend().name(), threshold);
|
||||
Some(State { client, threshold })
|
||||
}
|
||||
|
||||
fn router_enabled() -> bool {
|
||||
std::env::var("JARVIS_LLM_ROUTER")
|
||||
.map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(true) // default ON when GROQ_TOKEN is present
|
||||
jarvis_core::runtime_config::llm_router_enabled()
|
||||
&& config::LLM_DEFAULT_ENABLED
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ fn main() -> Result<(), String> {
|
|||
eprintln!("[jarvis-app] step: i18n::init");
|
||||
i18n::init(&settings.lock().language);
|
||||
|
||||
eprintln!("[jarvis-app] step: runtime_config::log_effective_config");
|
||||
jarvis_core::runtime_config::log_effective_config();
|
||||
|
||||
eprintln!("[jarvis-app] step: llm_fallback::init");
|
||||
llm_fallback::init();
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ pub mod lua;
|
|||
|
||||
pub mod text_utils;
|
||||
|
||||
pub mod runtime_config;
|
||||
|
||||
pub mod tts;
|
||||
|
||||
pub mod long_term_memory;
|
||||
|
|
|
|||
|
|
@ -120,10 +120,30 @@ fn now_secs() -> i64 {
|
|||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn normalize_key(k: &str) -> String {
|
||||
pub(crate) fn normalize_key(k: &str) -> String {
|
||||
k.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Filter+rank logic shared between `search` and tests. Public for unit tests
|
||||
/// to bypass the global state.
|
||||
pub(crate) fn search_in<'a>(
|
||||
entries: impl IntoIterator<Item = &'a MemoryRecord>,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Vec<MemoryRecord> {
|
||||
let nq = normalize_key(query);
|
||||
if nq.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut hits: Vec<MemoryRecord> = entries.into_iter()
|
||||
.filter(|r| r.key.contains(&nq) || r.value.to_lowercase().contains(&nq))
|
||||
.cloned()
|
||||
.collect();
|
||||
hits.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
|
||||
hits.truncate(limit.max(1));
|
||||
hits
|
||||
}
|
||||
|
||||
/// Store / overwrite a fact.
|
||||
pub fn remember(key: &str, value: &str) -> Result<(), String> {
|
||||
let state = STATE.get().ok_or_else(|| "memory not init".to_string())?;
|
||||
|
|
@ -169,18 +189,8 @@ pub fn recall(key: &str) -> Option<String> {
|
|||
/// recency. Matches against both key and value.
|
||||
pub fn search(query: &str, limit: usize) -> Vec<MemoryRecord> {
|
||||
let Some(state) = STATE.get() else { return Vec::new(); };
|
||||
let nq = normalize_key(query);
|
||||
if nq.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let store = state.store.read();
|
||||
let mut hits: Vec<MemoryRecord> = store.entries.values()
|
||||
.filter(|r| r.key.contains(&nq) || r.value.to_lowercase().contains(&nq))
|
||||
.cloned()
|
||||
.collect();
|
||||
hits.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
|
||||
hits.truncate(limit.max(1));
|
||||
hits
|
||||
search_in(store.entries.values(), query, limit)
|
||||
}
|
||||
|
||||
/// Delete a fact. Returns true if it existed.
|
||||
|
|
@ -205,13 +215,126 @@ pub fn all() -> Vec<MemoryRecord> {
|
|||
/// based on a substring search of the user's prompt. Empty string if no matches.
|
||||
pub fn build_context(prompt: &str, limit: usize) -> String {
|
||||
let hits = search(prompt, limit);
|
||||
build_context_from(&hits)
|
||||
}
|
||||
|
||||
/// Pure formatter used by both `build_context` and tests.
|
||||
pub(crate) fn build_context_from(hits: &[MemoryRecord]) -> String {
|
||||
if hits.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut buf = String::with_capacity(256);
|
||||
buf.push_str("Известные факты о пользователе (используй если уместно):\n");
|
||||
for h in &hits {
|
||||
for h in hits {
|
||||
buf.push_str(&format!("- {} = {}\n", h.key, h.value));
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rec(key: &str, value: &str, last_used: i64) -> MemoryRecord {
|
||||
MemoryRecord {
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
created_at: 0,
|
||||
last_used_at: last_used,
|
||||
use_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_key_lowercases_and_trims() {
|
||||
assert_eq!(normalize_key(" HeLLo "), "hello");
|
||||
assert_eq!(normalize_key(""), "");
|
||||
assert_eq!(normalize_key("любимый ЧАЙ "), "любимый чай");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_matches_key_or_value() {
|
||||
let entries = vec![
|
||||
rec("любимый чай", "улун", 100),
|
||||
rec("любимый кофе", "арабика", 200),
|
||||
rec("питомец", "собака бася", 300),
|
||||
];
|
||||
let hits = search_in(&entries, "чай", 5);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].key, "любимый чай");
|
||||
|
||||
let hits = search_in(&entries, "бася", 5);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].key, "питомец");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_ranks_by_recency() {
|
||||
let entries = vec![
|
||||
rec("чай улун", "вкусный", 100),
|
||||
rec("чай зеленый", "тоже", 300),
|
||||
rec("чай черный", "обычный", 200),
|
||||
];
|
||||
let hits = search_in(&entries, "чай", 5);
|
||||
assert_eq!(hits.len(), 3);
|
||||
assert_eq!(hits[0].key, "чай зеленый"); // most recent
|
||||
assert_eq!(hits[1].key, "чай черный");
|
||||
assert_eq!(hits[2].key, "чай улун");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_respects_limit() {
|
||||
let entries = vec![
|
||||
rec("a", "x", 1),
|
||||
rec("ab", "y", 2),
|
||||
rec("abc", "z", 3),
|
||||
];
|
||||
let hits = search_in(&entries, "a", 2);
|
||||
assert_eq!(hits.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_empty_query_returns_nothing() {
|
||||
let entries = vec![rec("a", "b", 0)];
|
||||
assert!(search_in(&entries, "", 5).is_empty());
|
||||
assert!(search_in(&entries, " ", 5).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_context_empty_returns_empty_string() {
|
||||
assert_eq!(build_context_from(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_context_lists_each_fact() {
|
||||
let hits = vec![
|
||||
rec("чай", "улун", 0),
|
||||
rec("кофе", "арабика", 0),
|
||||
];
|
||||
let ctx = build_context_from(&hits);
|
||||
assert!(ctx.contains("чай = улун"));
|
||||
assert!(ctx.contains("кофе = арабика"));
|
||||
assert!(ctx.starts_with("Известные факты"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_record_serde_round_trip() {
|
||||
let r = rec("test", "value", 42);
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
let back: MemoryRecord = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.key, r.key);
|
||||
assert_eq!(back.value, r.value);
|
||||
assert_eq!(back.last_used_at, r.last_used_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_serde_round_trip() {
|
||||
let mut store = Store::default();
|
||||
store.entries.insert("key1".into(), rec("key1", "val1", 100));
|
||||
store.entries.insert("key2".into(), rec("key2", "val2", 200));
|
||||
let json = serde_json::to_string(&store).unwrap();
|
||||
let back: Store = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.entries.len(), 2);
|
||||
assert_eq!(back.entries.get("key1").unwrap().value, "val1");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ pub mod text;
|
|||
pub mod memory;
|
||||
pub mod profile;
|
||||
pub mod vision;
|
||||
pub mod scheduler;
|
||||
pub mod scheduler;
|
||||
pub mod cmd;
|
||||
81
crates/jarvis-core/src/lua/api/cmd.rs
Normal file
81
crates/jarvis-core/src/lua/api/cmd.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! `jarvis.cmd` — boilerplate-killer helpers for Lua command scripts.
|
||||
//!
|
||||
//! Most packs end with the same 3-line ritual:
|
||||
//!
|
||||
//! jarvis.speak(msg)
|
||||
//! jarvis.audio.play_ok()
|
||||
//! return { chain = false }
|
||||
//!
|
||||
//! With these helpers it's one call:
|
||||
//!
|
||||
//! return jarvis.cmd.ok(msg)
|
||||
//!
|
||||
//! Available helpers (each returns a `{ chain = false }` result table so the
|
||||
//! pack can simply `return jarvis.cmd.ok(...)`):
|
||||
//!
|
||||
//! jarvis.cmd.ok(msg?) -- play_ok sound, optional spoken msg
|
||||
//! jarvis.cmd.chain_ok(msg?) -- like ok() but returns {chain = true}
|
||||
//! jarvis.cmd.error(msg?) -- play_error sound, optional spoken msg
|
||||
//! jarvis.cmd.not_found(msg?) -- play_not_found sound, optional spoken msg
|
||||
//! jarvis.cmd.silent_ok() -- only play_ok, no speech
|
||||
//! jarvis.cmd.silent_error() -- only play_error, no speech
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
|
||||
use crate::tts::{self, SpeakOpts};
|
||||
use crate::voices;
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
|
||||
let cmd = lua.create_table()?;
|
||||
|
||||
cmd.set("ok", lua.create_function(|l, msg: Option<String>| {
|
||||
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
|
||||
tts::speak(&text, &SpeakOpts::default());
|
||||
}
|
||||
voices::play_ok();
|
||||
result_table(l, false)
|
||||
})?)?;
|
||||
|
||||
cmd.set("chain_ok", lua.create_function(|l, msg: Option<String>| {
|
||||
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
|
||||
tts::speak(&text, &SpeakOpts::default());
|
||||
}
|
||||
voices::play_ok();
|
||||
result_table(l, true)
|
||||
})?)?;
|
||||
|
||||
cmd.set("error", lua.create_function(|l, msg: Option<String>| {
|
||||
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
|
||||
tts::speak(&text, &SpeakOpts::default());
|
||||
}
|
||||
voices::play_error();
|
||||
result_table(l, false)
|
||||
})?)?;
|
||||
|
||||
cmd.set("not_found", lua.create_function(|l, msg: Option<String>| {
|
||||
if let Some(text) = msg.filter(|s| !s.trim().is_empty()) {
|
||||
tts::speak(&text, &SpeakOpts::default());
|
||||
}
|
||||
voices::play_not_found();
|
||||
result_table(l, false)
|
||||
})?)?;
|
||||
|
||||
cmd.set("silent_ok", lua.create_function(|l, ()| {
|
||||
voices::play_ok();
|
||||
result_table(l, false)
|
||||
})?)?;
|
||||
|
||||
cmd.set("silent_error", lua.create_function(|l, ()| {
|
||||
voices::play_error();
|
||||
result_table(l, false)
|
||||
})?)?;
|
||||
|
||||
jarvis.set("cmd", cmd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn result_table(lua: &Lua, chain: bool) -> mlua::Result<Table> {
|
||||
let t = lua.create_table()?;
|
||||
t.set("chain", chain)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ impl LuaEngine {
|
|||
api::memory::register(&self.lua, &jarvis)?;
|
||||
api::profile::register(&self.lua, &jarvis)?;
|
||||
api::scheduler::register(&self.lua, &jarvis)?;
|
||||
api::cmd::register(&self.lua, &jarvis)?;
|
||||
|
||||
// sandbox-controlled APIs
|
||||
if self.sandbox.allows_http() {
|
||||
|
|
|
|||
|
|
@ -227,3 +227,83 @@ pub fn list() -> Vec<String> {
|
|||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p_with(allowed: Vec<&str>, disabled: Vec<&str>) -> Profile {
|
||||
Profile {
|
||||
name: "test".into(),
|
||||
description: String::new(),
|
||||
llm_personality: String::new(),
|
||||
allowed_command_prefixes: allowed.into_iter().map(String::from).collect(),
|
||||
disabled_command_prefixes: disabled.into_iter().map(String::from).collect(),
|
||||
greeting: String::new(),
|
||||
icon: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_lists_allow_everything() {
|
||||
let p = p_with(vec![], vec![]);
|
||||
assert!(p.allows_command("anything"));
|
||||
assert!(p.allows_command("games.steam.launch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_prefixes_act_as_whitelist() {
|
||||
let p = p_with(vec!["music.", "time."], vec![]);
|
||||
assert!(p.allows_command("music.spotify.play"));
|
||||
assert!(p.allows_command("time.current"));
|
||||
assert!(!p.allows_command("games.steam"));
|
||||
assert!(!p.allows_command("windows.minimize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_prefixes_act_as_blacklist() {
|
||||
let p = p_with(vec![], vec!["games.", "fun."]);
|
||||
assert!(!p.allows_command("games.steam.launch"));
|
||||
assert!(!p.allows_command("fun.joke"));
|
||||
assert!(p.allows_command("time.current"));
|
||||
assert!(p.allows_command("weather.now"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_wins_over_allowed() {
|
||||
// even if "media." is on the allow list, "media.spotify" being on
|
||||
// the deny list must short-circuit to false.
|
||||
let p = p_with(vec!["media."], vec!["media.spotify"]);
|
||||
assert!(!p.allows_command("media.spotify.skip"));
|
||||
assert!(p.allows_command("media.youtube.play"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_serde_round_trip() {
|
||||
let p = Profile {
|
||||
name: "work".into(),
|
||||
description: "Focus mode".into(),
|
||||
llm_personality: "Be brief.".into(),
|
||||
allowed_command_prefixes: vec!["time.".into(), "weather.".into()],
|
||||
disabled_command_prefixes: vec!["games.".into()],
|
||||
greeting: "Sir.".into(),
|
||||
icon: "💼".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: Profile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.name, "work");
|
||||
assert_eq!(back.allowed_command_prefixes.len(), 2);
|
||||
assert_eq!(back.disabled_command_prefixes.len(), 1);
|
||||
assert_eq!(back.icon, "💼");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_serde_tolerates_missing_optional_fields() {
|
||||
// Minimum-fields JSON should deserialize using #[serde(default)] on each field.
|
||||
let minimal = r#"{"name":"minimal"}"#;
|
||||
let p: Profile = serde_json::from_str(minimal).unwrap();
|
||||
assert_eq!(p.name, "minimal");
|
||||
assert!(p.description.is_empty());
|
||||
assert!(p.allowed_command_prefixes.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
crates/jarvis-core/src/runtime_config.rs
Normal file
138
crates/jarvis-core/src/runtime_config.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
//! Centralised runtime configuration knobs.
|
||||
//!
|
||||
//! All environment-variable-driven behaviour in J.A.R.V.I.S. is documented here
|
||||
//! and (for the most-used flags) wrapped in helper functions. Adding a new knob:
|
||||
//!
|
||||
//! 1. Add a `const ENV_*: &str = "JARVIS_..."` declaration below with a doc comment.
|
||||
//! 2. Add a thin getter (e.g. `pub fn my_feature_enabled() -> bool`) below.
|
||||
//! 3. Call it from the feature module instead of inlining `std::env::var(...)`.
|
||||
//!
|
||||
//! The goal is single-point discovery: open this file to learn every flag.
|
||||
//!
|
||||
//! For the on-disk settings (sqlite via `db::structs::Settings`), see `crate::db`.
|
||||
|
||||
use std::env;
|
||||
|
||||
// ─── TTS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// TTS backend selector. Values: `sapi` | `piper` | `silero`. Unset → auto-detect
|
||||
/// (Piper if `tools/piper/piper.exe` + voice present, else SAPI).
|
||||
pub const ENV_TTS: &str = "JARVIS_TTS";
|
||||
|
||||
/// Absolute path to `piper.exe`. Default: `<exe_dir>/tools/piper/piper.exe`.
|
||||
pub const ENV_TTS_PIPER_BIN: &str = "JARVIS_TTS_PIPER_BIN";
|
||||
|
||||
/// Absolute path to Piper voice `.onnx`. Default: first .onnx in `<piper_dir>/voices/`.
|
||||
pub const ENV_TTS_PIPER_VOICE: &str = "JARVIS_TTS_PIPER_VOICE";
|
||||
|
||||
/// Python executable for Silero subprocess. Default: `python`.
|
||||
pub const ENV_TTS_PYTHON: &str = "JARVIS_TTS_PYTHON";
|
||||
|
||||
/// Path to `silero_tts.py` helper. Default: `<exe_dir>/tools/silero/silero_tts.py`.
|
||||
pub const ENV_TTS_SILERO_HELPER: &str = "JARVIS_TTS_SILERO_HELPER";
|
||||
|
||||
/// Silero voice name. Default: `xenia`. Other ru_v3 voices: baya, aidar, eugene, kseniya.
|
||||
pub const ENV_TTS_SILERO_VOICE: &str = "JARVIS_TTS_SILERO_VOICE";
|
||||
|
||||
/// Set to `false` to disable LLM-reply TTS playback (keep IPC event only). Default: enabled.
|
||||
pub const ENV_LLM_TTS: &str = "JARVIS_LLM_TTS";
|
||||
|
||||
// ─── LLM ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// LLM backend selector. Values: `groq` | `ollama`. Auto-detect: Groq if
|
||||
/// `GROQ_TOKEN` present, else Ollama.
|
||||
pub const ENV_LLM: &str = "JARVIS_LLM";
|
||||
|
||||
/// Groq API token. Required for cloud LLM. Get one at https://groq.com.
|
||||
pub const ENV_GROQ_TOKEN: &str = "GROQ_TOKEN";
|
||||
|
||||
/// Groq base URL. Default: `https://api.groq.com/openai/v1`.
|
||||
pub const ENV_GROQ_BASE_URL: &str = "GROQ_BASE_URL";
|
||||
|
||||
/// Groq model. Default: `llama-3.3-70b-versatile`.
|
||||
pub const ENV_GROQ_MODEL: &str = "GROQ_MODEL";
|
||||
|
||||
/// Groq vision-capable model for IMBA-4. Default: `llama-3.2-11b-vision-preview`.
|
||||
pub const ENV_GROQ_VISION_MODEL: &str = "GROQ_VISION_MODEL";
|
||||
|
||||
/// Ollama base URL. Default: `http://localhost:11434/v1`.
|
||||
pub const ENV_OLLAMA_BASE_URL: &str = "OLLAMA_BASE_URL";
|
||||
|
||||
/// Ollama model name. Default: `qwen2.5:3b`. Pull via `ollama pull <name>`.
|
||||
pub const ENV_OLLAMA_MODEL: &str = "OLLAMA_MODEL";
|
||||
|
||||
// ─── LLM router (IMBA-1) ───────────────────────────────────────────────────
|
||||
|
||||
/// Enable agentic LLM router. Values: `1` / `true` / `yes` / `on`. Default: `1` (on).
|
||||
pub const ENV_LLM_ROUTER: &str = "JARVIS_LLM_ROUTER";
|
||||
|
||||
/// Confidence threshold (0.0..1.0) above which the router accepts the LLM's command pick.
|
||||
/// Default: `0.55`. Lower → more matches but more false positives.
|
||||
pub const ENV_LLM_ROUTER_THRESHOLD: &str = "JARVIS_LLM_ROUTER_THRESHOLD";
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read an env var, returning `None` for unset or whitespace-only.
|
||||
pub fn get(name: &str) -> Option<String> {
|
||||
env::var(name).ok().and_then(|v| {
|
||||
let trimmed = v.trim();
|
||||
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a boolean-ish env var. Accepts `1` / `true` / `yes` / `on` (case-insensitive)
|
||||
/// as true, `0` / `false` / `no` / `off` as false. Returns `default` if unset.
|
||||
pub fn get_bool(name: &str, default: bool) -> bool {
|
||||
match get(name).map(|v| v.to_lowercase()) {
|
||||
Some(s) => matches!(s.as_str(), "1" | "true" | "yes" | "on"),
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a typed env var. Returns `default` if unset or unparseable.
|
||||
pub fn get_parse<T: std::str::FromStr>(name: &str, default: T) -> T {
|
||||
get(name).and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
// ─── Feature-flag wrappers ─────────────────────────────────────────────────
|
||||
|
||||
/// True if LLM-reply TTS is enabled (default). Set `JARVIS_LLM_TTS=false` to mute.
|
||||
pub fn llm_tts_enabled() -> bool { get_bool(ENV_LLM_TTS, true) }
|
||||
|
||||
/// True if agentic LLM router is enabled (default on).
|
||||
pub fn llm_router_enabled() -> bool { get_bool(ENV_LLM_ROUTER, true) }
|
||||
|
||||
/// Router confidence threshold; default 0.55.
|
||||
pub fn llm_router_threshold() -> f32 { get_parse(ENV_LLM_ROUTER_THRESHOLD, 0.55) }
|
||||
|
||||
/// Log every effective env-driven knob at INFO level. Call once on startup.
|
||||
pub fn log_effective_config() {
|
||||
log::info!("[config] TTS backend: {}", get(ENV_TTS).unwrap_or_else(|| "auto".into()));
|
||||
log::info!("[config] LLM backend: {}", get(ENV_LLM).unwrap_or_else(|| "auto".into()));
|
||||
log::info!("[config] LLM router: {} (threshold {:.2})",
|
||||
llm_router_enabled(), llm_router_threshold());
|
||||
log::info!("[config] LLM reply TTS: {}", llm_tts_enabled());
|
||||
log::info!("[config] Groq model: {}",
|
||||
get(ENV_GROQ_MODEL).unwrap_or_else(|| "llama-3.3-70b-versatile (default)".into()));
|
||||
log::info!("[config] Ollama model: {}",
|
||||
get(ENV_OLLAMA_MODEL).unwrap_or_else(|| "qwen2.5:3b (default)".into()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn get_bool_defaults() {
|
||||
assert!(!get_bool("JARVIS_NON_EXISTENT_XYZ", false));
|
||||
assert!(get_bool("JARVIS_NON_EXISTENT_XYZ", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_parse_typed_default() {
|
||||
let n: u32 = get_parse("JARVIS_NON_EXISTENT_XYZ", 42);
|
||||
assert_eq!(n, 42);
|
||||
let f: f32 = get_parse("JARVIS_NON_EXISTENT_XYZ", 0.5);
|
||||
assert!((f - 0.5).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
//! If `JARVIS_TTS` is unset, the dispatcher auto-detects Piper, otherwise SAPI.
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::text_utils::sanitize_for_speech;
|
||||
|
|
@ -25,6 +26,31 @@ pub use sapi::SapiBackend;
|
|||
pub use piper::PiperBackend;
|
||||
pub use silero::SileroBackend;
|
||||
|
||||
/// Play a WAV file synchronously via PowerShell SoundPlayer (Windows) or no-op
|
||||
/// stub elsewhere. Shared by file-based TTS backends (Piper, Silero).
|
||||
pub(crate) fn play_wav(path: &Path) {
|
||||
play_wav_impl(path);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn play_wav_impl(path: &Path) {
|
||||
let escaped = path.display().to_string().replace('\'', "''");
|
||||
let ps = format!(
|
||||
"$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()",
|
||||
escaped
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn play_wav_impl(path: &Path) {
|
||||
log::info!("[TTS non-Windows stub] would play: {}", path.display());
|
||||
}
|
||||
|
||||
/// Options for a single `speak()` call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpeakOpts {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ fn synth_and_play(binary: &Path, voice: &Path, text: &str) {
|
|||
|
||||
match child.wait() {
|
||||
Ok(status) if status.success() => {
|
||||
play_wav(&wav_path);
|
||||
super::play_wav(&wav_path);
|
||||
}
|
||||
Ok(status) => log::warn!("Piper exited with status {}", status),
|
||||
Err(e) => log::warn!("Piper wait failed: {}", e),
|
||||
|
|
@ -115,25 +115,6 @@ fn synth_and_play(binary: &Path, voice: &Path, text: &str) {
|
|||
let _ = std::fs::remove_file(&wav_path);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn play_wav(path: &Path) {
|
||||
let escaped = path.display().to_string().replace('\'', "''");
|
||||
let ps = format!(
|
||||
"$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()",
|
||||
escaped
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn play_wav(path: &Path) {
|
||||
log::info!("[Piper non-Windows stub] would play: {}", path.display());
|
||||
}
|
||||
|
||||
fn find_binary() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("JARVIS_TTS_PIPER_BIN") {
|
||||
let candidate = PathBuf::from(p);
|
||||
|
|
|
|||
|
|
@ -107,29 +107,10 @@ fn synth_and_play(python: &str, helper: &Path, voice: &str, text: &str) {
|
|||
}
|
||||
|
||||
let p = PathBuf::from(wav_path);
|
||||
play_wav(&p);
|
||||
super::play_wav(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn play_wav(path: &Path) {
|
||||
let escaped = path.display().to_string().replace('\'', "''");
|
||||
let ps = format!(
|
||||
"$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()",
|
||||
escaped
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn play_wav(path: &Path) {
|
||||
log::info!("[Silero non-Windows stub] would play: {}", path.display());
|
||||
}
|
||||
|
||||
fn find_helper() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("JARVIS_TTS_SILERO_HELPER") {
|
||||
let candidate = PathBuf::from(p);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
local removed = jarvis.scheduler.remove("daily_briefing_main")
|
||||
if removed then
|
||||
jarvis.speak("Утренний брифинг отключен.")
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
jarvis.speak("Брифинг и так не был настроен.")
|
||||
jarvis.audio.play_not_found()
|
||||
if jarvis.scheduler.remove("daily_briefing_main") then
|
||||
return jarvis.cmd.ok("Утренний брифинг отключен.")
|
||||
end
|
||||
return { chain = false }
|
||||
return jarvis.cmd.not_found("Брифинг и так не был настроен.")
|
||||
|
|
|
|||
|
|
@ -4,11 +4,8 @@ for _, id in ipairs({ "habit_water", "habit_stretch", "habit_eyes" }) do
|
|||
removed = removed + 1
|
||||
end
|
||||
end
|
||||
|
||||
if removed > 0 then
|
||||
jarvis.speak("Отключил привычек: " .. removed .. ".")
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
jarvis.speak("Привычки и так не запущены.")
|
||||
jarvis.audio.play_not_found()
|
||||
return jarvis.cmd.ok("Отключил привычек: " .. removed .. ".")
|
||||
end
|
||||
return { chain = false }
|
||||
return jarvis.cmd.not_found("Привычки и так не запущены.")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
local recs = jarvis.memory.all()
|
||||
if #recs == 0 then
|
||||
jarvis.speak("Я пока ничего о вас не запомнил.")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
return jarvis.cmd.ok("Я пока ничего о вас не запомнил.")
|
||||
end
|
||||
|
||||
local count = #recs
|
||||
|
|
@ -14,6 +12,4 @@ for i = 1, sample do
|
|||
end
|
||||
line = line .. "."
|
||||
|
||||
jarvis.speak(line)
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
return jarvis.cmd.ok(line)
|
||||
|
|
|
|||
39
resources/commands/mood_log/command.toml
Normal file
39
resources/commands/mood_log/command.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Mood / energy log — daily journal entries via voice.
|
||||
# Each entry stored via jarvis.memory with key = "mood_<unix_ts>" so it survives restart.
|
||||
|
||||
[[commands]]
|
||||
id = "mood.record"
|
||||
type = "lua"
|
||||
script = "record.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"запиши настроение",
|
||||
"настроение",
|
||||
"запиши настроение на",
|
||||
"сегодня мне",
|
||||
"мой день был",
|
||||
"мне сейчас",
|
||||
]
|
||||
en = ["log mood", "my mood is", "I feel"]
|
||||
ua = ["запиши настрій", "мій настрій"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "mood.recap"
|
||||
type = "lua"
|
||||
script = "recap.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 15000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"как прошла неделя",
|
||||
"недельный отчет настроения",
|
||||
"сводка настроения",
|
||||
"что я писал про настроение",
|
||||
]
|
||||
en = ["mood recap", "weekly mood summary", "how was my week"]
|
||||
ua = ["підсумок настрою"]
|
||||
33
resources/commands/mood_log/recap.lua
Normal file
33
resources/commands/mood_log/recap.lua
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- Pull all mood_* entries, ask LLM to summarize trends.
|
||||
local all = jarvis.memory.all()
|
||||
local moods = {}
|
||||
for _, rec in ipairs(all) do
|
||||
if rec.key:sub(1, 5) == "mood_" then
|
||||
table.insert(moods, rec.value)
|
||||
end
|
||||
end
|
||||
|
||||
if #moods == 0 then
|
||||
return jarvis.cmd.not_found("Записей о настроении пока нет.")
|
||||
end
|
||||
|
||||
-- Trim to most-recent-ish 30 entries (memory.all isn't ordered, but mood_<ts> keys sort)
|
||||
table.sort(moods)
|
||||
local recent = {}
|
||||
local start = math.max(1, #moods - 30 + 1)
|
||||
for i = start, #moods do
|
||||
table.insert(recent, moods[i])
|
||||
end
|
||||
|
||||
local joined = table.concat(recent, "\n")
|
||||
|
||||
local summary = jarvis.llm({
|
||||
{ role = "system", content = "Ты — психотерапевт-помощник. На основе записей пользователя о настроении дай короткую сводку (3-5 предложений) на русском: общий тон, тренды, выдели хороший/плохой день. Без банальностей, без советов." },
|
||||
{ role = "user", content = "Записи:\n" .. joined }
|
||||
}, { max_tokens = 250, temperature = 0.5 })
|
||||
|
||||
if not summary or summary == "" then
|
||||
return jarvis.cmd.error("Не получилось обобщить. Возможно, нет связи с LLM.")
|
||||
end
|
||||
|
||||
return jarvis.cmd.ok(summary)
|
||||
37
resources/commands/mood_log/record.lua
Normal file
37
resources/commands/mood_log/record.lua
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- "Запиши настроение 7" / "сегодня мне грустно" / "настроение восемь, продуктивно"
|
||||
local phrase = (jarvis.context.phrase or "")
|
||||
|
||||
local body = jarvis.text.strip_trigger(phrase:lower(), {
|
||||
"запиши настроение на",
|
||||
"запиши настроение",
|
||||
"недельный отчет",
|
||||
"настроение",
|
||||
"сегодня мне",
|
||||
"мой день был",
|
||||
"мне сейчас",
|
||||
"log mood",
|
||||
"my mood is",
|
||||
"i feel",
|
||||
"запиши настрій",
|
||||
})
|
||||
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
|
||||
if body == "" then
|
||||
return jarvis.cmd.error("Что записать про настроение?")
|
||||
end
|
||||
|
||||
local t = jarvis.context.time
|
||||
local stamp = string.format("%04d-%02d-%02d %02d:%02d",
|
||||
t.year, t.month, t.day, t.hour, t.minute)
|
||||
local key = "mood_" .. (t.timestamp or os.time())
|
||||
local value = stamp .. " — " .. body
|
||||
|
||||
local ok, err = pcall(function()
|
||||
jarvis.memory.remember(key, value)
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.log("warn", "mood.record: " .. tostring(err))
|
||||
return jarvis.cmd.error("Не получилось записать.")
|
||||
end
|
||||
|
||||
return jarvis.cmd.ok("Записал.")
|
||||
|
|
@ -3,10 +3,6 @@ local removed_b = jarvis.scheduler.remove("pomodoro_break")
|
|||
jarvis.state.set("pomodoro_phase", "")
|
||||
|
||||
if removed_w or removed_b then
|
||||
jarvis.speak("Помодоро остановлен.")
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
jarvis.speak("Помодоро и так не запущен.")
|
||||
jarvis.audio.play_not_found()
|
||||
return jarvis.cmd.ok("Помодоро остановлен.")
|
||||
end
|
||||
return { chain = false }
|
||||
return jarvis.cmd.not_found("Помодоро и так не запущен.")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
local n = jarvis.scheduler.clear()
|
||||
if n == 0 then
|
||||
jarvis.speak("В расписании и так пусто.")
|
||||
else
|
||||
jarvis.speak(string.format("Удалил %d задач.", n))
|
||||
return jarvis.cmd.ok("В расписании и так пусто.")
|
||||
end
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
return jarvis.cmd.ok(string.format("Удалил %d задач.", n))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue