diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-26 12:36:47 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-26 12:36:47 +0200 |
| commit | f5948fc5a6ec7ca91c6f454ca119a453a0ecb1a8 (patch) | |
| tree | 2a26efd65952afb21f6694d1f25ea1190bdf407c | |
| parent | 746e8c05512675b1c059d73f5f54d977d5e8e8e3 (diff) | |
| download | desktop-assistant-f5948fc5a6ec7ca91c6f454ca119a453a0ecb1a8.tar.gz desktop-assistant-f5948fc5a6ec7ca91c6f454ca119a453a0ecb1a8.zip | |
Add a Slackware updates tool and a restart script
updates.py compares the -current ChangeLog with the installed package
database: the newest batch holding an installed package is the last one
applied, everything newer is pending. The spoken summary gives counts,
security fixes and a kernel bump, never package lists; report=true writes
a full markdown recap and opens it in MD_EDITOR, detached.
restart.sh stops a running assistant and its quickshell overlay and
starts both again in their own session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 26 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rwxr-xr-x | examples/tools/updates.py | 240 | ||||
| -rw-r--r-- | examples/tools/updates.toml | 11 | ||||
| -rwxr-xr-x | restart.sh | 50 |
5 files changed, 331 insertions, 1 deletions
@@ -16,7 +16,8 @@ built-in settings. Examples in `examples/`. STT, LLM and TTS stay local; only the weather and search tools reach the network, and only when a question needs them. Spec: `docs/superpowers/specs/2026-09-26-tools-and-config-design.md`. -Run: `python3 assistant.py` (`--test` for the self-check). +Run: `python3 assistant.py` (`--test` for the self-check), or `./restart.sh` +to replace a running one (and its overlay) detached from the terminal. --- @@ -461,6 +462,29 @@ before tool rounds. The button loop is never blocked by a reply any more. - Unrelated but seen: whisper garbled "i Mille" into "i 1000.000e" and the model invented an answer for it instead of searching. +### Slackware updates tool (2026-09-26) + +`updates` compares the -current ChangeLog (NFS mirror) with +`/var/lib/pkgtools/packages`: the newest batch holding an entry whose exact +package name is installed is the last one applied, everything newer is +pending. Parses the 2.1 MB ChangeLog in ~95 ms, so no announcement. + +- Removed entries are left out of that check: a removed package is still + installed under its exact name and would mark the batch as applied. +- A package bumped twice keeps the security flag of the older bump. +- The first version listed every package (6.8k characters, 506 upgrades) + and the 2000-character cap cut it. The user does not want lists read + aloud: the summary gives counts, security fixes, the kernel bump and the + short new/removed lists, and tells the model to offer a written recap. + `report=true` writes `$XDG_RUNTIME_DIR/slackware-updates.md` and opens it + in `MD_EDITOR` (typora in the user's config), detached. +- Live: counts, security fixes and "kernel 7.2.7" spoken, then it offered + the recap on its own; "si" opened it (2.2 s). +- The overlay did not show after `restart.sh` started it together with the + assistant: quickshell's `Socket` never retries a failed connect. Fixed in + the quickshell repo (fresh `Socket` per retry), which also covers login, + where the overlay starts first. + ### TTS (2026-09-25) `piper-tts` on CPU (onnxruntime), ~5 s sentence per voice: @@ -44,6 +44,7 @@ Paths, the card name, ports and voices can be changed in `config.toml`, see ```sh python3 assistant.py # run python3 assistant.py --test # self-check +./restart.sh # stop a running one, start detached ``` ## Configuration and tools @@ -90,6 +91,10 @@ The example tools only read: ones, through `bluetoothctl show/devices/info` - `vms`: libvirt VMs and their state, or one VM's details, through `virsh` read commands +- `updates`: Slackware -current ChangeLog entries newer than the installed + packages: counts, security fixes, kernel bump, one package's notes, or a + full markdown recap opened in `MD_EDITOR` (default `xdg-open`); `CHANGELOG` + and `PKGDIR` in `[env]` override the mirror path and package database ## Overlay diff --git a/examples/tools/updates.py b/examples/tools/updates.py new file mode 100755 index 0000000..57cc61a --- /dev/null +++ b/examples/tools/updates.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# desktop-assistant tool: Slackware ChangeLog entries not yet installed. +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""updates.py [PACKAGE] [REPORT]: pending upgrades, comparing CHANGELOG +(default the slackware64-current mirror on NFS) with the installed packages +in PKGDIR (default /var/lib/pkgtools/packages). With PACKAGE, its pending +ChangeLog notes. With REPORT true, writes a markdown recap to +$XDG_RUNTIME_DIR/slackware-updates.md and opens it in MD_EDITOR (default +xdg-open). --test runs the self-check. + +The last applied batch is the newest one holding an entry whose exact +package name is installed; every newer batch is pending. +""" + +import os +import re +import shlex +import subprocess +import sys +import tempfile + +SEP = "+--------------------------+" +ENTRY = re.compile(r"^(?:(\S+)/)?(\S+)-([^-\s]+)-([^-\s]+)-([^-\s]+)\.t[gx]z:\s+" + r"(Upgraded|Rebuilt|Added|Removed)\.") + + +def batches(text): + """[(date, [(series, name, full, action, notes)])], newest first.""" + out = [] + for chunk in text.split(SEP): + lines = chunk.strip("\n").splitlines() + if not lines: + continue + entries = [] + for line in lines[1:]: + m = ENTRY.match(line) + if m: + series, name, ver, arch, build, action = m.groups() + entries.append([series or "", name, f"{name}-{ver}-{arch}-{build}", action, []]) + elif line.startswith((" ", "\t")) and entries: + entries[-1][4].append(line.strip()) + elif not line.startswith((" ", "\t")) and entries: + entries.append(["", "", "", "", []]) # isolinux/, kernels/*: ends the notes + out.append((lines[0].strip(), [e for e in entries if e[1]])) + return out + + +def pending(text, installed): + """(newest date, last applied date or None, pending batch count, {name: info})""" + have = {f.rsplit("-", 3)[0]: f for f in installed} + full = set(installed) + bs = batches(text) + todo, applied, n = {}, None, 0 + for date, entries in bs: + if any(e[2] in full and e[3] != "Removed" for e in entries): # a removed one stays installed + applied = date + break + n += 1 + for series, name, fullname, action, notes in entries: + if series.split("/")[0] in ("testing", "pasture"): + continue + ok = {"Upgraded": name in have, "Rebuilt": name in have, + "Added": name not in have, "Removed": name in have}[action] + if not ok: + continue + p = todo.setdefault(name, {"action": action, "new": fullname, + "old": have.get(name), "security": False, "log": []}) + p["security"] |= "(* Security fix *)" in notes + p["log"].append((date, fullname, action, notes)) + return (bs[0][0] if bs else "?"), applied, n, todo + + +def ver(full): + return full.rsplit("-", 3)[1] if full else "?" + + +def kernel(todo): + """'old -> new' when a pending kernel changes version, else None.""" + for k in ("kernel-generic", "kernel-huge", "kernel-source"): + p = todo.get(k) + if p and p["old"] and ver(p["old"]) != ver(p["new"]): + return f"{ver(p['old'])} -> {ver(p['new'])}" + return None + + +def by_action(todo): + return {a: sorted(k for k, v in todo.items() if v["action"] == a) + for a in ("Upgraded", "Rebuilt", "Added", "Removed")} + + +def summary(newest, applied, n, todo): + # Spoken: counts only, names just for the short lists. + if not todo: + return f"Up to date with the ChangeLog of {newest}." + by = by_action(todo) + sec = sorted(k for k, v in todo.items() if v["security"]) + out = [f"ChangeLog of {newest}; last applied batch {applied or 'not found'}; " + f"{n} pending batches.", + f"Pending: {len(by['Upgraded'])} upgraded, {len(by['Rebuilt'])} rebuilt, " + f"{len(by['Added'])} new, {len(by['Removed'])} removed.", + f"Security fixes: {len(sec)}" + (f" ({', '.join(sec)})." if sec else ".")] + k = kernel(todo) + out.append(f"Kernel upgrade: {k}." if k else "No kernel upgrade.") + for a, label in (("Added", "New"), ("Removed", "Removed")): + if by[a]: + out.append(f"{label}: " + ", ".join(by[a]) + ".") + out.append("Offer a written recap (report = true); do not read package lists aloud.") + return "\n".join(out) + + +def report(newest, applied, n, todo): + """The same recap in full, as markdown.""" + by = by_action(todo) + sec = sorted(k for k, v in todo.items() if v["security"]) + k = kernel(todo) + out = [f"# Pending Slackware updates", "", + f"- ChangeLog: {newest}", f"- Last applied batch: {applied or 'not found'}", + f"- Pending batches: {n}", + f"- {len(by['Upgraded'])} upgraded, {len(by['Rebuilt'])} rebuilt, " + f"{len(by['Added'])} new, {len(by['Removed'])} removed", + f"- Security fixes: {len(sec)}", f"- Kernel: {k or 'no upgrade'}"] + if sec: + out += ["", "## Security fixes"] + for name in sec: + out += ["", f"### {name}", "", "```", detail(name, todo), "```"] + for a, label in (("Added", "New"), ("Removed", "Removed"), ("Rebuilt", "Rebuilt")): + if by[a]: + out += ["", f"## {label}", ""] + [f"- {x}" for x in by[a]] + if by["Upgraded"]: + out += ["", "## Upgraded", "", "| Package | Installed | New |", "|---|---|---|"] + out += [f"| {x} | {ver(todo[x]['old'])} | {ver(todo[x]['new'])} |" for x in by["Upgraded"]] + notes = [x for x in sorted(todo) if x not in sec and any(l[3] for l in todo[x]["log"])] + if notes: + out += ["", "## Other notes"] + for name in notes: + out += ["", f"### {name}", "", "```", detail(name, todo), "```"] + return "\n".join(out) + "\n" + + +def open_report(r): + # Private runtime dir (tmpfs, gone at reboot); the editor is detached so the + # tool returns at once and a tool timeout never kills it. + d = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() + path = os.path.join(d, "slackware-updates.md") + with open(path, "w") as f: + f.write(report(*r)) + editor = shlex.split(os.environ.get("MD_EDITOR") or "xdg-open") + subprocess.Popen(editor + [path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + return f"Written recap opened in {editor[0]}: {path}" + + +def detail(name, todo): + p = todo.get(name) + if not p: + return f"No pending ChangeLog entry for {name}." + out = [f"{name}: installed {p['old'] or 'no'}."] + for date, fullname, action, notes in p["log"]: + out.append(f"{date}: {fullname} {action}.") + out.extend(" " + l for l in notes) + return "\n".join(out) + + +def test(): + log = f"""Sat Sep 26 20:00:00 UTC 2026 +a/kbd-2.10.0-x86_64-4.txz: Rebuilt. + Use setfont -R. +n/php-8.5.11-x86_64-1.txz: Upgraded. +l/newlib-1.0-x86_64-1.txz: Added. +l/oldlib-2.0-x86_64-1.txz: Removed. +testing/packages/newer-9.0-x86_64-1.txz: Added. +x/notmine-1.0-x86_64-2.txz: Upgraded. +isolinux/initrd.img: Rebuilt. +{SEP} +Fri Sep 25 20:00:00 UTC 2026 +n/php-8.5.10-x86_64-2.txz: Rebuilt. + Fixes a hole. + (* Security fix *) +{SEP} +Thu Sep 24 20:00:00 UTC 2026 +n/php-8.5.10-x86_64-1.txz: Upgraded. +{SEP} +Wed Sep 23 20:00:00 UTC 2026 +n/php-8.5.9-x86_64-1.txz: Upgraded. + (* Security fix *) +""" + inst = ["kbd-2.10.0-x86_64-2", "php-8.5.10-x86_64-1", "oldlib-2.0-x86_64-1"] + newest, applied, n, todo = pending(log, inst) + assert (newest, applied, n) == ("Sat Sep 26 20:00:00 UTC 2026", "Thu Sep 24 20:00:00 UTC 2026", 2) + assert sorted(todo) == ["kbd", "newlib", "oldlib", "php"], todo + assert todo["php"]["action"] == "Upgraded" and todo["php"]["security"] # older bump's fix counts + assert not todo["kbd"]["security"] and todo["kbd"]["log"][0][3] == ["Use setfont -R."] + s = summary(newest, applied, n, todo) + assert "1 upgraded, 1 rebuilt, 1 new, 1 removed" in s and "Security fixes: 1 (php)." in s, s + assert "No kernel upgrade." in s and "kbd" not in s, s + m = report(newest, applied, n, todo) + assert "| php | 8.5.10 | 8.5.11 |" in m and "Fixes a hole." in m and "Use setfont -R." in m, m + todo2 = pending("d\na/kernel-generic-7.2.7-x86_64-1.txz: Upgraded.\n", + ["kernel-generic-6.18.50-x86_64-1"])[3] + assert kernel(todo2) == "6.18.50 -> 7.2.7" and kernel(todo) is None + d = detail("php", todo) + assert "Fixes a hole." in d and "installed php-8.5.10-x86_64-1" in d, d + assert pending(log, ["php-8.5.11-x86_64-1", "kbd-2.10.0-x86_64-4"])[3] == {} + with tempfile.TemporaryDirectory() as t: # PKGDIR listing path + open(os.path.join(t, "php-8.5.10-x86_64-1"), "w").close() + assert sorted(pending(log, os.listdir(t))[3]) == ["newlib", "php"] + print("ok") + + +if __name__ == "__main__": + if sys.argv[1:] == ["--test"]: + test() + sys.exit() + cl = os.environ.get("CHANGELOG") or "/mnt/nfs/Slackware/slackware64-current/ChangeLog.txt" + pd = os.environ.get("PKGDIR") or "/var/lib/pkgtools/packages" + try: + with open(cl, encoding="utf-8", errors="replace") as f: + text = f.read() + except OSError as e: + sys.exit(f"ChangeLog not reachable ({cl}): {e.strerror}") + r = pending(text, os.listdir(pd)) + a = sys.argv[1:] + ["", ""] + name = a[0].strip() + if a[1].lower() in ("true", "1", "yes"): + print(open_report(r)) + else: + print(detail(name, r[3]) if name else summary(*r)) diff --git a/examples/tools/updates.toml b/examples/tools/updates.toml new file mode 100644 index 0000000..86eac1d --- /dev/null +++ b/examples/tools/updates.toml @@ -0,0 +1,11 @@ +description = "Pending Slackware -current upgrades: ChangeLog entries not yet installed. Summary gives counts (upgraded, rebuilt, new, removed, security fixes) and a pending kernel version bump; say the counts, never read package lists aloud. Can instead open a written markdown recap in the user's editor, or give one package's ChangeLog notes. Read-only." +command = ["updates.py", "{package}", "{report}"] +timeout = 10 + +[params.package] +type = "string" +description = "Package name (e.g. php) for its pending ChangeLog notes. Empty for the summary." + +[params.report] +type = "boolean" +description = "true to write the full recap and open it in the editor. Only when the user said yes to it." diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..24dd5ea --- /dev/null +++ b/restart.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# desktop-assistant: restart the voice loop, detached from the terminal. +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# restart.sh: SIGTERM any running assistant.py (whisper-server and amixer +# die with it through PDEATHSIG), SIGKILL after 10 s, then start a new one +# in its own session, logging where the Hyprland autostart does. The +# quickshell overlay (OVERLAY, default the quickshell repo next to this one) +# is restarted the same way. Run it from the login session: it needs its +# audio, bus, Wayland display and XDG_RUNTIME_DIR. + +set -eu + +dir=$(cd "$(dirname "$0")" && pwd) +log="${XDG_RUNTIME_DIR:?not in a login session}/desktop-assistant.log" +overlay=$(realpath -m "${OVERLAY:-$dir/../quickshell/assistant}") +pat='^\S*python3 (-u )?\S*assistant\.py' +qpat="^qs -p $overlay\$" + +stop() { + pkill -u "$USER" -f "$1" || return 0 + for _ in $(seq 50); do + pgrep -u "$USER" -f "$1" >/dev/null || return 0 + sleep 0.2 + done + pkill -KILL -u "$USER" -f "$1" && sleep 0.5 || true +} + +stop "$pat" +stop "$qpat" + +cd "$dir" +setsid -f python3 -u "$dir/assistant.py" >>"$log" 2>&1 </dev/null +setsid -f qs -p "$overlay" >/dev/null 2>&1 </dev/null +sleep 0.5 +pgrep -u "$USER" -af "$pat" || { echo "assistant not running, see $log" >&2; exit 1; } +pgrep -u "$USER" -af "$qpat" || { echo "overlay not running: qs -p $overlay" >&2; exit 1; } |
