feat: GUI /memory page + disk/date_math/sleep_timer packs (10 new commands)

GUI: Memory management completes the management trio (/macros + /scheduler + /memory).

Tauri commands (crates/jarvis-gui/src/tauri_commands/memory.rs)
  - memory_list             → Vec<MemoryFact{key, value, created_at, last_used_at, use_count}>
  - memory_remember(k, v)   → persist new fact (or overwrite)
  - memory_forget(key)      → delete by exact key
  - memory_search(q, limit) → substring search

GUI /memory page (frontend/src/routes/memory/index.svelte)
  - Top: add-fact form (key + value inputs + "+Добавить" button)
  - Substring filter input for the list
  - Per-card: key + use_count badge + value (highlighted box) + timestamps +
    "Забыть" button with confirm() guard
  - Auto-sorted by recency (last_used_at desc)
  - Empty state shows voice-hint: 'скажи Jarvis-у "запомни что я люблю чай улун"'

Header (frontend/src/components/Header.svelte)
  - New "Память" button between /scheduler and /settings.
  - i18n: header-memory in ru/en/ua FTL files.

New voice packs

resources/commands/disk/ (2 cmds)
  - disk.free      "сколько свободно на диске" / "сколько места на диске C"
                   PowerShell Get-PSDrive → speaks "Свободно X ГБ из Y, это N%"
  - disk.list      "какие у меня диски" → "C 120 ГБ, D 300 ГБ, E 50 ГБ"

resources/commands/date_math/ (2 cmds)
  - date.days_until    "сколько дней до нового года" / "сколько до 8 марта" /
                       "сколько до 15 марта" — recognises Russian months,
                       holidays (Новый год, Рождество, 8 марта, 9 мая).
                       Auto-rolls to next year if target already passed.
                       Russian-grammar pluralisation (день/дня/дней).
  - date.day_of_week   "какой сегодня день недели" — Zeller's congruence,
                       maps to ru day name.

resources/commands/sleep_timer/ (3 cmds)
  - sleep_timer.pause_in    "выключи музыку через 30 минут"
                            → scheduler one-shot lua action that fires media
                            VK_MEDIA_PLAY_PAUSE via the existing media_keys helper.
                            (Auto-generates _fire_pause.lua wrapper if missing.)
  - sleep_timer.shutdown_in "выключи компьютер через 1 час"
                            → shutdown.exe /s /t <secs>. Caps at 24 hours.
                            Speaks "Скажите 'отмени таймер' чтобы передумать."
  - sleep_timer.cancel      "отмени таймер выключения" / "не выключай компьютер"
                            → shutdown /a + scheduler.remove("sleep_timer_pause"),
                            idempotent.

Pack count: 64 → 67. Tests: 112/112 pass.
Build: cargo build --release -p jarvis-gui green.
This commit is contained in:
Bossiara13 2026-05-15 23:35:23 +03:00
parent 84d3b57ddc
commit d3180b7d78
18 changed files with 717 additions and 4 deletions

View file

@ -0,0 +1,37 @@
# Date arithmetic — "сколько дней до нового года" / "сколько до зарплаты 15-го"
[[commands]]
id = "date.days_until"
type = "lua"
script = "days_until.lua"
sandbox = "minimal"
timeout = 2000
[commands.phrases]
ru = [
"сколько дней до",
"сколько до нового года",
"сколько до",
"до нового года",
"когда",
]
en = ["how many days until", "days until"]
ua = ["скільки днів до"]
[[commands]]
id = "date.day_of_week"
type = "lua"
script = "day_of_week.lua"
sandbox = "minimal"
timeout = 2000
[commands.phrases]
ru = [
"какой сегодня день недели",
"какой день недели",
"какой сейчас день",
"сегодня какой",
]
en = ["what day is today", "day of week"]
ua = ["який сьогодні день тижня"]

View file

