The HF tree API returns entries shaped {oid,path,size,type} with no
rfilename field, so every downstream .rfilename read was null: the
--gguf filter crashed with 'jq: endswith() requires string inputs' and
single-file/all-files/meta modes silently built 'null' URLs. hf_repo_files
now normalizes tree entries to the {rfilename,size} shape the fallback
already emits (object-guarded; error-object bodies degrade to [] instead
of jq 5). The --gguf filter is type-guarded and empty results get
mode-aware messages. Verified: 12/12 fixture harness, live API 13->10
gguf, tiny real download OK, gates green. User confirmed the real
--gguf command now downloads [1/10].
Bash-native tool using curl/jq to download AI models from HF Hub.
Subcommands: download (single file/repo/gguf filter), search, list, remove.
Auth via HF_TOKEN in ai.env, resume support, disk space pre-flight,
rate limit handling, .hf-meta metadata tracking.
46 test cases / 0 failed. make gen/check/lint 0 FAIL / 0 WARN.
New tool that classifies URLs by domain and delegates to pos media mp3
(audio) or pos media mp4 (video). Listener gains URL detection step
between prefix map and AI bridge — bare URLs auto-download.
Domain rules: music.youtube.com/soundcloud/bandcamp → mp3,
youtube/vimeo/twitch → mp4 --best, unknown → configurable default.
28 test cases / 70 assertions / 0 failed.
make gen && make check green, make lint 0 FAIL / 0 WARN.
Generalizes the Telegram listener with a configurable text-prefix map
(telegram_prefixes.env): any non-command message '<word> <text>' runs
the mapped command with <text> appended as ONE quoted argument — e.g.
opencode=opencode turns 'opencode check cpu' into opencode "check cpu".
Routing order per message: text-prefix map → built-in Gemini ai bridge →
/command map → Unknown command. A mapped word shadows the Gemini bridge.
The prefix verb is reworked: bare = list map + bridge word; 'prefix
<word> <cmd...>' = map; 'prefix <word>' = show; 'prefix -r <word>' = remove.
The Gemini trigger word itself is now set via 'pos config telegram'
(TELEGRAM_AI_PREFIX).
Also extracted run_and_reply() to share the /command-map (60s) and
prefix (120s) execution semantics; fixed a latent set -e abort on
invalid templates in prefix_map_set's check_syntax call.
Verified: 27/27 routing-harness assertions, full CLI verb suite,
dispatch smoke, pos config telegram render, bash -n, make gen && make check,
make lint 0 FAIL / 0 WARN, shellcheck -S style (0 new findings).
menu_ask_value used line-oriented read -rp: a multiline Ctrl+V paste
flooded the tty queue, read consumed only the first line, and the rest
executed as commands later (or were eaten by a later prompt).
- lib/menu-lib.sh: new menu_read_value() raw-mode bracketed-paste
reader (stty -icanon -echo -isig, \e[?2004h/l, literal newlines inside
[200~..[201~, Enter submits outside paste, edit keys, cancel on
Ctrl-D-empty/Ctrl-C/Z/\, terminal restored via trap). Bytes via
dd|od|tr chunks, not bash read: read self-interrupts on ETX from a
tty even with ISIG disabled.
- bin/pos-ai-alias: prompt encode/decode (backslash, newline) with
load/save wiring; newline-safe truncate; edit wizard Enter keeps the
full original prompt (no more silent >80-char truncation).
Verified via pty harnesses: multiline + single-line paste captured
verbatim with nothing executed, Ctrl-D/Ctrl-C cancel cleanly, full
create/list/show/edit E2E, round-trips byte-exact. Gates: make gen &&
make check, make lint 0 FAIL 0 WARN.
New bin/pos-docker-stack: docker ps output grouped by Docker Compose
project (stack). Each stack is a sorted section (name, status, ports);
containers without a compose project land in a Standalone group at the
end. Running only by default, -a|--all includes stopped/exited. Status
colored on a terminal; summary line 'Stacks: N containers: N
standalone: N'; exit 0 when empty.
Data via docker ps --format with \x1f delimiters (project label
com.docker.compose.project from compose v2); parsed with awk -F'\x1f'
+ IFS=$'\x1f' read — tab/pipe delimiters are IFS whitespace or appear
in values (DEV.md:213). Dash padding via sed, not tr (multi-byte).
Deps guard (docker) before --help; no stdin.
Docs: POS.md docker row + detail, howto/docker.md table + section,
bin/pos usage EXAMPLES, AGENT_Context Common Tasks row. Verified:
stub suite 23/23, live daemon runs, dispatch, make gen && make check,
make lint 0 FAIL / 0 WARN.
usb_detect offered a Ventoy stick's 32M VTOYEFI ESP as a sync/backup
target: with the data partition unmounted it was the only mounted
candidate, and cp died mid-copy with 'No space left on device'.
Detection now reads FSTYPE/PARTTYPENAME and drops EFI system
partitions from both the mounted list and the mount-offer list;
USB_MOUNTED entries carry mp|label|size|model|fs and usb_pick_root
shows that in the single-stick confirm and the multi-stick/partition
picker (USB_ROOT stays a bare mountpoint). pos-media-sync pre-flights
the exact payload size vs df free space (err, or warn under --dry-run)
before any copy.
GNU find (default -P) does not descend a command-line symlink to a
directory, so `find $HOME/Music ...` returned zero files and the tool
reported '0 added, 0 updated, 0 unchanged' without creating the target.
Switch to `find -H` (follows only command-line symlinks; inner-symlink
semantics unchanged).
CI is now live (act_runner on the Gitea host), so the docs stop saying a
runner 'needs to be registered' and record how the gate works:
- AGENTS.md: CI bullet now notes the live runner, red run = merge-blocker,
and the byte-order deterministic generator rule (LC_ALL=C, learned when the
CI container's locale reordered the category-less pos-config/pos-tree keys
and the gen-drift gate caught it).
- DEV.md: definition-of-done mentions the live CI re-run; new 'CI: Gitea
Actions Gate' section (runner location, CONFIG_FILE run.sh gotcha,
--add-host pin, one-time tokens, runnerv1 status enum 1=success/2=failure,
deterministic-generator convention, static-only limits).
- AGENT_Context step 7: pushing re-runs the gates, red run blocks.
- AGENT_TODO: dropped a duplicated 'gate added' Done entry and fixed its
stale 'runner pending' phrasing.
Plain sort in scripts/gen-docs.sh is locale-dependent: category-less tool
keys start with '|', which collates after letters under the CI container's
locale (pos-config/pos-tree moved to the end of the tree/dispatch/filetable
blocks). The new Gitea Actions gate caught this as a gen-drift failure on a
clean checkout. Force LC_ALL=C for byte-order sort and regenerate the doc
(config/tree now deterministically sort after the letter categories).
- .gitea/workflows/lint.yml: on push + pull_request runs make gen, then
git diff --exit-code (gen-drift check), then make check, then make lint
- requires a registered act_runner (ubuntu-latest label) to execute
- docs: AGENTS.md Quick facts 'no CI' -> CI bullet (runner required, gates
still run locally); DEV.md stub-harness note clarifies CI is static-gates
only; AGENT_TODO Done entry + Next item for runner registration
- verified locally: gen idempotent (0 gen-managed files changed), check OK,
lint 0 FAIL / 0 WARN
features/usb-automount.sh (installed via ./install.sh --feature, flag
usb-automount) mounts every unmounted removable block device at
/media/<label> — world-writable via -o umask=000 (fallback plain mount),
label-collision bump -2/-3, no-label -> usb-<name>. First root run
self-installs the hotplug udev rule (/etc/udev/rules.d/99-usb-automount.rules,
SYSTEMD_WANTS=usb-automount.service) + udevadm reload/trigger; an existing
rule is never overwritten. systemd/usb-automount.service (Type=oneshot,
WantedBy=multi-user.target) covers boot + hotplug + manual start, gated in
postinstall.sh's systemd loop exactly like autostart. Purpose: a plugged-in
stick is ready for pos system backup's post-verify USB copy without manual
mounting. Docs: SYSTEMD/SCRIPTS/README/AGENT_Context (tree, filetable —
postinstall.sh count corrected 152->163, it was already stale). Verified:
stub suite /tmp/opencode/usb-automount-test 47/47 green; make gen && make check.
Once the archive verifies, USB detection runs (so a stick plugged in while
the backup ran is found): mounted removable storage is auto-detected via
lsblk -J + a recursive jq filter (rm, mounted, type part|disk — JSON makes
spacey mountpoints safe), or BACKUP_USB_ROOT pins a fixed stick and skips
detection. None mounted → one re-scan prompt ('s' skips, EOF from cron
skips silently, rc stays 0); one stick → y/N confirm; several → numbered
pick (0 = skip). The copy lands in <usb>/backups/ (mkdir -p, chmod 600
best-effort — a vfat chmod failure warns, never fails the copy) and the
transfer is proven 100% by sha256 source-vs-copy before any success is
announced; a mismatch warns with both hashes, notifies 'USB copy FAILED',
and exits 1. The ERR trap is re-armed before the USB phase so a copy
failure no longer notifies 'Backup FAILED'. Docs: usage() Environment,
POS.md backup row, howto/system.md (USB section + env table + mismatch
troubleshooting), DEV.md system.env list. Stub suite
(/tmp/opencode/backup-test, HOME-isolated, sudo/gpg/lsblk/sender stubs,
corrupting-cp + vfat-chmod overrides, per-test lsblk JSON fixtures):
40/40 green.
Session-learned hardening (sole-developer project — terse, actionable):
- §7 env-override precedent list gains USER_SYSTEMD_DIR (network-download,
communication listeners, scheduler-lib) + the scheduler's SCHEDULE_* seams,
plus the missing-:-guard gotcha that silently writes to the real $HOME
under stub runs.
- New-tool test checklist gains an env-seam review step (grep for unguarded
config writes, prove with VAR=/tmp/x).
- §7 notes stub harnesses are throwaway by design: build in
/tmp/opencode/<tool>-test/, leave there, keep only the pattern.
- howto/schedule.md: migrate copies the rule LHS verbatim as COMMAND — the
old tool never had disk root/loadavg shorthands; rewrite those jobs.
restart <gid>: re-queue from history — torrents via rebuilt magnet
(urn:btih: + &tr= trackers), HTTP via original URIs keeping dir/out;
--continue=true resumes partials, complete files verify instantly.
retry <gid|all>: waits out internet outages (NET_PROBE seam,
--interval/--max-wait), re-queues and re-verifies; aria2 error 3 = real
problem → diagnosed + marked permanent (url:/bt: ids in download.retry,
skipped by retry all, manual restart overrides); --once/--quiet for the
healer timer.
Healer: pos-aria2-retry.{service,timer} user units — arms on download
start (add/torrent/metalink/restart), disables when nothing left.
watch <gid> auto-restarts after an outage.
Fixes from stub-suite review: ensure_healer missing from submit paths;
RESTART_NAME lost across do_restart subshell (download_name helper);
restart exited 1 (tmux test as last statement).
Stub harness (/tmp/opencode/dl-test) 119/119 green; make gen && make check green.
Docs: POS.md rows, howto/network.md outage recipe, SYSTEMD.md user units.
Persistent aria2c as a systemd user service (pos-aria2.service, enable --now,
linger warning) on localhost:6800 with a generated RPC secret in
~/.config/linux_post_install/download.env (chmod 600, env override). 18
commands: start/stop/status, add/torrent/metalink (auto-start, --tmux live
view), list/info/files/peers, pause/resume/remove/purge/move, limit/set,
watch (2s live repoll). JSON built via jq -nc --arg, never string
interpolation. Deps: aria2 in preinstall PACKAGES, aria2c/jq/curl guards
before --help. Docs: POS.md + howto/network.md + indices + Common Tasks row.
pos communication matrix sender: send (plain/--markdown/--room), login
(password->access token via m.login.password), test. Implements the
lib/notify.sh sender contract, so NOTIFY_PLATFORM=telegram,matrix now
fans out for real; matrix.env config scope registered for pos config.
pos communication matrix listener: systemd user daemon long-polling
/sync (since token, compact m.room.message filter); reacts to own user's
messages (/ and ! both resolve), threaded m.in_reply_to replies, @quiet
marker, ai bridge with per-room session, interactive editor. Added to
INTERACTIVE_CMDS.
Docs: POS.md, howto/communication.md, HOWTO.md, usage EXAMPLES.
Verified against a mock homeserver (send shape, login, owner filter,
replies, exit codes, editor). make gen && make check green.
Each line of event.env is an independent rule: ["msg" if ] <check> <op> <thr>.
Check runs on every pass; first numeric output compared float-safe; op is the
rightmost 'op threshold' pair so checks with their own >/< parse fine. Alerts
once on false->true + one recovery message on true->false (no repeats while
the condition holds); per-rule state keyed by rule-line hash in
~/.local/share/linux_post_install/eventer/state/.
Subcommands: run (timer entrypoint), config (interactive add/remove/edit with
check-validation), list (rules + live values), enable [interval] (systemd
user timer pos-event-trigger.timer; 5m..weekly or OnCalendar; graceful without
a user manager, loginctl enable-linger attempt), disable, status. --dry-run
honors the DEV.md convention. Alerts via lib/notify.sh (Telegram default).
New: bin/pos-system-event-trigger, lib/eventer-lib.sh, config/event.env
template (no-clobber via postinstall), install.sh lib install, INTERACTIVE_CMDS
entry. Docs: POS.md system row, HOWTO.md index, howto/event-trigger.md.
make gen && make check green; functional tests cover trigger/recovery/no-repeat,
float+unit parsing, editor add/remove/edit + validation + dry-run, timer
enable/disable/status, dispatcher routing.
Both tools: yt-dlp calls via spawn (honor DRY_RUN; --dry-run prints the
command and skips dep checks), -o/--output, --no-playlist, --cookies (with
file check), clean ffmpeg/yt-dlp guards, POS_FLAGS for completion, full
embedded metadata (--embed-metadata --embed-chapters --embed-thumbnail
--no-overwrites; mp3 adds --convert-thumbnails jpg + artist-from-uploader
parse-metadata). mp3 gains --by-artist (~/Music/<artist>/<title>.mp3).
mp4: -f <id>/--best/--worst skip the prompt (mutual-exclusion validated),
and the interactive picker now shows a curated [audio]/[video]/[combo]
format table on stderr (stdout carries only the chosen id — ui_pick
lesson), validates the id against the real -F output, and defaults empty
input to best. Docs: howto/media.md rewritten.
Extract reply_to_message.text (fallback caption) from each getUpdates
result and pass it to handle_message; the ai bridge prefixes the prompt
with '[Reply context — the message you are replying to]', so replying to
a /status output and asking 'ai check this details' gives the model the
actual output. AI bridge only — mapped /commands unchanged; context rides
in the user turn so the session records what was analyzed. Docs:
howto/ai.md bridge section.
pos ai gemini: --session gives ask/chat persistent memory
(~/.local/share/linux_post_install/ai/<name>.json, capped 40 turns),
new 'sessions' subcommand to list/clear; --system injects a Gemini
systemInstruction on every turn without storing it in the session file.
Telegram listener keeps one session per chat (telegram-<chat_id>), clears
on 'ai /reset', passes a Telegram-voice system prompt (emojis welcome),
and strips markdown from replies before sendMessage since messages are
sent as plain text. Docs: howto/ai.md flags/sessions/bridge behavior.
cfg_read_secret is invoked via $() inside _cfg_edit_one, so the echo that
advanced the cursor after hidden input landed on the capture pipe instead of
the terminal — every secret value stored with pos config on a real TTY got a
leading newline (e.g. AI_GEMINI_API_KEY="\n<key>"), which neither cfg_value
nor the ai/telegram load_config parsers could read back: the menu showed
'(not set)' and pos ai gemini kept demanding a key.
Fix: send that newline to stderr (>&2). Defense in depth: cfg_write and
write_config_key now strip CR and truncate multi-line pastes, and the readers
(cfg_value, ai + telegram load_config) strip CR. Reproduced and verified on a
pseudo-TTY; piped-stdin tests never caught it because non-TTY input skips the
stty/echo path.
- bin/pos-ai-gemini: one-shot ask (stdout-only, pipe-friendly), interactive
multi-turn chat REPL (q/Ctrl+C, /reset), models list; --model override,
default gemini-2.5-flash; key via x-goog-api-key header, never printed.
- Config scope 'ai' (AI_GEMINI_API_KEY secret, AI_GEMINI_MODEL) in
~/.config/linux_post_install/ai.env via 'pos config ai'; config/ai.env
template installed no-clobber by postinstall.
- Telegram listener: non-command text starting with 'ai ' (case-insensitive)
is answered by Gemini via 'pos ai gemini ask'; owner-chat only, errors
reply with the pos config ai hint. Future intents slot in as case arms.
- ai-gemini added to INTERACTIVE_CMDS (chat reads stdin).
- Docs: POS.md ai section + listener bridge, howto/ai.md, HOWTO/README
index rows, bin/pos usage example; make gen refreshed context/completions.