Personality pack (`resources/commands/personality/`):
- 5 voice commands × ~7-29 phrases each across RU and EN.
- personality.greet: 4 time-of-day buckets (morning/midday/evening/night),
pulls one of ~7 lines per bucket per language.
- personality.thanks / .compliment / .how_are_you / .tony_quote.
- how_are_you embeds live memory size + active profile via jarvis.health()
and jarvis.memory.all() for a "feels alive" effect.
- All use jarvis.cmd.ok helpers, no inline PowerShell SAPI.
- Built by sub-agent. Verified: 6 rust command tests + 60 python tests.
Idle banter (`crates/jarvis-core/src/idle_banter.rs`):
- Background thread chimes in periodically without being asked. Gated by
JARVIS_IDLE_BANTER env (default OFF — intrusion is opt-in).
- Quiet hours 23:00–07:00, skipped under "sleep" profile, paused during
active interactions via `pause()`.
- 30+ static offline lines split into RU/EN × morning/evening/generic
buckets — no network required.
- Lua API jarvis.banter.{fire, pause, resume, enabled}.
- New voice pack `banter/` exposes "скажи что-нибудь интересное",
"помолчи", "можешь говорить".
- 6 unit tests covering pool selection, quiet hours, interval clamp,
pause/resume, opt-in default.
Conversation continuity (`crates/jarvis-core/src/llm/history.rs`):
- New `ConversationHistory::with_persistence(path)` builder. Every
push/clear/pop atomically writes to `<APP_CONFIG_DIR>/llm_history.json`
so daemon restart picks up the thread.
- System prompt is intentionally NOT persisted — comes from current init
call so prompt edits take effect immediately on restart.
- `llm::init_history` wires the path in automatically.
- 4 new tests: round-trip, clear wipes file, corrupt file tolerated,
len/is_empty helpers.
Offline-first math (`resources/commands/math/math.lua`):
- Was: always-LLM, hard fail without GROQ_TOKEN, inline PowerShell SAPI.
- Now: shunting-yard parser handles 95% of voice queries in <50ms — no
network, no token. Russian operator words ("плюс", "умножить на",
"в степени", "квадрат", ...) normalised to symbols first. Patterns
for "корень из X" and "X процентов от Y". Falls back to LLM only on
parse failure (word problems / equations / unit conversions).
- Drops inline PowerShell — speaks via jarvis.cmd.ok.
- 10-case shunting-yard kernel test added (basic ops, precedence,
parens, unary minus, div-by-zero, garbage rejected).
DuckDuckGo Instant Answer (`resources/commands/ddg_answer/`):
- New pack — short factual Q&A without API key. Trigger phrases
"что такое", "кто такой", "расскажи про", "what is", etc.
- Reads AbstractText → Answer → Definition → RelatedTopics[0] in order
from DDG's free JSON API. Opens the search page only if nothing
useful comes back.
- Sandbox full (needs http + system.open).
Tests: 128 → 139 (+11). Release build green.
99 lines
3.9 KiB
Lua
99 lines
3.9 KiB
Lua
-- DuckDuckGo Instant Answer — short factual lookups WITHOUT an API key.
|
||
--
|
||
-- Endpoint: https://api.duckduckgo.com/?q=<query>&format=json&no_html=1
|
||
--
|
||
-- DDG returns a JSON object. We look at, in order:
|
||
-- 1. `AbstractText` — Wikipedia-style summary, usually 1–3 sentences
|
||
-- 2. `Answer` — direct answer for math / conversion / calc queries
|
||
-- 3. `Definition` — dictionary definitions
|
||
-- 4. `RelatedTopics[1].Text` — first related topic as last resort
|
||
--
|
||
-- If nothing useful comes back, we open the regular DDG search page so the
|
||
-- user can read it themselves.
|
||
|
||
local lang = jarvis.context.language
|
||
local raw = (jarvis.context.phrase or ""):lower()
|
||
|
||
local triggers = {
|
||
"что такое", "кто такой", "кто такая",
|
||
"расскажи про", "расскажи о",
|
||
"найди информацию о", "найди информацию про",
|
||
"поищи в интернете", "поищи в сети",
|
||
"погугли что такое",
|
||
"what is", "who is", "tell me about", "look up", "search the web for",
|
||
}
|
||
local query = jarvis.text.strip_trigger(raw, triggers)
|
||
if not query or query == "" then query = raw end
|
||
query = query:gsub("^%s+", ""):gsub("%s+$", "")
|
||
|
||
if query == "" then
|
||
return jarvis.cmd.not_found(lang == "ru" and "Что искать?" or "What to look up?")
|
||
end
|
||
|
||
-- URL-encode (basic — DDG accepts both UTF-8 and percent-encoded)
|
||
local function url_encode(s)
|
||
return (s:gsub("([^%w%-_%.~])", function(c)
|
||
return string.format("%%%02X", string.byte(c))
|
||
end))
|
||
end
|
||
|
||
local url = "https://api.duckduckgo.com/?q="
|
||
.. url_encode(query)
|
||
.. "&format=json&no_html=1&skip_disambig=1"
|
||
|
||
jarvis.log("info", "ddg query: " .. query)
|
||
local res = jarvis.http.get(url, { ["User-Agent"] = "Mozilla/5.0 J.A.R.V.I.S." })
|
||
|
||
if not res or not res.ok then
|
||
return jarvis.cmd.error(lang == "ru" and "DDG не отвечает." or "DDG didn't respond.")
|
||
end
|
||
|
||
local body = res.body or ""
|
||
|
||
-- Helper: pull the first top-level JSON string field. Quick-and-dirty regex
|
||
-- works because DDG never nests the fields we care about, AND because
|
||
-- consequent `"` inside values are escaped as `\"`.
|
||
local function field(name)
|
||
local pat = '"' .. name .. '"%s*:%s*"((\\.[^"]*|[^"])*)"'
|
||
local v = body:match('"' .. name .. '"%s*:%s*"(.-)"')
|
||
if not v or v == "" then return nil end
|
||
-- unescape common JSON sequences
|
||
v = v:gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\n', ' '):gsub('\\t', ' ')
|
||
-- trim, collapse whitespace
|
||
v = v:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||
if v == "" then return nil end
|
||
return v
|
||
end
|
||
|
||
-- First related topic — array element is itself an object with "Text".
|
||
local function first_related()
|
||
-- Find start of "RelatedTopics" array
|
||
local start_idx = body:find('"RelatedTopics"%s*:%s*%[')
|
||
if not start_idx then return nil end
|
||
-- Look for the first Text inside that array
|
||
local v = body:sub(start_idx):match('"Text"%s*:%s*"(.-)"')
|
||
if not v or v == "" then return nil end
|
||
v = v:gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\n', ' '):gsub('\\t', ' ')
|
||
v = v:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||
-- Trim absurdly long blurbs to the first ~300 chars
|
||
if #v > 320 then v = v:sub(1, 300) .. "…" end
|
||
return v
|
||
end
|
||
|
||
local answer =
|
||
field("AbstractText")
|
||
or field("Answer")
|
||
or field("Definition")
|
||
or first_related()
|
||
|
||
if answer then
|
||
jarvis.log("info", "ddg answer found, " .. #answer .. " chars")
|
||
return jarvis.cmd.ok(answer)
|
||
end
|
||
|
||
-- Nothing useful — open the search page so the user can read raw results.
|
||
local fallback = "https://duckduckgo.com/?q=" .. url_encode(query)
|
||
jarvis.system.open(fallback)
|
||
return jarvis.cmd.ok(lang == "ru"
|
||
and ("Ничего конкретного не нашёл, открываю поиск по запросу: " .. query)
|
||
or ("Nothing direct found, opening search for: " .. query))
|