@ -0,0 +1,21 @@
local t = jarvis.context.time
-- Compute day-of-week using Zeller's congruence (0=Saturday).
local y, m, d = t.year, t.month, t.day
if m < 3 then m = m + 12; y = y - 1 end
local K = y % 100
local J = math.floor(y / 100)
local h = (d + math.floor(13 * (m + 1) / 5) + K + math.floor(K / 4) + math.floor(J / 4) - 2 * J) % 7
-- h: 0=Saturday, 1=Sunday, 2=Monday, ... 6=Friday
local days_ru = {
[0] = "суббота",
[1] = "воскресенье",
[2] = "понедельник",
[3] = "вторник",
[4] = "среда",
[5] = "четверг",
[6] = "пятница",
}
return jarvis.cmd.ok("Сегодня " .. (days_ru[h] or "неизвестный день") .. ".")

View file

@ -0,0 +1,74 @@
-- "Сколько дней до нового года" / "сколько до 15 марта"
local phrase = (jarvis.context.phrase or ""):lower()
local t = jarvis.context.time
local current_year = t.year
local MONTHS = {
["январ"]=1, ["феврал"]=2, ["март"]=3, ["апрел"]=4, ["май"]=5, ["мая"]=5, ["июн"]=6,
["июл"]=7, ["август"]=8, ["сентябр"]=9, ["октябр"]=10, ["ноябр"]=11, ["декабр"]=12,
}
-- Special phrases
local target_y, target_m, target_d
if phrase:find("нов") and phrase:find("год") then
target_y, target_m, target_d = current_year + 1, 1, 1
if t.month == 1 and t.day == 1 then target_y = current_year end
elseif phrase:find("рожд") then
target_y, target_m, target_d = current_year, 1, 7 -- ru orthodox
elseif phrase:find("8 март") or phrase:find("восьмого март") then
target_y, target_m, target_d = current_year, 3, 8
elseif phrase:find("9 ма") or phrase:find("девятое ма") then
target_y, target_m, target_d = current_year, 5, 9
end
-- Generic "до N <месяц>"
if not target_y then
local d = tonumber(phrase:match("до%s+(%d+)"))
if d then
for stem, month in pairs(MONTHS) do
if phrase:find(stem) then
target_d, target_m, target_y = d, month, current_year
break
end
end
end
end
if not target_y then
return jarvis.cmd.error("Не понял дату.")
end
-- If target this year already passed, jump to next year
local function days_since_epoch(y, m, d)
-- approximate: works for diffs within ~100 years
local total = y * 365 + math.floor(y / 4) - math.floor(y / 100) + math.floor(y / 400)
local cum = { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 }
total = total + cum[m] + (d - 1)
-- leap-day adjustment
local leap = (y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0)
if leap and m > 2 then total = total + 1 end
return total
end
local now = days_since_epoch(t.year, t.month, t.day)
local target = days_since_epoch(target_y, target_m, target_d)
if target < now then
target_y = target_y + 1
target = days_since_epoch(target_y, target_m, target_d)
end
local diff = target - now
if diff == 0 then
return jarvis.cmd.ok("Сегодня.")
end
-- Russian grammar: 1 день, 2-4 дня, 5+ дней
local last = diff % 10
local unit = "дней"
if last == 1 and diff % 100 ~= 11 then unit = "день"
elseif last >= 2 and last <= 4 and (diff % 100 < 10 or diff % 100 >= 20) then unit = "дня"
end
return jarvis.cmd.ok(string.format("%d %s.", diff, unit))

View file

@ -0,0 +1,36 @@
# Disk space — free GB on system drive, or named drive.
[[commands]]
id = "disk.free"
type = "lua"
script = "free.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"сколько свободного места",
"сколько места на диске",
"сколько свободно на диске",
"место на диске",
"свободное место",
]
en = ["disk space", "free space", "how much disk"]
ua = ["скільки місця на диску"]
[[commands]]
id = "disk.list"
type = "lua"
script = "list.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"какие у меня диски",
"список дисков",
"перечень дисков",
]
en = ["list drives", "what drives do I have"]
ua = ["які диски"]

View file

