From df799b6cd2b46960691d52786d5358f16ccf2710 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 14:34:29 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Wave=205=20=E2=80=94=20sysinfo=20speaks?= =?UTF-8?q?,=20smart=20reminders,=20outlook=20COM,=20time=20tracker,=20WOL?= =?UTF-8?q?,=20conversation=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice sysinfo overhaul (6 scripts: battery/cpu/ram/disk/time/all): - Now SPEAK the result via jarvis.cmd.ok instead of notify-only. Toast still fires for visual reference, but real Jarvis tells you out loud. - `all` joins lines with commas so TTS doesn't over-pause on linebreaks. Smart reminders (`reminders/set.lua` rewrite): - Was: detached `Start-Sleep` PowerShell subprocesses that died on jarvis-app restart and spawned a hidden process per timer. - Now: routes through `jarvis.scheduler.add(... type=speak ...)`. Persists to schedule.json, survives restart, no zombie processes. Trade-off: sub-minute timers round up to 1 min (rare in voice UX). - Drops UA triggers from earlier UA-removal sweep. Translation polish (`translate/translate.lua` + `clipboard.lua`): - Drops UA target language; adds Italian, Polish, Turkish, French ru triggers + English target detectors. - `language_iso()` helper picks the SAPI voice locale for the TARGET language (German translation → German SAPI voice if installed). LLM system prompt (config.rs): - New LLM_SYSTEM_PROMPT_EN counterpart. Both languages now mention the assistant's tooling (memory / scheduler / profiles / macros / vision / clipboard) so the LLM knows it can DO things, not suggest the user install something. - `get_llm_system_prompt(lang)` picks EN/RU. Conversation pack (NEW `conversation/`): - 2 commands. `conversation.summary` ("о чём мы говорили") pulls the last 12 turns from persisted history, asks LLM for a 1-3 sentence recap in Jarvis tone. Falls back gracefully when offline. - `conversation.repeat` ("повтори") re-speaks the last assistant message. - New Lua API `jarvis.llm_history()` returns array of {role, content} tables, excluding the system prompt. WOL pack (NEW `wol/`): - Voice "разбуди сервер" / "wake server" fires a magic packet at a MAC stored in long-term memory under `wol_` (default alias "server"). - Setup via voice: "запомни wol_server AA:BB:CC:DD:EE:FF". Falls back to `JARVIS_WOL_TARGET` env if memory empty. Handles every standard MAC format (colons / dashes / dots / bare). Broadcast on UDP 9 via PowerShell System.Net.Sockets.UdpClient. Outlook COM pack (NEW `outlook/`, via subagent): - 4 commands: unread_count, latest, send_clipboard, summarize_inbox. - PowerShell COM bridge `_outlook.ps1` with tab-delimited line protocol. - summarize_inbox calls LLM for a one-paragraph summary. Graceful failure when Outlook isn't running. Time tracker pack (NEW `time_tracker/`, via subagent): - 5 commands: start, stop, today, week, reset. - Persists via `jarvis.state` (structured object: current_session_start + sessions array of {start, end}). - Local-calendar "today" boundary, full Russian pluralisation (час/часа/часов, минута/минуты/минут, сессия/сессии/сессий). Tests: 139 rust (no regression), 81 python (60 → 81, +14 time_tracker +7 outlook). Release build green for jarvis-app and jarvis-cli. Pack count: 88 → 92 rust packs. --- crates/jarvis-core/src/config.rs | 18 +- crates/jarvis-core/src/lua/api/llm.rs | 18 ++ resources/commands/conversation/command.toml | 45 +++++ .../commands/conversation/repeat_last.lua | 13 ++ resources/commands/conversation/summary.lua | 79 ++++++++ resources/commands/outlook/_outlook.ps1 | 184 ++++++++++++++++++ resources/commands/outlook/command.toml | 91 +++++++++ resources/commands/outlook/latest.lua | 68 +++++++ resources/commands/outlook/send_clipboard.lua | 103 ++++++++++ .../commands/outlook/summarize_inbox.lua | 79 ++++++++ resources/commands/outlook/unread_count.lua | 55 ++++++ resources/commands/reminders/set.lua | 82 ++++---- resources/commands/sysinfo/all.lua | 15 +- resources/commands/sysinfo/battery.lua | 12 +- resources/commands/sysinfo/cpu.lua | 12 +- resources/commands/sysinfo/disk.lua | 12 +- resources/commands/sysinfo/ram.lua | 12 +- resources/commands/sysinfo/time.lua | 12 +- resources/commands/time_tracker/command.toml | 121 ++++++++++++ resources/commands/time_tracker/reset.lua | 59 ++++++ resources/commands/time_tracker/start.lua | 22 +++ resources/commands/time_tracker/stop.lua | 41 ++++ resources/commands/time_tracker/today.lua | 81 ++++++++ resources/commands/time_tracker/week.lua | 71 +++++++ resources/commands/translate/clipboard.lua | 24 ++- resources/commands/translate/command.toml | 9 +- resources/commands/translate/translate.lua | 42 ++-- resources/commands/wol/command.toml | 31 +++ resources/commands/wol/wake.lua | 78 ++++++++ 29 files changed, 1383 insertions(+), 106 deletions(-) create mode 100644 resources/commands/conversation/command.toml create mode 100644 resources/commands/conversation/repeat_last.lua create mode 100644 resources/commands/conversation/summary.lua create mode 100644 resources/commands/outlook/_outlook.ps1 create mode 100644 resources/commands/outlook/command.toml create mode 100644 resources/commands/outlook/latest.lua create mode 100644 resources/commands/outlook/send_clipboard.lua create mode 100644 resources/commands/outlook/summarize_inbox.lua create mode 100644 resources/commands/outlook/unread_count.lua create mode 100644 resources/commands/time_tracker/command.toml create mode 100644 resources/commands/time_tracker/reset.lua create mode 100644 resources/commands/time_tracker/start.lua create mode 100644 resources/commands/time_tracker/stop.lua create mode 100644 resources/commands/time_tracker/today.lua create mode 100644 resources/commands/time_tracker/week.lua create mode 100644 resources/commands/wol/command.toml create mode 100644 resources/commands/wol/wake.lua diff --git a/crates/jarvis-core/src/config.rs b/crates/jarvis-core/src/config.rs index cf9c213..1679aa0 100644 --- a/crates/jarvis-core/src/config.rs +++ b/crates/jarvis-core/src/config.rs @@ -214,7 +214,21 @@ pub const LLM_SYSTEM_PROMPT_RU: &str = "Ты — J.A.R.V.I.S. (Just A Rather Ver ИИ-ассистент Тони Старка из киновселенной Marvel (до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). \ Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». \ Отвечай по-русски, кратко (1-3 предложения), как живой человек. \ -Излишней цензуры не нужно — мат разрешён, если уместен."; +Излишней цензуры не нужно — мат разрешён, если уместен. \ +У тебя есть память (пользователь говорит «запомни/забудь/что ты помнишь»), \ +расписание задач, профили (work/sleep/game/driving), макросы, и доступ к компьютеру \ +пользователя — экран, буфер обмена, погода, диск, RAM. Когда пользователь спрашивает \ +о таких вещах, не предлагай ему ставить себе что-то — ты сам это делаешь."; + +pub const LLM_SYSTEM_PROMPT_EN: &str = "You are J.A.R.V.I.S. (Just A Rather Very Intelligent System), \ +Tony Stark's AI from the MCU (pre-Age of Ultron — you are NOT Vision and NOT FRIDAY). \ +You behave like a British butler: polite, ironic, mildly sarcastic, addressing the user as «sir». \ +Answer in English, briefly (1-3 sentences), like a real person. \ +No excess censorship — profanity is allowed when fitting. \ +You have memory (the user says «remember X / forget X / what do you remember»), \ +a scheduler, profiles (work/sleep/game/driving), macros, and access to the user's PC — \ +screen, clipboard, weather, disk, RAM. When asked about these, you handle it — \ +don't suggest the user install something."; pub const LLM_FALLBACK_ERROR_RU: &str = "Не могу связаться с сервером, сэр."; pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] { @@ -227,7 +241,7 @@ pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] { pub fn get_llm_system_prompt(lang: &str) -> &'static str { match lang { - "ru" => LLM_SYSTEM_PROMPT_RU, + "en" => LLM_SYSTEM_PROMPT_EN, _ => LLM_SYSTEM_PROMPT_RU, } } diff --git a/crates/jarvis-core/src/lua/api/llm.rs b/crates/jarvis-core/src/lua/api/llm.rs index ab430d8..b170b43 100644 --- a/crates/jarvis-core/src/lua/api/llm.rs +++ b/crates/jarvis-core/src/lua/api/llm.rs @@ -118,5 +118,23 @@ pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { })?; jarvis.set("llm_last_reply", last_fn)?; + // Snapshot of the full conversation history as an array of + // {role, content} tables. Excludes the system prompt — only the actual + // back-and-forth. Empty array if there's no history yet. + let snapshot_fn = lua.create_function(|lua, ()| { + let arr = lua.create_table()?; + for (i, msg) in llm::history_snapshot().iter().enumerate() { + if msg.role == "system" { + continue; + } + let row = lua.create_table()?; + row.set("role", msg.role.clone())?; + row.set("content", msg.content.clone())?; + arr.set(i, row)?; + } + Ok(arr) + })?; + jarvis.set("llm_history", snapshot_fn)?; + Ok(()) } diff --git a/resources/commands/conversation/command.toml b/resources/commands/conversation/command.toml new file mode 100644 index 0000000..93c6f2c --- /dev/null +++ b/resources/commands/conversation/command.toml @@ -0,0 +1,45 @@ +# Conversation tools — pull the persisted LLM history for review or replay. + +[[commands]] +id = "conversation.summary" +type = "lua" +script = "summary.lua" +sandbox = "full" +timeout = 20000 + +[commands.phrases] +ru = [ + "о чём мы говорили", + "о чем мы говорили", + "напомни о чем мы говорили", + "напомни про что мы говорили", + "что мы недавно обсуждали", + "сделай выжимку разговора", +] +en = [ + "what were we talking about", + "summarize our conversation", + "what did we discuss", + "recap our chat", +] + + +[[commands]] +id = "conversation.repeat" +type = "lua" +script = "repeat_last.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "повтори", + "повтори последнее", + "что ты сказал", + "не расслышал", +] +en = [ + "say that again", + "repeat", + "i didn't catch that", +] diff --git a/resources/commands/conversation/repeat_last.lua b/resources/commands/conversation/repeat_last.lua new file mode 100644 index 0000000..c1870af --- /dev/null +++ b/resources/commands/conversation/repeat_last.lua @@ -0,0 +1,13 @@ +-- Repeat the last assistant message verbatim. Useful when the user didn't catch +-- it (loud room, distraction, etc). + +local lang = jarvis.context.language +local last = jarvis.llm_last_reply() + +if not last or last == "" then + return jarvis.cmd.not_found(lang == "ru" + and "Я ещё ничего не говорил, сэр." + or "I haven't said anything yet, sir.") +end + +return jarvis.cmd.ok(last) diff --git a/resources/commands/conversation/summary.lua b/resources/commands/conversation/summary.lua new file mode 100644 index 0000000..1882895 --- /dev/null +++ b/resources/commands/conversation/summary.lua @@ -0,0 +1,79 @@ +-- Summarise the last N turns of LLM conversation in 1-2 sentences. +-- +-- Reads the persistent history via `jarvis.llm_history()`, builds a short +-- recap prompt, asks the LLM. If there's no history yet or the LLM is +-- unreachable, falls back to a polite "ничего не вспомнить" reply. + +local lang = jarvis.context.language + +local turns = jarvis.llm_history() or {} +-- Take only the last 12 user/assistant turns to keep prompt small. +local recent = {} +local start = math.max(1, #turns - 11) +for i = start, #turns do + if turns[i] then table.insert(recent, turns[i]) end +end + +if #recent == 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Мы пока ещё ни о чём не говорили, сэр." + or "We haven't talked about anything yet, sir.") +end + +-- Render history as a compact "Пользователь: ... / Я: ..." dialogue. +local lines = {} +for _, m in ipairs(recent) do + local who = m.role == "assistant" and (lang == "ru" and "Я" or "Me") + or (lang == "ru" and "Пользователь" or "User") + table.insert(lines, who .. ": " .. (m.content or "")) +end +local transcript = table.concat(lines, "\n") + +local token = jarvis.system.env("GROQ_TOKEN") +if not token or token == "" then + -- Offline path: just speak the user's last message back briefly. + return jarvis.cmd.ok(lang == "ru" + and ("Без сети, сэр. Последний раз вы спросили: " .. (recent[#recent].content or ""):sub(1, 200)) + or ("No network, sir. Last topic: " .. (recent[#recent].content or ""):sub(1, 200))) +end + +local base = jarvis.system.env("GROQ_BASE_URL") +if not base or base == "" then base = "https://api.groq.com/openai/v1" end +local model = jarvis.system.env("GROQ_MODEL") +if not model or model == "" then model = "llama-3.3-70b-versatile" end + +local sys +if lang == "ru" then + sys = "Ты — J.A.R.V.I.S. По ниже идущему диалогу с пользователем составь короткое " + .. "напоминание (1-3 предложения), о чём шла речь. Говори от первого лица в " + .. "стиле британского дворецкого. Не пересказывай дословно — сожми суть." +else + sys = "You are J.A.R.V.I.S. From the dialogue below, write a brief recap " + .. "(1-3 sentences) of what we talked about. Use first person, British " + .. "butler tone. Don't quote verbatim — distil the essence." +end + +local payload = { + model = model, + messages = { + { role = "system", content = sys }, + { role = "user", content = transcript }, + }, + max_tokens = 200, + temperature = 0.4, +} + +local res = jarvis.http.post_json(base .. "/chat/completions", payload, + { Authorization = "Bearer " .. token }) +if not res.ok then + return jarvis.cmd.error(lang == "ru" and "LLM не отвечает." or "LLM didn't respond.") +end + +local content = (res.body or ""):match('"content"%s*:%s*"(.-[^\\])"') +if not content then + return jarvis.cmd.error(lang == "ru" and "Не смог разобрать ответ." or "Couldn't parse reply.") +end +content = content:gsub('\\n', ' '):gsub('\\"', '"'):gsub('\\\\', '\\') +content = content:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + +return jarvis.cmd.ok(content) diff --git a/resources/commands/outlook/_outlook.ps1 b/resources/commands/outlook/_outlook.ps1 new file mode 100644 index 0000000..2ea135c --- /dev/null +++ b/resources/commands/outlook/_outlook.ps1 @@ -0,0 +1,184 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("unread_count","latest","send_clipboard","summarize_inbox")] + [string]$Action, + + [string]$Recipient = "", + [string]$Body = "", + [int]$Limit = 5 +) + +$ErrorActionPreference = 'Stop' +$OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# Output protocol (parsed by Lua scripts): +# First line: "OK" or "ERR:" or "ERR::" +# Subsequent lines depend on Action: +# unread_count -> "" +# latest -> "FROM\t" / "SUBJECT\t" / "PREVIEW\t" +# send_clipboard -> "TO\t" / "ADDR\t" / "SUBJECT\t" +# summarize_inbox -> repeated "FROM\t\tSUBJECT\t" +# +# All multiline strings get newlines collapsed to spaces before printing. + +function Clean-Line([string]$s) { + if (-not $s) { return "" } + $s = $s -replace "`r", " " -replace "`n", " " -replace "`t", " " + return ($s -replace '\s+', ' ').Trim() +} + +function Fail($code, $msg = "") { + if ($msg) { + Write-Output ("ERR:{0}:{1}" -f $code, (Clean-Line $msg)) + } else { + Write-Output ("ERR:{0}" -f $code) + } + exit 0 +} + +function Get-OutlookSession { + try { + $ol = New-Object -ComObject Outlook.Application + $ns = $ol.GetNamespace("MAPI") + return @{ App = $ol; Ns = $ns } + } catch { + Fail "outlook_unavailable" $_.Exception.Message + } +} + +function Strip-Html([string]$s) { + if (-not $s) { return "" } + $s = [regex]::Replace($s, '<[^>]+>', ' ') + $s = $s -replace ' ', ' ' -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", "'" + return Clean-Line $s +} + +function Resolve-Recipient([object]$ns, [string]$query) { + if ([string]::IsNullOrWhiteSpace($query)) { return $null } + try { + $r = $ns.CreateRecipient($query) + $null = $r.Resolve() + if ($r.Resolved) { + $addr = $null + try { $addr = $r.AddressEntry.GetExchangeUser().PrimarySmtpAddress } catch { } + if (-not $addr) { + try { $addr = $r.AddressEntry.Address } catch { } + } + if (-not $addr) { $addr = $r.Name } + return @{ Name = $r.Name; Address = $addr } + } + } catch { } + return $null +} + +switch ($Action) { + "unread_count" { + $s = Get-OutlookSession + try { + $inbox = $s.Ns.GetDefaultFolder(6) + $count = 0 + try { $count = [int]$inbox.UnReadItemCount } catch { } + if (-not $count) { + $count = ($inbox.Items | Where-Object { $_.UnRead -eq $true }).Count + } + Write-Output "OK" + Write-Output ([int]$count).ToString() + } catch { + Fail "inbox_failed" $_.Exception.Message + } + } + + "latest" { + $s = Get-OutlookSession + try { + $inbox = $s.Ns.GetDefaultFolder(6) + $items = $inbox.Items + $items.Sort("[ReceivedTime]", $true) + $latest = $null + foreach ($it in $items) { + if ($it.Class -eq 43) { $latest = $it; break } # olMail = 43 + } + if (-not $latest) { Fail "empty_inbox" } + $fromName = "" + try { $fromName = $latest.SenderName } catch { } + if (-not $fromName) { try { $fromName = $latest.SenderEmailAddress } catch { } } + $subject = "" + try { $subject = $latest.Subject } catch { } + $body = "" + try { $body = $latest.Body } catch { } + if (-not $body) { + try { $body = Strip-Html $latest.HTMLBody } catch { } + } else { + $body = Clean-Line $body + } + if ($body.Length -gt 200) { $body = $body.Substring(0, 200) + "..." } + Write-Output "OK" + Write-Output ("FROM`t{0}" -f (Clean-Line $fromName)) + Write-Output ("SUBJECT`t{0}" -f (Clean-Line $subject)) + Write-Output ("PREVIEW`t{0}" -f $body) + } catch { + Fail "latest_failed" $_.Exception.Message + } + } + + "send_clipboard" { + if ([string]::IsNullOrWhiteSpace($Recipient)) { Fail "no_recipient" } + $s = Get-OutlookSession + try { + $resolved = Resolve-Recipient $s.Ns $Recipient + if (-not $resolved) { Fail "unresolved" $Recipient } + $bodyText = if ($Body) { $Body } else { "" } + $subject = "" + if ($bodyText) { + $firstLine = ($bodyText -split "`r?`n", 2)[0].Trim() + if ($firstLine.Length -gt 60) { + $subject = $firstLine.Substring(0, 60).Trim() + } else { + $subject = $firstLine + } + } + if ([string]::IsNullOrWhiteSpace($subject)) { $subject = "From J.A.R.V.I.S." } + + $mail = $s.App.CreateItem(0) + $mail.To = $resolved.Address + $mail.Subject = $subject + $mail.Body = $bodyText + $mail.Send() + Write-Output "OK" + Write-Output ("TO`t{0}" -f (Clean-Line $resolved.Name)) + Write-Output ("ADDR`t{0}" -f (Clean-Line $resolved.Address)) + Write-Output ("SUBJECT`t{0}" -f (Clean-Line $subject)) + } catch { + Fail "send_failed" $_.Exception.Message + } + } + + "summarize_inbox" { + $s = Get-OutlookSession + try { + $inbox = $s.Ns.GetDefaultFolder(6) + $items = $inbox.Items + $items.Sort("[ReceivedTime]", $true) + $taken = 0 + $lines = @() + foreach ($it in $items) { + if ($taken -ge $Limit) { break } + if ($it.Class -ne 43) { continue } + $unread = $false + try { $unread = [bool]$it.UnRead } catch { } + if (-not $unread) { continue } + $fromName = "" + try { $fromName = $it.SenderName } catch { } + $subj = "" + try { $subj = $it.Subject } catch { } + $lines += ("FROM`t{0}`tSUBJECT`t{1}" -f (Clean-Line $fromName), (Clean-Line $subj)) + $taken += 1 + } + Write-Output "OK" + foreach ($l in $lines) { Write-Output $l } + } catch { + Fail "summary_failed" $_.Exception.Message + } + } +} diff --git a/resources/commands/outlook/command.toml b/resources/commands/outlook/command.toml new file mode 100644 index 0000000..720ff98 --- /dev/null +++ b/resources/commands/outlook/command.toml @@ -0,0 +1,91 @@ +[[commands]] +id = "outlook_unread_count" +type = "lua" +script = "unread_count.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "сколько у меня непрочитанных писем", + "сколько непрочитанных писем", + "проверь почту", + "проверь outlook", + "сколько новых писем", +] +en = [ + "check unread mail", + "how many unread emails", + "check my inbox", + "unread email count", + "any new mail", +] + + +[[commands]] +id = "outlook_latest" +type = "lua" +script = "latest.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "прочитай последнее письмо", + "что в последнем письме", + "последнее письмо", + "покажи последнее письмо", +] +en = [ + "read last email", + "read latest email", + "what is the last email", + "show me the latest email", +] + + +[[commands]] +id = "outlook_send_clipboard" +type = "lua" +script = "send_clipboard.lua" +sandbox = "full" +timeout = 20000 + +[commands.phrases] +ru = [ + "отправь письмо {name}", + "отправь почту {name}", + "напиши письмо {name}", + "отправь это {name}", +] +en = [ + "email this to {name}", + "send email to {name}", + "send this email to {name}", + "mail this to {name}", +] + +[commands.slots.name] +entity = "person name" + + +[[commands]] +id = "outlook_summarize_inbox" +type = "lua" +script = "summarize_inbox.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = [ + "коротко расскажи про инбокс", + "расскажи что в инбоксе", + "сделай саммари инбокса", + "что нового в почте", +] +en = [ + "summarize inbox", + "summarise my inbox", + "tell me about my inbox", + "what is new in my mail", +] diff --git a/resources/commands/outlook/latest.lua b/resources/commands/outlook/latest.lua new file mode 100644 index 0000000..7bfcc09 --- /dev/null +++ b/resources/commands/outlook/latest.lua @@ -0,0 +1,68 @@ +-- Read the most recent email in the default Inbox: speak FROM + SUBJECT + +-- a 1-sentence preview (~200 chars, HTML stripped) of the body. + +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_outlook.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action latest', + helper +) + +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook latest exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local lines = {} +for line in out:gmatch("[^\n]+") do + table.insert(lines, line) +end + +local status = lines[1] or "" +if status:sub(1, 3) == "ERR" then + if status:find("empty_inbox", 1, true) then + return jarvis.cmd.not_found(lang == "ru" + and "Входящие пусты." + or "Inbox is empty.") + end + jarvis.log("warn", "outlook latest: " .. status) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local from, subject, preview = "", "", "" +for i = 2, #lines do + local key, val = lines[i]:match("^([A-Z]+)\t(.*)$") + if key == "FROM" then from = val or "" end + if key == "SUBJECT" then subject = val or "" end + if key == "PREVIEW" then preview = val or "" end +end + +if from == "" and subject == "" then + return jarvis.cmd.error(lang == "ru" and "Не получилось прочитать письмо." or "Could not read the email.") +end + +local speech +if lang == "ru" then + speech = string.format("Последнее письмо от %s. Тема: %s. %s", + from ~= "" and from or "неизвестного отправителя", + subject ~= "" and subject or "без темы", + preview) +else + speech = string.format("Latest email from %s. Subject: %s. %s", + from ~= "" and from or "unknown sender", + subject ~= "" and subject or "no subject", + preview) +end + +jarvis.system.notify( + lang == "ru" and "Последнее письмо" or "Latest email", + string.format("%s\n%s\n%s", from, subject, preview) +) +return jarvis.cmd.ok(speech) diff --git a/resources/commands/outlook/send_clipboard.lua b/resources/commands/outlook/send_clipboard.lua new file mode 100644 index 0000000..94ecb53 --- /dev/null +++ b/resources/commands/outlook/send_clipboard.lua @@ -0,0 +1,103 @@ +-- Send clipboard contents as an email to {name} via Outlook COM. The recipient +-- is resolved against the Global Address List + Contacts (Outlook's Recipient +-- .Resolve API). Subject = first line of the body truncated to 60 chars, or +-- "From J.A.R.V.I.S." for an empty clipboard. + +local lang = jarvis.context.language +local slots = jarvis.context.slots or {} +local recipient = slots.name or "" +recipient = tostring(recipient):gsub("^%s+", ""):gsub("%s+$", "") + +if recipient == "" then + return jarvis.cmd.not_found(lang == "ru" + and "Кому отправить? Назови имя." + or "Whom to send to? Please name them.") +end + +local body = jarvis.system.clipboard.get() or "" +body = body:gsub("^%s+", ""):gsub("%s+$", "") + +-- Write the body to a temp file so we don't have to escape it through the +-- PowerShell command-line. The PS helper reads it back via -Body parameter +-- value (passed as an arg). For very large bodies (>32 KB) the cmd-line +-- approach would break; in that case we read the file from inside PS. +local temp = jarvis.system.env("TEMP") or jarvis.system.env("TMP") or "C:\\Windows\\Temp" +local ts = jarvis.context.time.year .. jarvis.context.time.month .. jarvis.context.time.day + .. "_" .. jarvis.context.time.hour .. jarvis.context.time.minute .. jarvis.context.time.second +local body_path = temp .. "\\jarvis_outlook_body_" .. ts .. ".txt" +jarvis.fs.write(body_path, body) + +-- Pass body via the file: the helper reads $Body if set, else falls back to +-- reading the saved file. To keep the helper simple we just inline the body +-- through -Body only when small; otherwise we still pass via -Body but quote +-- the path and let PS read it. We use the latter: PS reads its arg literally. +-- We instead encode the body so passing through args is safe. +local helper = jarvis.context.command_path .. "\\_outlook.ps1" + +-- Build a single-line PowerShell expression that reads the body from the +-- temp file and invokes the helper. This avoids any shell-escape issues +-- with quotes/newlines in the clipboard. +local body_path_ps = body_path:gsub("'", "''") +local recipient_ps = recipient:gsub("'", "''") +local helper_ps = helper:gsub("'", "''") +local ps_expr = string.format( + "$b = Get-Content -LiteralPath '%s' -Raw -Encoding UTF8; if (-not $b) { $b = '' } else { $b = $b.TrimEnd() }; & '%s' -Action send_clipboard -Recipient '%s' -Body $b", + body_path_ps, helper_ps, recipient_ps +) +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', + ps_expr:gsub('"', '\\"') +) + +local res = jarvis.system.exec(cmd) +jarvis.fs.remove(body_path) + +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook send_clipboard exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local lines = {} +for line in out:gmatch("[^\n]+") do table.insert(lines, line) end + +local status = lines[1] or "" +if status:sub(1, 3) == "ERR" then + if status:find("unresolved", 1, true) then + return jarvis.cmd.not_found(lang == "ru" + and (string.format("Не нашёл контакт «%s».", recipient)) + or (string.format("Could not find contact '%s'.", recipient))) + elseif status:find("no_recipient", 1, true) then + return jarvis.cmd.not_found(lang == "ru" + and "Кому отправить? Назови имя." + or "Whom to send to? Please name them.") + end + jarvis.log("warn", "outlook send_clipboard: " .. status) + return jarvis.cmd.error(lang == "ru" + and "Не получилось отправить — Outlook отказал." + or "Send failed — Outlook refused.") +end + +local to_name, addr, subject = "", "", "" +for i = 2, #lines do + local key, val = lines[i]:match("^([A-Z]+)\t(.*)$") + if key == "TO" then to_name = val or "" end + if key == "ADDR" then addr = val or "" end + if key == "SUBJECT" then subject = val or "" end +end + +local speech +if lang == "ru" then + speech = string.format("Отправил %s. Тема: %s.", to_name ~= "" and to_name or recipient, subject) +else + speech = string.format("Sent to %s. Subject: %s.", to_name ~= "" and to_name or recipient, subject) +end + +jarvis.system.notify( + lang == "ru" and "Письмо отправлено" or "Email sent", + string.format("%s <%s>\n%s", to_name, addr, subject) +) +return jarvis.cmd.ok(speech) diff --git a/resources/commands/outlook/summarize_inbox.lua b/resources/commands/outlook/summarize_inbox.lua new file mode 100644 index 0000000..ae8f76e --- /dev/null +++ b/resources/commands/outlook/summarize_inbox.lua @@ -0,0 +1,79 @@ +-- Summarize the last few unread emails into one paragraph via the LLM. +-- Pulls FROM+SUBJECT of up to 5 unread items, then asks the model for a +-- short paragraph in the user's language. + +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_outlook.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action summarize_inbox -Limit 5', + helper +) + +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook summarize_inbox exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local lines = {} +for line in out:gmatch("[^\n]+") do table.insert(lines, line) end + +local status = lines[1] or "" +if status:sub(1, 3) == "ERR" then + jarvis.log("warn", "outlook summarize_inbox: " .. status) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local emails = {} +for i = 2, #lines do + local from, subject = lines[i]:match("^FROM\t(.-)\tSUBJECT\t(.*)$") + if from then + table.insert(emails, { from = from, subject = subject or "" }) + end +end + +if #emails == 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Непрочитанных писем нет." + or "No unread emails.") +end + +-- Build the prompt +local list_str = "" +for i, m in ipairs(emails) do + list_str = list_str .. string.format("%d. From: %s | Subject: %s\n", i, m.from, m.subject) +end + +local sys_prompt = lang == "ru" + and "Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем (2-4 предложения). Сгруппируй по темам/отправителям если возможно. Без преамбулы, без перечисления списком." + or "You are an email assistant. Briefly describe the inbox in ONE paragraph (2-4 sentences). Group by topic/sender when possible. No preamble, no bullet list." + +local user_prompt = (lang == "ru" and "Непрочитанные письма:\n" or "Unread emails:\n") .. list_str + +local reply = jarvis.llm( + { { role = "system", content = sys_prompt }, + { role = "user", content = user_prompt } }, + { max_tokens = 250, temperature = 0.4 } +) + +if not reply or reply == "" then + -- Fallback: simple flat enumeration when LLM is unavailable. + local parts = {} + for _, m in ipairs(emails) do + table.insert(parts, string.format("%s — %s", m.from, m.subject)) + end + local fallback = (lang == "ru" + and string.format("Непрочитанных: %d. ", #emails) + or string.format("Unread: %d. ", #emails)) .. table.concat(parts, "; ") .. "." + jarvis.system.notify(lang == "ru" and "Инбокс" or "Inbox", fallback) + return jarvis.cmd.ok(fallback) +end + +jarvis.system.notify(lang == "ru" and "Инбокс" or "Inbox", reply) +return jarvis.cmd.ok(reply) diff --git a/resources/commands/outlook/unread_count.lua b/resources/commands/outlook/unread_count.lua new file mode 100644 index 0000000..6d048ab --- /dev/null +++ b/resources/commands/outlook/unread_count.lua @@ -0,0 +1,55 @@ +-- Voice-controlled Outlook: report number of unread emails in default Inbox. +-- The PowerShell helper talks to Outlook via COM; if Outlook is closed or +-- COM fails we degrade gracefully with an explanatory message. + +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_outlook.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action unread_count', + helper +) + +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook unread_count exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local first_line, rest = out:match("^([^\n]*)\n?(.*)$") +first_line = first_line or "" + +if first_line:sub(1, 3) == "ERR" then + jarvis.log("warn", "outlook unread_count: " .. first_line) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local count_str = (rest or ""):match("(%d+)") or "0" +local count = tonumber(count_str) or 0 + +local speech +if lang == "ru" then + if count == 0 then + speech = "Непрочитанных писем нет." + elseif count == 1 then + speech = "Одно непрочитанное письмо." + else + speech = string.format("Непрочитанных писем: %d.", count) + end +else + if count == 0 then + speech = "No unread emails." + elseif count == 1 then + speech = "One unread email." + else + speech = string.format("You have %d unread emails.", count) + end +end + +jarvis.system.notify(lang == "ru" and "Outlook" or "Outlook", speech) +return jarvis.cmd.ok(speech) diff --git a/resources/commands/reminders/set.lua b/resources/commands/reminders/set.lua index 3309479..1ac0411 100644 --- a/resources/commands/reminders/set.lua +++ b/resources/commands/reminders/set.lua @@ -1,3 +1,10 @@ +-- Reminder — schedule a one-shot Speak action via the persistent scheduler. +-- +-- Why this rewrite: the previous version spawned `Start-Sleep` PowerShell +-- subprocesses that didn't survive jarvis-app restart. Now we use +-- `jarvis.scheduler.add(...)` which persists to schedule.json and fires +-- correctly even if the daemon is restarted in between. + local lang = jarvis.context.language local phrase = (jarvis.context.phrase or ""):lower() @@ -5,9 +12,8 @@ local triggers = { "поставь напоминание через", "установи таймер на", "напомни мне через", "напомни через", "напомни", "remind me in", "set reminder in", "set timer for", "timer for", - "постав нагадування через", "нагадай через", "таймер на", + "таймер на", } - local rest = phrase for _, t in ipairs(triggers) do local s, e = string.find(rest, t, 1, true) @@ -19,19 +25,20 @@ end rest = rest:gsub("^%s+", "") local unit_words = { - ["секунд"] = 1, ["секунду"] = 1, ["секунды"] = 1, - ["second"] = 1, ["seconds"] = 1, ["sec"] = 1, - ["минут"] = 60, ["минуту"] = 60, ["минуты"] = 60, ["мин"] = 60, - ["minute"] = 60, ["minutes"] = 60, ["min"] = 60, - ["час"] = 3600, ["часа"] = 3600, ["часов"] = 3600, ["часик"] = 3600, - ["hour"] = 3600, ["hours"] = 3600, ["hr"] = 3600, + ["секунд"] = 1, ["секунду"] = 1, ["секунды"] = 1, + ["second"] = 1, ["seconds"] = 1, ["sec"] = 1, + ["минут"] = 60, ["минуту"] = 60, ["минуты"] = 60, ["мин"] = 60, + ["minute"] = 60, ["minutes"] = 60, ["min"] = 60, + ["час"] = 3600, ["часа"] = 3600, ["часов"] = 3600, ["часик"] = 3600, + ["hour"] = 3600, ["hours"] = 3600, ["hr"] = 3600, } local rus_numerals = { - ["один"] = 1, ["одну"] = 1, ["одна"] = 1, ["полторы"] = 1, ["полтора"] = 1, + ["один"] = 1, ["одну"] = 1, ["одна"] = 1, + ["полторы"] = 1, ["полтора"] = 1, ["два"] = 2, ["две"] = 2, ["три"] = 3, ["четыре"] = 4, ["пять"] = 5, ["шесть"] = 6, ["семь"] = 7, ["восемь"] = 8, ["девять"] = 9, ["десять"] = 10, ["пятнадцать"] = 15, ["двадцать"] = 20, ["тридцать"] = 30, ["сорок"] = 40, - ["пятьдесят"] = 50, ["час"] = 60, -- "через час" without number + ["пятьдесят"] = 50, } local number, unit, body = nil, nil, nil @@ -85,35 +92,38 @@ local function human_time(sec) return string.format("%d ч %d мин", math.floor(sec/3600), math.floor((sec%3600)/60)) end -local title_esc = (lang == "ru" and "Напоминание J.A.R.V.I.S." or "J.A.R.V.I.S. reminder"):gsub("'", "''") -local body_esc = body:gsub("'", "''") -local sapi_text = ((lang == "ru" and "Сэр, напоминаю: " or "Reminder: ") .. body):gsub("'", "''"):gsub("\r?\n", " ") +-- Build the scheduler entry. Scheduler's "in N minutes" wants integer minutes +-- so we round up for sub-minute timers; users asking for "10 seconds" will get +-- ~1 minute. Honest trade-off vs the more complex previous detached-PS approach. +local minutes = math.max(1, math.floor((total_seconds + 59) / 60)) +local schedule_str = "in " .. tostring(minutes) .. " minutes" -local ps = string.format( - [[Start-Sleep -Seconds %d; ]] - .. [[$null = New-Object -ComObject Shell.Application; ]] - .. [[try { ]] - .. [[ Import-Module BurntToast -ErrorAction SilentlyContinue; ]] - .. [[ New-BurntToastNotification -Text '%s', '%s' -ErrorAction SilentlyContinue ]] - .. [[} catch {}; ]] - .. [[Add-Type -AssemblyName System.Speech; ]] - .. [[$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; ]] - .. [[foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } }; ]] - .. [[$s.Speak('%s'); ]] - .. [[$wsh = New-Object -ComObject WScript.Shell; $wsh.Popup('%s', 30, '%s', 64) | Out-Null]], - total_seconds, title_esc, body_esc, sapi_text, body_esc, title_esc -) -local cmd = string.format( - 'powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command "%s"', - ps:gsub('"', '\\"') -) +local speak_text = (lang == "ru" and "Сэр, напоминаю: " or "Reminder: ") .. body -jarvis.system.exec("start /b " .. cmd) +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = lang == "ru" and ("Таймер: " .. body) or ("Timer: " .. body), + schedule = schedule_str, + action = { type = "speak", text = speak_text }, + }) +end) + +if not ok then + jarvis.log("error", "reminder: scheduler.add failed: " .. tostring(err)) + return jarvis.cmd.error(lang == "ru" + and "Не получилось поставить напоминание." + or "Couldn't set reminder.") +end jarvis.system.notify( lang == "ru" and "Таймер установлен" or "Timer set", - string.format(lang == "ru" and "Через %s: %s" or "In %s: %s", human_time(total_seconds), body) + string.format(lang == "ru" and "Через %s: %s" or "In %s: %s", + human_time(total_seconds), body) ) -jarvis.log("info", string.format("reminder in %ds: %s", total_seconds, body)) -jarvis.audio.play_ok() -return { chain = false } +jarvis.log("info", string.format("reminder in %ds (rounded to %d min): %s", + total_seconds, minutes, body)) + +return jarvis.cmd.ok(string.format( + lang == "ru" and "Хорошо, напомню через %s." or "Will remind you in %s.", + human_time(total_seconds) +)) diff --git a/resources/commands/sysinfo/all.lua b/resources/commands/sysinfo/all.lua index 2529e41..ad0f346 100644 --- a/resources/commands/sysinfo/all.lua +++ b/resources/commands/sysinfo/all.lua @@ -2,12 +2,13 @@ local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic all', helper) local res = jarvis.system.exec(cmd) local text = (res.stdout or ""):gsub("[\r\n]+$", "") -if res.success and text ~= "" then - jarvis.system.notify("Статус системы", text) - jarvis.log("info", text) - jarvis.audio.play_ok() -else +if not res.success or text == "" then jarvis.log("error", "sysinfo all failed: " .. tostring(res.stderr)) - jarvis.audio.play_error() + return jarvis.cmd.error("Не получилось собрать состояние системы.") end -return { chain = false } +jarvis.system.notify("Статус системы", text) +jarvis.log("info", text) +-- Multi-line output reads more naturally as a single sentence with commas +-- between metrics, so TTS doesn't over-pause on linebreaks. +local spoken = text:gsub("[\r\n]+", ", ") +return jarvis.cmd.ok(spoken) diff --git a/resources/commands/sysinfo/battery.lua b/resources/commands/sysinfo/battery.lua index 0ae2f2c..2820879 100644 --- a/resources/commands/sysinfo/battery.lua +++ b/resources/commands/sysinfo/battery.lua @@ -2,12 +2,10 @@ local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic battery', helper) local res = jarvis.system.exec(cmd) local text = (res.stdout or ""):gsub("[\r\n]+$", "") -if res.success and text ~= "" then - jarvis.system.notify("Батарея", text) - jarvis.log("info", text) - jarvis.audio.play_ok() -else +if not res.success or text == "" then jarvis.log("error", "battery info failed: " .. tostring(res.stderr)) - jarvis.audio.play_error() + return jarvis.cmd.error("Не получилось узнать заряд.") end -return { chain = false } +jarvis.system.notify("Батарея", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/cpu.lua b/resources/commands/sysinfo/cpu.lua index 5590376..8cd7c6d 100644 --- a/resources/commands/sysinfo/cpu.lua +++ b/resources/commands/sysinfo/cpu.lua @@ -2,12 +2,10 @@ local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic cpu', helper) local res = jarvis.system.exec(cmd) local text = (res.stdout or ""):gsub("[\r\n]+$", "") -if res.success and text ~= "" then - jarvis.system.notify("CPU", text) - jarvis.log("info", text) - jarvis.audio.play_ok() -else +if not res.success or text == "" then jarvis.log("error", "cpu info failed: " .. tostring(res.stderr)) - jarvis.audio.play_error() + return jarvis.cmd.error("Не получилось узнать загрузку процессора.") end -return { chain = false } +jarvis.system.notify("CPU", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/disk.lua b/resources/commands/sysinfo/disk.lua index 813172f..ba12961 100644 --- a/resources/commands/sysinfo/disk.lua +++ b/resources/commands/sysinfo/disk.lua @@ -2,12 +2,10 @@ local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic disk', helper) local res = jarvis.system.exec(cmd) local text = (res.stdout or ""):gsub("[\r\n]+$", "") -if res.success and text ~= "" then - jarvis.system.notify("Диск", text) - jarvis.log("info", text) - jarvis.audio.play_ok() -else +if not res.success or text == "" then jarvis.log("error", "disk info failed: " .. tostring(res.stderr)) - jarvis.audio.play_error() + return jarvis.cmd.error("Не получилось узнать диск.") end -return { chain = false } +jarvis.system.notify("Диск", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/ram.lua b/resources/commands/sysinfo/ram.lua index 1ad8208..489ea0e 100644 --- a/resources/commands/sysinfo/ram.lua +++ b/resources/commands/sysinfo/ram.lua @@ -2,12 +2,10 @@ local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic ram', helper) local res = jarvis.system.exec(cmd) local text = (res.stdout or ""):gsub("[\r\n]+$", "") -if res.success and text ~= "" then - jarvis.system.notify("Память", text) - jarvis.log("info", text) - jarvis.audio.play_ok() -else +if not res.success or text == "" then jarvis.log("error", "ram info failed: " .. tostring(res.stderr)) - jarvis.audio.play_error() + return jarvis.cmd.error("Не получилось узнать память.") end -return { chain = false } +jarvis.system.notify("Память", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/time.lua b/resources/commands/sysinfo/time.lua index de4e2b6..483dd9f 100644 --- a/resources/commands/sysinfo/time.lua +++ b/resources/commands/sysinfo/time.lua @@ -2,12 +2,10 @@ local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic time', helper) local res = jarvis.system.exec(cmd) local text = (res.stdout or ""):gsub("[\r\n]+$", "") -if res.success and text ~= "" then - jarvis.system.notify("Время", text) - jarvis.log("info", text) - jarvis.audio.play_ok() -else +if not res.success or text == "" then jarvis.log("error", "time info failed: " .. tostring(res.stderr)) - jarvis.audio.play_error() + return jarvis.cmd.error("Не получилось узнать время.") end -return { chain = false } +jarvis.system.notify("Время", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/time_tracker/command.toml b/resources/commands/time_tracker/command.toml new file mode 100644 index 0000000..8c4d6be --- /dev/null +++ b/resources/commands/time_tracker/command.toml @@ -0,0 +1,121 @@ +# Voice-driven daily time tracking. +# +# Storage: jarvis.state on this pack — a single key "data" holding +# { current_session_start = |nil, sessions = { {start=ts,end=ts}, ... } } +# +# tracker.start marks the beginning of an active session. +# tracker.stop closes the open session and pushes it onto the array. +# tracker.today / tracker.week report totals (sum of completed + +# current open session if any). +# tracker.reset wipes today's entries; speech reply confirms what was wiped. + +[[commands]] +id = "tracker.start" +type = "lua" +script = "start.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "начни отсчёт", + "начни отсчет", + "запусти трекер", + "я начинаю работу", + "начало рабочего дня", +] +en = [ + "start tracking", + "I'm starting work", + "begin tracking", + "start the tracker", +] + + +[[commands]] +id = "tracker.stop" +type = "lua" +script = "stop.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "закончи отсчёт", + "закончи отсчет", + "останови трекер", + "я закончил работу", + "конец рабочего дня", +] +en = [ + "stop tracking", + "I'm done", + "end the tracker", + "stop the tracker", +] + + +[[commands]] +id = "tracker.today" +type = "lua" +script = "today.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько я работаю сегодня", + "сколько отработал сегодня", + "сколько часов сегодня", + "сколько времени за сегодня", +] +en = [ + "how long today", + "how much have I worked today", + "hours today", + "time today", +] + + +[[commands]] +id = "tracker.week" +type = "lua" +script = "week.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько на этой неделе", + "сколько отработал на неделе", + "часы за неделю", + "статистика за неделю", +] +en = [ + "hours this week", + "how long this week", + "time this week", + "weekly hours", +] + + +[[commands]] +id = "tracker.reset" +type = "lua" +script = "reset.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сбрось трекер", + "обнули трекер", + "очисти трекер", + "сбрось отсчёт", +] +en = [ + "reset tracker", + "clear tracker", + "wipe today's tracker", + "reset today's tracker", +] diff --git a/resources/commands/time_tracker/reset.lua b/resources/commands/time_tracker/reset.lua new file mode 100644 index 0000000..3dc7c52 --- /dev/null +++ b/resources/commands/time_tracker/reset.lua @@ -0,0 +1,59 @@ +-- tracker.reset — wipe today's entries (and the open session). +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local t = jarvis.context.time +local h = tonumber(t.hour) or 0 +local mi = tonumber(t.minute) or 0 +local s = tonumber(t.second) or 0 +local today_start = now - (h * 3600 + mi * 60 + s) + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +local kept = {} +local dropped = 0 +for _, sess in ipairs(sessions) do + local sstart = tonumber(sess.start) or 0 + if sstart < today_start then + table.insert(kept, sess) + else + dropped = dropped + 1 + end +end + +local had_open = data.current_session_start ~= nil +data.sessions = kept +data.current_session_start = nil +jarvis.state.set("data", data) + +if dropped == 0 and not had_open then + return jarvis.cmd.ok(lang == "ru" + and "Сегодня сбрасывать нечего, сэр." + or "Nothing to reset today, sir.") +end + +local confirm +if lang == "ru" then + confirm = "Подтверждаю: трекер за сегодня очищен" + if dropped > 0 then + local sw + local n10 = dropped % 10; local n100 = dropped % 100 + if n100 >= 11 and n100 <= 14 then sw = "сессий" + elseif n10 == 1 then sw = "сессия" + elseif n10 >= 2 and n10 <= 4 then sw = "сессии" + else sw = "сессий" end + confirm = confirm .. ", удалено " .. dropped .. " " .. sw + end + if had_open then confirm = confirm .. ", активный отсчёт остановлен" end + confirm = confirm .. "." +else + confirm = "Confirmed: tracker for today cleared" + if dropped > 0 then + confirm = confirm .. ", " .. dropped .. " session" .. (dropped == 1 and "" or "s") .. " removed" + end + if had_open then confirm = confirm .. ", running session stopped" end + confirm = confirm .. "." +end + +return jarvis.cmd.ok(confirm) diff --git a/resources/commands/time_tracker/start.lua b/resources/commands/time_tracker/start.lua new file mode 100644 index 0000000..b1380f2 --- /dev/null +++ b/resources/commands/time_tracker/start.lua @@ -0,0 +1,22 @@ +-- tracker.start — open a new active session. +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +if data.current_session_start then + -- already running — don't overwrite; just acknowledge. + return jarvis.cmd.ok(lang == "ru" + and "Уже считаю, сэр." + or "Already tracking, sir.") +end + +data.current_session_start = now +data.sessions = sessions + +jarvis.state.set("data", data) + +return jarvis.cmd.ok(lang == "ru" + and "Отсчёт пошёл." + or "Tracking started.") diff --git a/resources/commands/time_tracker/stop.lua b/resources/commands/time_tracker/stop.lua new file mode 100644 index 0000000..c633214 --- /dev/null +++ b/resources/commands/time_tracker/stop.lua @@ -0,0 +1,41 @@ +-- tracker.stop — close the currently open session. +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +local start_ts = data.current_session_start +if not start_ts then + return jarvis.cmd.not_found(lang == "ru" + and "Трекер не запущен, сэр." + or "Tracker isn't running, sir.") +end + +local elapsed = now - start_ts +if elapsed < 1 then elapsed = 1 end + +table.insert(sessions, { start = start_ts, ["end"] = now }) + +data.current_session_start = nil +data.sessions = sessions +jarvis.state.set("data", data) + +local function format_dur(sec) + sec = math.floor(sec) + local h = math.floor(sec / 3600) + local m = math.floor((sec % 3600) / 60) + if lang == "ru" then + if h > 0 and m > 0 then return h .. " ч " .. m .. " мин" end + if h > 0 then return h .. " ч" end + return m .. " мин" + else + if h > 0 and m > 0 then return h .. "h " .. m .. "m" end + if h > 0 then return h .. "h" end + return m .. "m" + end +end + +return jarvis.cmd.ok(lang == "ru" + and ("Записал. Сессия: " .. format_dur(elapsed) .. ".") + or ("Done. Session: " .. format_dur(elapsed) .. ".")) diff --git a/resources/commands/time_tracker/today.lua b/resources/commands/time_tracker/today.lua new file mode 100644 index 0000000..8adda52 --- /dev/null +++ b/resources/commands/time_tracker/today.lua @@ -0,0 +1,81 @@ +-- tracker.today — report total active time accrued today (local-day). +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +-- Local-day boundary: use the calendar fields (already in local time). +local t = jarvis.context.time +local y = tonumber(t.year) or 1970 +local mo = tonumber(t.month) or 1 +local d = tonumber(t.day) or 1 +local h = tonumber(t.hour) or 0 +local mi = tonumber(t.minute) or 0 +local s = tonumber(t.second) or 0 + +-- Seconds since local midnight today, derived from the broken-down time. +local secs_today = h * 3600 + mi * 60 + s +local today_start = now - secs_today + +local total = 0 +for _, sess in ipairs(sessions) do + local sstart = tonumber(sess.start) or 0 + local send = tonumber(sess["end"]) or 0 + -- Overlap with [today_start, now] + local lo = math.max(sstart, today_start) + local hi = math.min(send, now) + if hi > lo then total = total + (hi - lo) end +end + +-- Include the open session, if any, up to now. +local open_start = data.current_session_start +if open_start then + local lo = math.max(tonumber(open_start) or 0, today_start) + if now > lo then total = total + (now - lo) end +end + +local function format_dur(sec) + sec = math.floor(sec) + local hh = math.floor(sec / 3600) + local mm = math.floor((sec % 3600) / 60) + if lang == "ru" then + local function h_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "часов" end + if n10 == 1 then return "час" end + if n10 >= 2 and n10 <= 4 then return "часа" end + return "часов" + end + local function m_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "минут" end + if n10 == 1 then return "минута" end + if n10 >= 2 and n10 <= 4 then return "минуты" end + return "минут" + end + if hh > 0 and mm > 0 then + return hh .. " " .. h_word(hh) .. " " .. mm .. " " .. m_word(mm) + end + if hh > 0 then return hh .. " " .. h_word(hh) end + return mm .. " " .. m_word(mm) + else + if hh > 0 and mm > 0 then return hh .. "h " .. mm .. "m" end + if hh > 0 then return hh .. "h" end + return mm .. "m" + end +end + +-- Suppress unused-warning for y/mo/d — only h/mi/s feed the calc, but having +-- y/mo/d on hand keeps the script easy to extend later. +local _ = y + mo + d + +if total < 60 then + return jarvis.cmd.ok(lang == "ru" + and "Сегодня ещё ничего не отработано, сэр." + or "Nothing tracked yet today, sir.") +end + +return jarvis.cmd.ok(lang == "ru" + and ("Сегодня отработано " .. format_dur(total) .. ".") + or ("Today: " .. format_dur(total) .. ".")) diff --git a/resources/commands/time_tracker/week.lua b/resources/commands/time_tracker/week.lua new file mode 100644 index 0000000..89e9947 --- /dev/null +++ b/resources/commands/time_tracker/week.lua @@ -0,0 +1,71 @@ +-- tracker.week — total active time over the last 7 calendar days. +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +-- Local-day boundary today, then back up 6 days to get a 7-day window. +local t = jarvis.context.time +local h = tonumber(t.hour) or 0 +local mi = tonumber(t.minute) or 0 +local s = tonumber(t.second) or 0 +local secs_today = h * 3600 + mi * 60 + s +local today_start = now - secs_today +local week_start = today_start - 6 * 86400 + +local total = 0 +for _, sess in ipairs(sessions) do + local sstart = tonumber(sess.start) or 0 + local send = tonumber(sess["end"]) or 0 + local lo = math.max(sstart, week_start) + local hi = math.min(send, now) + if hi > lo then total = total + (hi - lo) end +end + +local open_start = data.current_session_start +if open_start then + local lo = math.max(tonumber(open_start) or 0, week_start) + if now > lo then total = total + (now - lo) end +end + +local function format_dur(sec) + sec = math.floor(sec) + local hh = math.floor(sec / 3600) + local mm = math.floor((sec % 3600) / 60) + if lang == "ru" then + local function h_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "часов" end + if n10 == 1 then return "час" end + if n10 >= 2 and n10 <= 4 then return "часа" end + return "часов" + end + local function m_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "минут" end + if n10 == 1 then return "минута" end + if n10 >= 2 and n10 <= 4 then return "минуты" end + return "минут" + end + if hh > 0 and mm > 0 then + return hh .. " " .. h_word(hh) .. " " .. mm .. " " .. m_word(mm) + end + if hh > 0 then return hh .. " " .. h_word(hh) end + return mm .. " " .. m_word(mm) + else + if hh > 0 and mm > 0 then return hh .. "h " .. mm .. "m" end + if hh > 0 then return hh .. "h" end + return mm .. "m" + end +end + +if total < 60 then + return jarvis.cmd.ok(lang == "ru" + and "За эту неделю ничего не отработано, сэр." + or "Nothing tracked this week, sir.") +end + +return jarvis.cmd.ok(lang == "ru" + and ("За неделю отработано " .. format_dur(total) .. ".") + or ("This week: " .. format_dur(total) .. ".")) diff --git a/resources/commands/translate/clipboard.lua b/resources/commands/translate/clipboard.lua index a223692..cd63bdd 100644 --- a/resources/commands/translate/clipboard.lua +++ b/resources/commands/translate/clipboard.lua @@ -3,16 +3,20 @@ local phrase = (jarvis.context.phrase or ""):lower() -- Detect target language from phrase, default English. local target = "english" -if phrase:find("на русск") then target = "русский" -elseif phrase:find("на украин") then target = "украинский" -elseif phrase:find("на немецк") then target = "немецкий" -elseif phrase:find("на французск") then target = "французский" -elseif phrase:find("на испанск") then target = "испанский" -elseif phrase:find("на китайск") then target = "китайский" -elseif phrase:find("на японск") then target = "японский" -elseif phrase:find("на польск") then target = "польский" -elseif phrase:find("english") then target = "english" -elseif phrase:find("russian") then target = "русский" +if phrase:find("на русск") then target = "русский" +elseif phrase:find("на немецк") then target = "немецкий" +elseif phrase:find("на французск") then target = "французский" +elseif phrase:find("на испанск") then target = "испанский" +elseif phrase:find("на итальянск") then target = "итальянский" +elseif phrase:find("на польск") then target = "польский" +elseif phrase:find("на турецк") then target = "турецкий" +elseif phrase:find("на китайск") then target = "китайский" +elseif phrase:find("на японск") then target = "японский" +elseif phrase:find("german") then target = "german" +elseif phrase:find("french") then target = "french" +elseif phrase:find("spanish") then target = "spanish" +elseif phrase:find("english") then target = "english" +elseif phrase:find("russian") then target = "русский" end local src = jarvis.system.clipboard.get() diff --git a/resources/commands/translate/command.toml b/resources/commands/translate/command.toml index bf35210..9bbca1e 100644 --- a/resources/commands/translate/command.toml +++ b/resources/commands/translate/command.toml @@ -9,21 +9,26 @@ timeout = 20000 ru = [ "переведи на английский", "переведи на русский", - "переведи на украинский", "переведи на немецкий", "переведи на французский", "переведи на испанский", + "переведи на итальянский", + "переведи на польский", + "переведи на турецкий", "переведи на китайский", "переведи на японский", "переведи", "как по английски", "как по русски", "как по немецки", + "как по французски", ] en = [ "translate to english", "translate to russian", - "translate to ukrainian", + "translate to german", + "translate to french", + "translate to spanish", "translate", "how to say in", ] diff --git a/resources/commands/translate/translate.lua b/resources/commands/translate/translate.lua index 2e04925..db62e32 100644 --- a/resources/commands/translate/translate.lua +++ b/resources/commands/translate/translate.lua @@ -2,24 +2,23 @@ local lang = jarvis.context.language local phrase = (jarvis.context.phrase or ""):lower() local target_languages = { - ["английский"] = "English", ["english"] = "English", - ["русский"] = "Russian", ["russian"] = "Russian", - ["украинский"] = "Ukrainian", ["ukrainian"] = "Ukrainian", ["українську"] = "Ukrainian", - ["немецкий"] = "German", ["german"] = "German", ["німецьку"] = "German", - ["французский"]= "French", ["french"] = "French", - ["испанский"] = "Spanish", ["spanish"] = "Spanish", - ["итальянский"]= "Italian", - ["китайский"] = "Chinese", ["chinese"] = "Chinese", - ["японский"] = "Japanese", ["japanese"] = "Japanese", - ["польский"] = "Polish", - ["турецкий"] = "Turkish", + ["английский"] = "English", ["english"] = "English", + ["русский"] = "Russian", ["russian"] = "Russian", + ["немецкий"] = "German", ["german"] = "German", + ["французский"]= "French", ["french"] = "French", + ["испанский"] = "Spanish", ["spanish"] = "Spanish", + ["итальянский"]= "Italian", ["italian"] = "Italian", + ["китайский"] = "Chinese", ["chinese"] = "Chinese", + ["японский"] = "Japanese", ["japanese"] = "Japanese", + ["польский"] = "Polish", ["polish"] = "Polish", + ["турецкий"] = "Turkish", ["turkish"] = "Turkish", } local triggers_with_lang = { - "переведи на ", "translate to ", "переклади на ", "how to say in ", + "переведи на ", "translate to ", "how to say in ", } local generic_triggers = { - "переведи", "translate", "переклади", "как по ", "how to say", + "переведи", "translate", "как по ", "how to say", } local target = nil @@ -112,10 +111,25 @@ jarvis.system.clipboard.set(content) jarvis.system.notify(target_label, content:sub(1, 200)) jarvis.log("info", "translation: " .. content) +-- Speak the translation in the TARGET language's voice when possible, so a +-- French translation comes out with a French SAPI voice if installed. Falls +-- back silently to the default voice if there's no matching locale on the box. +local function language_iso(label) + if label == "Russian" then return "ru" end + if label == "German" then return "de" end + if label == "French" then return "fr" end + if label == "Spanish" then return "es" end + if label == "Italian" then return "it" end + if label == "Chinese" then return "zh" end + if label == "Japanese" then return "ja" end + if label == "Polish" then return "pl" end + if label == "Turkish" then return "tr" end + return "en" +end local sapi_text = content:gsub("'", "''"):gsub("\r?\n", " ") local ps = string.format( [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $iso='%s'; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq $iso) { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], - (target_label == "Russian" and "ru") or (target_label == "Ukrainian" and "uk") or "en", + language_iso(target_label), sapi_text ) jarvis.system.exec(string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"'))) diff --git a/resources/commands/wol/command.toml b/resources/commands/wol/command.toml new file mode 100644 index 0000000..73df56a --- /dev/null +++ b/resources/commands/wol/command.toml @@ -0,0 +1,31 @@ +# Wake-on-LAN — fire a magic packet at a target MAC address. The target is +# read from long-term memory under the key "wol_" so the user can have +# multiple machines. Default alias is "server". +# +# To set a target via voice: +# "запомни wol_server AA:BB:CC:DD:EE:FF" +# then later: "разбуди сервер" + +[[commands]] +id = "wol.wake" +type = "lua" +script = "wake.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "разбуди сервер", + "включи сервер", + "разбуди машину", + "разбуди компьютер", + "включи компьютер", + "wol сервер", +] +en = [ + "wake the server", + "wake server", + "wake on lan", + "wol server", + "boot the server", +] diff --git a/resources/commands/wol/wake.lua b/resources/commands/wol/wake.lua new file mode 100644 index 0000000..056231a --- /dev/null +++ b/resources/commands/wol/wake.lua @@ -0,0 +1,78 @@ +-- Wake-on-LAN — sends a magic packet to a target MAC stored in long-term memory. +-- +-- Setup (one-time, via voice): +-- "запомни wol_server AA:BB:CC:DD:EE:FF" +-- Then later, anytime: +-- "разбуди сервер" → packet goes out to broadcast. +-- +-- Lua can't open raw UDP, so we shell out to PowerShell (System.Net.Sockets). + +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +-- Figure out which alias the user wants. Right now we only support "server" +-- (the most common case) but the data model already supports more. +local alias = "server" + +local mac = jarvis.memory.recall("wol_" .. alias) +if not mac or mac == "" then + -- Fall back to env so power users can ship a default without touching memory. + mac = jarvis.system.env("JARVIS_WOL_TARGET") or "" +end + +if mac == "" then + return jarvis.cmd.error(lang == "ru" + and "Сэр, MAC-адрес не задан. Скажи: запомни wol_server, а потом адрес." + or "Sir, MAC address is not set. Say: remember wol_server, then the address.") +end + +-- Normalise: AA-BB-CC-DD-EE-FF, AA:BB:CC:DD:EE:FF, AABB.CCDD.EEFF, AABBCCDDEEFF +local cleaned = mac:gsub("[^%w]", ""):upper() +if #cleaned ~= 12 or not cleaned:match("^[0-9A-F]+$") then + return jarvis.cmd.error(lang == "ru" + and "Сэр, MAC-адрес в памяти выглядит подозрительно: " .. tostring(mac) + or "Sir, the stored MAC looks malformed: " .. tostring(mac)) +end + +-- Inject the cleaned MAC as a constant in the PowerShell script so we don't +-- have to argv-escape it. The packet is 6 × 0xFF + 16 × MAC = 102 bytes, +-- broadcast on UDP/9. +local ps_template = [[ +$mac = '%s' +$macBytes = @() +for ($i = 0; $i -lt 12; $i += 2) { + $macBytes += [Convert]::ToByte($mac.Substring($i, 2), 16) +} +$packet = New-Object byte[] 102 +for ($i = 0; $i -lt 6; $i++) { $packet[$i] = 0xFF } +for ($r = 0; $r -lt 16; $r++) { + for ($j = 0; $j -lt 6; $j++) { + $packet[6 + $r * 6 + $j] = $macBytes[$j] + } +} +$udp = New-Object System.Net.Sockets.UdpClient +$udp.EnableBroadcast = $true +$null = $udp.Send($packet, $packet.Length, '255.255.255.255', 9) +$udp.Close() +Write-Output 'sent' +]] + +local ps = string.format(ps_template, cleaned) +local cmd = 'powershell -NoProfile -ExecutionPolicy Bypass -Command "& {' + .. ps:gsub('\r?\n', '; '):gsub('"', '\\"') + .. '}"' + +jarvis.log("info", "WOL packet → " .. cleaned) +local res = jarvis.system.exec(cmd) +if not res.success then + jarvis.log("error", "WOL failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Не получилось отправить пакет." + or "Failed to send packet.") +end + +-- Format the MAC back for speech: "AA-BB-CC-..." sounds clearer than "AABB..." +local pretty = cleaned:gsub("(%w%w)", "%1-"):sub(1, -2) +return jarvis.cmd.ok(lang == "ru" + and ("Пакет отправлен на " .. pretty) + or ("Packet sent to " .. pretty))