aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-26 12:36:47 +0200
committerDanilo M. <danix@danix.xyz>2026-09-26 12:36:47 +0200
commitf5948fc5a6ec7ca91c6f454ca119a453a0ecb1a8 (patch)
tree2a26efd65952afb21f6694d1f25ea1190bdf407c
parent746e8c05512675b1c059d73f5f54d977d5e8e8e3 (diff)
downloaddesktop-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.md26
-rw-r--r--README.md5
-rwxr-xr-xexamples/tools/updates.py240
-rw-r--r--examples/tools/updates.toml11
-rwxr-xr-xrestart.sh50
5 files changed, 331 insertions, 1 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 23fa55f..0cd301e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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:
diff --git a/README.md b/README.md
index 5a8cc92..69fe21f 100644
--- a/README.md
+++ b/README.md
@@ -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; }