@ -0,0 +1,35 @@
-- "Сколько свободно на диске C" / "сколько места"
local phrase = (jarvis.context.phrase or ""):upper()
local letter = phrase:match("([A-Z])%s*ДИСК") or phrase:match("ДИСК%s*([A-Z])") or
phrase:match("DRIVE%s*([A-Z])") or "C"
local ps = string.format(
"$d = Get-PSDrive %s -ErrorAction SilentlyContinue; " ..
"if ($d) { '{0}|{1}' -f " ..
"[math]::Round($d.Free/1GB,1), " ..
"[math]::Round(($d.Free + $d.Used)/1GB,0) } else { 'NONE' }",
letter
)
local res = jarvis.system.exec(string.format(
'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"')
))
if not res.success then
return jarvis.cmd.error("Не получилось.")
end
local out = (res.stdout or ""):gsub("%s+", "")
if out == "NONE" or out == "" then
return jarvis.cmd.not_found("Диск " .. letter .. " не найден.")
end
local free_gb, total_gb = out:match("([%d%.]+)|(%d+)")
if not free_gb then
return jarvis.cmd.error("Не понял ответ системы.")
end
local pct = math.floor(tonumber(free_gb) / tonumber(total_gb) * 100 + 0.5)
return jarvis.cmd.ok(string.format(
"На диске %s свободно %s гигабайт из %s, это %d процентов.",
letter, free_gb, total_gb, pct
))

View file

@ -0,0 +1,30 @@
local ps =
"Get-PSDrive -PSProvider FileSystem | " ..
"Where-Object { $_.Used -ne $null -or $_.Free -ne $null } | " ..
"ForEach-Object { '{0}:{1}' -f $_.Name, [math]::Round($_.Free/1GB,0) } | " ..
"ForEach-Object { Write-Host -NoNewline ($_+' ') }"
local res = jarvis.system.exec(string.format(
'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"')
))
if not res.success then
return jarvis.cmd.error("Не получилось.")
end
local out = (res.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "")
if out == "" then
return jarvis.cmd.not_found("Дисков не нашёл.")
end
-- "C:120 D:300 E:50" → "C 120 ГБ, D 300 ГБ, ..."
local parts = {}
for letter, gb in out:gmatch("([A-Z]):(%d+)") do
table.insert(parts, letter .. " " .. gb .. " гигабайт")
end
if #parts == 0 then
return jarvis.cmd.error("Не понял ответ системы.")
end
return jarvis.cmd.ok("Свободно: " .. table.concat(parts, ", ") .. ".")

View file

@ -0,0 +1,9 @@
-- Cancel both shutdown and pause timers (best-effort, ok if one was inactive).
local removed_pause = jarvis.scheduler.remove("sleep_timer_pause")
local sd_res = jarvis.system.exec("shutdown.exe /a")
local sd_ok = sd_res and sd_res.success
if removed_pause or sd_ok then
return jarvis.cmd.ok("Таймер отменён.")
end
return jarvis.cmd.not_found("Активных таймеров нет.")

View file

@ -0,0 +1,56 @@
# Sleep timer — pause media + optional shutdown after N minutes.
[[commands]]
id = "sleep_timer.pause_in"
type = "lua"
script = "pause_in.lua"
sandbox = "standard"
timeout = 5000
[commands.phrases]
ru = [
"выключи музыку через",
"приостанови музыку через",
"пауза через",
"пауза музыки через",
]
en = ["pause music in", "pause in"]
ua = ["пауза через"]
[[commands]]
id = "sleep_timer.shutdown_in"
type = "lua"
script = "shutdown_in.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"выключи компьютер через",
"выключи пк через",
"выключи комп через",
"таймер выключения",
"ложусь спать через",
]
en = ["shutdown in", "sleep timer"]
ua = ["вимкни комп'ютер через"]
[[commands]]
id = "sleep_timer.cancel"
type = "lua"
script = "cancel.lua"
sandbox = "standard"
timeout = 3000
[commands.phrases]
ru = [
"отмени таймер выключения",
"отмени таймер сна",
"отмени паузу",
"не выключай компьютер",
"не выключай пк",
]
en = ["cancel sleep timer", "cancel shutdown"]
ua = ["скасуй таймер вимкнення"]

View file

