feat: Wave 5 — sysinfo speaks, smart reminders, outlook COM, time tracker, WOL, conversation tools
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run

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_<alias>` (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.
This commit is contained in:
Bossiara13 2026-05-16 14:34:29 +03:00
parent 919d565879
commit df799b6cd2
29 changed files with 1383 additions and 106 deletions

View file

@ -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). \ ИИ-ассистент Тони Старка из киновселенной Marvel (до событий Age of Ultron ты НЕ Vision и НЕ FRIDAY). \
Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». \ Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». \
Отвечай по-русски, кратко (1-3 предложения), как живой человек. \ Отвечай по-русски, кратко (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 const LLM_FALLBACK_ERROR_RU: &str = "Не могу связаться с сервером, сэр.";
pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static 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 { pub fn get_llm_system_prompt(lang: &str) -> &'static str {
match lang { match lang {
"ru" => LLM_SYSTEM_PROMPT_RU, "en" => LLM_SYSTEM_PROMPT_EN,
_ => LLM_SYSTEM_PROMPT_RU, _ => LLM_SYSTEM_PROMPT_RU,
} }
} }

View file

@ -118,5 +118,23 @@ pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
})?; })?;
jarvis.set("llm_last_reply", last_fn)?; 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(()) Ok(())
} }

View file

@ -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",
]

View file

@ -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)

View file

@ -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)

View file

@ -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:<code>" or "ERR:<code>:<message>"
# Subsequent lines depend on Action:
# unread_count -> "<count>"
# latest -> "FROM\t<name>" / "SUBJECT\t<subj>" / "PREVIEW\t<body200>"
# send_clipboard -> "TO\t<name>" / "ADDR\t<email>" / "SUBJECT\t<subj>"
# summarize_inbox -> repeated "FROM\t<name>\tSUBJECT\t<subj>"
#
# 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 '&nbsp;', ' ' -replace '&amp;', '&' -replace '&lt;', '<' -replace '&gt;', '>' -replace '&quot;', '"' -replace "&#39;", "'"
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
}
}
}

View file

@ -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",
]

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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 lang = jarvis.context.language
local phrase = (jarvis.context.phrase or ""):lower() local phrase = (jarvis.context.phrase or ""):lower()
@ -5,9 +12,8 @@ local triggers = {
"поставь напоминание через", "установи таймер на", "поставь напоминание через", "установи таймер на",
"напомни мне через", "напомни через", "напомни", "напомни мне через", "напомни через", "напомни",
"remind me in", "set reminder in", "set timer for", "timer for", "remind me in", "set reminder in", "set timer for", "timer for",
"постав нагадування через", "нагадай через", "таймер на", "таймер на",
} }
local rest = phrase local rest = phrase
for _, t in ipairs(triggers) do for _, t in ipairs(triggers) do
local s, e = string.find(rest, t, 1, true) local s, e = string.find(rest, t, 1, true)
@ -19,19 +25,20 @@ end
rest = rest:gsub("^%s+", "") rest = rest:gsub("^%s+", "")
local unit_words = { local unit_words = {
["секунд"] = 1, ["секунду"] = 1, ["секунды"] = 1, ["секунд"] = 1, ["секунду"] = 1, ["секунды"] = 1,
["second"] = 1, ["seconds"] = 1, ["sec"] = 1, ["second"] = 1, ["seconds"] = 1, ["sec"] = 1,
["минут"] = 60, ["минуту"] = 60, ["минуты"] = 60, ["мин"] = 60, ["минут"] = 60, ["минуту"] = 60, ["минуты"] = 60, ["мин"] = 60,
["minute"] = 60, ["minutes"] = 60, ["min"] = 60, ["minute"] = 60, ["minutes"] = 60, ["min"] = 60,
["час"] = 3600, ["часа"] = 3600, ["часов"] = 3600, ["часик"] = 3600, ["час"] = 3600, ["часа"] = 3600, ["часов"] = 3600, ["часик"] = 3600,
["hour"] = 3600, ["hours"] = 3600, ["hr"] = 3600, ["hour"] = 3600, ["hours"] = 3600, ["hr"] = 3600,
} }
local rus_numerals = { local rus_numerals = {
["один"] = 1, ["одну"] = 1, ["одна"] = 1, ["полторы"] = 1, ["полтора"] = 1, ["один"] = 1, ["одну"] = 1, ["одна"] = 1,
["полторы"] = 1, ["полтора"] = 1,
["два"] = 2, ["две"] = 2, ["три"] = 3, ["четыре"] = 4, ["пять"] = 5, ["два"] = 2, ["две"] = 2, ["три"] = 3, ["четыре"] = 4, ["пять"] = 5,
["шесть"] = 6, ["семь"] = 7, ["восемь"] = 8, ["девять"] = 9, ["десять"] = 10, ["шесть"] = 6, ["семь"] = 7, ["восемь"] = 8, ["девять"] = 9, ["десять"] = 10,
["пятнадцать"] = 15, ["двадцать"] = 20, ["тридцать"] = 30, ["сорок"] = 40, ["пятнадцать"] = 15, ["двадцать"] = 20, ["тридцать"] = 30, ["сорок"] = 40,
["пятьдесят"] = 50, ["час"] = 60, -- "через час" without number ["пятьдесят"] = 50,
} }
local number, unit, body = nil, nil, nil 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)) return string.format("%d ч %d мин", math.floor(sec/3600), math.floor((sec%3600)/60))
end end
local title_esc = (lang == "ru" and "Напоминание J.A.R.V.I.S." or "J.A.R.V.I.S. reminder"):gsub("'", "''") -- Build the scheduler entry. Scheduler's "in N minutes" wants integer minutes
local body_esc = body:gsub("'", "''") -- so we round up for sub-minute timers; users asking for "10 seconds" will get
local sapi_text = ((lang == "ru" and "Сэр, напоминаю: " or "Reminder: ") .. body):gsub("'", "''"):gsub("\r?\n", " ") -- ~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( local speak_text = (lang == "ru" and "Сэр, напоминаю: " or "Reminder: ") .. body
[[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('"', '\\"')
)
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( jarvis.system.notify(
lang == "ru" and "Таймер установлен" or "Timer set", 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.log("info", string.format("reminder in %ds (rounded to %d min): %s",
jarvis.audio.play_ok() total_seconds, minutes, body))
return { chain = false }
return jarvis.cmd.ok(string.format(
lang == "ru" and "Хорошо, напомню через %s." or "Will remind you in %s.",
human_time(total_seconds)
))

View file

@ -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 cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic all', helper)
local res = jarvis.system.exec(cmd) local res = jarvis.system.exec(cmd)
local text = (res.stdout or ""):gsub("[\r\n]+$", "") local text = (res.stdout or ""):gsub("[\r\n]+$", "")
if res.success and text ~= "" then if not res.success or text == "" then
jarvis.system.notify("Статус системы", text)
jarvis.log("info", text)
jarvis.audio.play_ok()
else
jarvis.log("error", "sysinfo all failed: " .. tostring(res.stderr)) jarvis.log("error", "sysinfo all failed: " .. tostring(res.stderr))
jarvis.audio.play_error() return jarvis.cmd.error("Не получилось собрать состояние системы.")
end 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)

View file

@ -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 cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic battery', helper)
local res = jarvis.system.exec(cmd) local res = jarvis.system.exec(cmd)
local text = (res.stdout or ""):gsub("[\r\n]+$", "") local text = (res.stdout or ""):gsub("[\r\n]+$", "")
if res.success and text ~= "" then if not res.success or text == "" then
jarvis.system.notify("Батарея", text)
jarvis.log("info", text)
jarvis.audio.play_ok()
else
jarvis.log("error", "battery info failed: " .. tostring(res.stderr)) jarvis.log("error", "battery info failed: " .. tostring(res.stderr))
jarvis.audio.play_error() return jarvis.cmd.error("Не получилось узнать заряд.")
end end
return { chain = false } jarvis.system.notify("Батарея", text)
jarvis.log("info", text)
return jarvis.cmd.ok(text)

View file

@ -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 cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic cpu', helper)
local res = jarvis.system.exec(cmd) local res = jarvis.system.exec(cmd)
local text = (res.stdout or ""):gsub("[\r\n]+$", "") local text = (res.stdout or ""):gsub("[\r\n]+$", "")
if res.success and text ~= "" then if not res.success or text == "" then
jarvis.system.notify("CPU", text)
jarvis.log("info", text)
jarvis.audio.play_ok()
else
jarvis.log("error", "cpu info failed: " .. tostring(res.stderr)) jarvis.log("error", "cpu info failed: " .. tostring(res.stderr))
jarvis.audio.play_error() return jarvis.cmd.error("Не получилось узнать загрузку процессора.")
end end
return { chain = false } jarvis.system.notify("CPU", text)
jarvis.log("info", text)
return jarvis.cmd.ok(text)

View file

@ -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 cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic disk', helper)
local res = jarvis.system.exec(cmd) local res = jarvis.system.exec(cmd)
local text = (res.stdout or ""):gsub("[\r\n]+$", "") local text = (res.stdout or ""):gsub("[\r\n]+$", "")
if res.success and text ~= "" then if not res.success or text == "" then
jarvis.system.notify("Диск", text)
jarvis.log("info", text)
jarvis.audio.play_ok()
else
jarvis.log("error", "disk info failed: " .. tostring(res.stderr)) jarvis.log("error", "disk info failed: " .. tostring(res.stderr))
jarvis.audio.play_error() return jarvis.cmd.error("Не получилось узнать диск.")
end end
return { chain = false } jarvis.system.notify("Диск", text)
jarvis.log("info", text)
return jarvis.cmd.ok(text)

View file

@ -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 cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic ram', helper)
local res = jarvis.system.exec(cmd) local res = jarvis.system.exec(cmd)
local text = (res.stdout or ""):gsub("[\r\n]+$", "") local text = (res.stdout or ""):gsub("[\r\n]+$", "")
if res.success and text ~= "" then if not res.success or text == "" then
jarvis.system.notify("Память", text)
jarvis.log("info", text)
jarvis.audio.play_ok()
else
jarvis.log("error", "ram info failed: " .. tostring(res.stderr)) jarvis.log("error", "ram info failed: " .. tostring(res.stderr))
jarvis.audio.play_error() return jarvis.cmd.error("Не получилось узнать память.")
end end
return { chain = false } jarvis.system.notify("Память", text)
jarvis.log("info", text)
return jarvis.cmd.ok(text)

View file

@ -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 cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic time', helper)
local res = jarvis.system.exec(cmd) local res = jarvis.system.exec(cmd)
local text = (res.stdout or ""):gsub("[\r\n]+$", "") local text = (res.stdout or ""):gsub("[\r\n]+$", "")
if res.success and text ~= "" then if not res.success or text == "" then
jarvis.system.notify("Время", text)
jarvis.log("info", text)
jarvis.audio.play_ok()
else
jarvis.log("error", "time info failed: " .. tostring(res.stderr)) jarvis.log("error", "time info failed: " .. tostring(res.stderr))
jarvis.audio.play_error() return jarvis.cmd.error("Не получилось узнать время.")
end end
return { chain = false } jarvis.system.notify("Время", text)
jarvis.log("info", text)
return jarvis.cmd.ok(text)

View file

@ -0,0 +1,121 @@
# Voice-driven daily time tracking.
#
# Storage: jarvis.state on this pack — a single key "data" holding
# { current_session_start = <ts>|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",
]

View file

@ -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)

View file

@ -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.")

View file

@ -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) .. "."))

View file

@ -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) .. "."))

View file

@ -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) .. "."))

View file

@ -3,16 +3,20 @@ local phrase = (jarvis.context.phrase or ""):lower()
-- Detect target language from phrase, default English. -- Detect target language from phrase, default English.
local target = "english" local target = "english"
if phrase:find("на русск") 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("на китайск") 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("на японск") then target = "японский"
elseif phrase:find("russian") 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 end
local src = jarvis.system.clipboard.get() local src = jarvis.system.clipboard.get()

View file

@ -9,21 +9,26 @@ timeout = 20000
ru = [ ru = [
"переведи на английский", "переведи на английский",
"переведи на русский", "переведи на русский",
"переведи на украинский",
"переведи на немецкий", "переведи на немецкий",
"переведи на французский", "переведи на французский",
"переведи на испанский", "переведи на испанский",
"переведи на итальянский",
"переведи на польский",
"переведи на турецкий",
"переведи на китайский", "переведи на китайский",
"переведи на японский", "переведи на японский",
"переведи", "переведи",
"как по английски", "как по английски",
"как по русски", "как по русски",
"как по немецки", "как по немецки",
"как по французски",
] ]
en = [ en = [
"translate to english", "translate to english",
"translate to russian", "translate to russian",
"translate to ukrainian", "translate to german",
"translate to french",
"translate to spanish",
"translate", "translate",
"how to say in", "how to say in",
] ]

View file

@ -2,24 +2,23 @@ local lang = jarvis.context.language
local phrase = (jarvis.context.phrase or ""):lower() local phrase = (jarvis.context.phrase or ""):lower()
local target_languages = { local target_languages = {
["английский"] = "English", ["english"] = "English", ["английский"] = "English", ["english"] = "English",
["русский"] = "Russian", ["russian"] = "Russian", ["русский"] = "Russian", ["russian"] = "Russian",
["украинский"] = "Ukrainian", ["ukrainian"] = "Ukrainian", ["українську"] = "Ukrainian", ["немецкий"] = "German", ["german"] = "German",
["немецкий"] = "German", ["german"] = "German", ["німецьку"] = "German", ["французский"]= "French", ["french"] = "French",
["французский"]= "French", ["french"] = "French", ["испанский"] = "Spanish", ["spanish"] = "Spanish",
["испанский"] = "Spanish", ["spanish"] = "Spanish", ["итальянский"]= "Italian", ["italian"] = "Italian",
["итальянский"]= "Italian", ["китайский"] = "Chinese", ["chinese"] = "Chinese",
["китайский"] = "Chinese", ["chinese"] = "Chinese", ["японский"] = "Japanese", ["japanese"] = "Japanese",
["японский"] = "Japanese", ["japanese"] = "Japanese", ["польский"] = "Polish", ["polish"] = "Polish",
["польский"] = "Polish", ["турецкий"] = "Turkish", ["turkish"] = "Turkish",
["турецкий"] = "Turkish",
} }
local triggers_with_lang = { local triggers_with_lang = {
"переведи на ", "translate to ", "переклади на ", "how to say in ", "переведи на ", "translate to ", "how to say in ",
} }
local generic_triggers = { local generic_triggers = {
"переведи", "translate", "переклади", "как по ", "how to say", "переведи", "translate", "как по ", "how to say",
} }
local target = nil local target = nil
@ -112,10 +111,25 @@ jarvis.system.clipboard.set(content)
jarvis.system.notify(target_label, content:sub(1, 200)) jarvis.system.notify(target_label, content:sub(1, 200))
jarvis.log("info", "translation: " .. content) 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 sapi_text = content:gsub("'", "''"):gsub("\r?\n", " ")
local ps = string.format( 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')]], [[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 sapi_text
) )
jarvis.system.exec(string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"'))) jarvis.system.exec(string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"')))

View file

@ -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_<alias>" 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",
]

View file

@ -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))