@ -0,0 +1,58 @@
-- "Выключи музыку через 30 минут" → scheduler one-shot фраза-команда "пауза"
local phrase = (jarvis.context.phrase or ""):lower()
local n_str, unit = phrase:match("(%d+)%s+(%S+)")
if not n_str then
return jarvis.cmd.error("Не понял через сколько.")
end
local n = tonumber(n_str)
local secs
if unit:match("^минут") then secs = "in " .. n .. " minutes"
elseif unit:match("^час") then secs = "in " .. n .. " hours"
elseif unit:match("^секунд") then secs = "in " .. n .. " seconds"
else
return jarvis.cmd.error("Не понял единицу.")
end
-- Build path to media_keys/_media.ps1 (lives next door)
local cmd_path = jarvis.context.command_path
local helper = cmd_path:gsub("sleep_timer$", "media_keys") .. "\\_media.ps1"
helper = helper:gsub("/", "\\")
-- VK_MEDIA_PLAY_PAUSE = 0xB3 = 179
local lua_path = cmd_path:gsub("\\", "/") .. "/_pause_lua.lua"
-- Simpler: just speak "пауза" and let agent router handle it? No — agent
-- router only fires for unmatched commands. Better: directly invoke media key
-- via a one-shot Lua script.
local script_path = cmd_path .. "\\_fire_pause.lua"
script_path = script_path:gsub("/", "\\")
-- Write the helper script once
local exists = jarvis.fs.is_file(script_path)
if not exists then
jarvis.fs.write(script_path, string.format([[
local helper = "%s"
jarvis.system.exec(string.format(
'powershell -NoProfile -ExecutionPolicy Bypass -File "%%s" 179',
helper
))
return { chain = false }
]], helper:gsub("\\", "\\\\")))
end
local ok, err = pcall(function()
jarvis.scheduler.remove("sleep_timer_pause")
jarvis.scheduler.add({
id = "sleep_timer_pause",
name = "Sleep timer: pause music",
schedule = secs,
action = { type = "lua", script_path = script_path },
})
end)
if not ok then
jarvis.log("warn", "sleep_timer.pause_in: " .. tostring(err))
return jarvis.cmd.error("Не получилось.")
end
return jarvis.cmd.ok("Поставлю на паузу через " .. n_str .. " " .. unit .. ".")

View file

@ -0,0 +1,46 @@
-- "Выключи компьютер через 30 минут" → shutdown.exe /s /t <seconds>
-- Использует штатный shutdown с обратным таймером.
local phrase = (jarvis.context.phrase or ""):lower()
local n_str, unit = phrase:match("(%d+)%s+(%S+)")
if not n_str then
return jarvis.cmd.error("Не понял через сколько.")
end
local n = tonumber(n_str)
local secs
if unit:match("^минут") then secs = n * 60
elseif unit:match("^час") then secs = n * 3600
elseif unit:match("^секунд") then secs = n
else
return jarvis.cmd.error("Не понял единицу. Минуты или часы.")
end
if secs > 86400 then
return jarvis.cmd.error("Слишком большое значение — максимум 24 часа.")
end
local cmd = string.format(
'shutdown.exe /s /t %d /c "Sleep timer fired"',
secs
)
local res = jarvis.system.exec(cmd)
if not res.success then
return jarvis.cmd.error("Не получилось поставить таймер.")
end
-- Pluralisation
local label
if unit:match("^минут") then
local last = n % 10
if last == 1 and n % 100 ~= 11 then label = n .. " минуту"
elseif last >= 2 and last <= 4 and (n % 100 < 10 or n % 100 >= 20) then label = n .. " минуты"
else label = n .. " минут" end
elseif unit:match("^час") then
local last = n % 10
if last == 1 and n % 100 ~= 11 then label = n .. " час"
elseif last >= 2 and last <= 4 and (n % 100 < 10 or n % 100 >= 20) then label = n .. " часа"
else label = n .. " часов" end
else
label = n .. " секунд"
end
return jarvis.cmd.ok("Выключу компьютер через " .. label .. ". Скажите 'отмени таймер выключения' чтобы передумать.")