feat(assistant): marketplace watches the assistant sets up from chat #1222

Open
lytedev wants to merge 4 commits from propose/assistant-watch into main
Owner

See the four commit messages. Supersedes #1215 (the beefcake LPCAMM2 watcher). Deploy pebble before bigtower.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D6HRbierL4ThD2XJP7ajKd

See the four commit messages. Supersedes #1215 (the beefcake LPCAMM2 watcher). Deploy pebble before bigtower. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01D6HRbierL4ThD2XJP7ajKd
bigtower's assistantd publishes marketplace-watch alerts as the
existing `alerts` user. Access is granted per topic in the
ExecStartPost provisioner, so without this every push 403s (and is
retried each run until it is granted).

The grant was already added by hand on pebble on 2026-09-24
(`ntfy access alerts hardware-deals read-write`) for the ad-hoc
LPCAMM2 watcher on dragon; this makes it declarative. The provisioner
only adds grants, so deploying this is a no-op on the live DB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6HRbierL4ThD2XJP7ajKd
A pure filter for assistantd: active watches on stdin, the listings
that currently match each one on stdout, grouped per (watch, source).
All state (what was announced, price drops, restocks, expiry) belongs
to the daemon, so this program has nothing to remember and its tests
need no fixtures beyond page samples.

The parsers are the ones proven in the LPCAMM2 watcher (propose/
lpcamm-watch, #1215) against the live sites on 2026-09-23/24: Reddit
RSS, the Craigslist search API, Newegg's embedded __initialState__,
Central Computers' Magento markup, and the eBay Browse API. What
changed is matching: the LPCAMM2-specific regexes became per-watch
data (must/not phrases, compared with case, spaces and hyphens
squashed so "LP-CAMM2" matches "lpcamm"), since the model now writes
those per request.

Dropped from that watcher: Crucial product pages. Their schema.org
Offer claimed CT64G75C2LP5X at $451.99 InStock while the rendered page
showed no price and no cart.

A failed source is reported as a failure, never as an empty result:
the daemon reads an empty report as "every listing is gone", which
would re-announce them all as restocks on the next good run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6HRbierL4ThD2XJP7ajKd
"Keep an eye out for X under $Y on Z for N days", stored beside
reminders in assistant.db and driven by the same daemon:

- `assistant watch add|list|rm|hits|check` over the socket, and
  `!watches` / `!unwatch` in Matrix that work without the model.
- An in-process loop (default every 15 min) runs
  listing-watch-checker with the active watches and folds each
  (watch, source) report into watch_listings. New listings, price
  drops and listings that come back after dropping out (restocks) are
  announced; everything else stays quiet.
- Each watch chooses delivery: ntfy (sent by the daemon itself, so a
  push still arrives with Hermes or the Mac's model down; failed
  pushes retry on the next run) and/or Matrix (an AgentEvent the agent
  relays and may annotate, queued in the inbox if no agent is
  attached). A watch expiring is announced once.
- A source failing 12 runs in a row is reported once, so a scraper
  that rots is noticed instead of silently searching nothing.

Why the daemon runs the checker rather than a systemd timer: same
reason reminders are in-process -- one scheduler, nothing separate to
silently stop after a reboot -- and the daemon already owns the
state the results have to be diffed against.

Why the model only sets watches up and relays hits: an LLM polling
pages would compete with conversations for the one local model, and
would repeat the mistake that dropped Crucial from the checker
(trusting a stale embedded price). Its job is translating a request
into search terms, typo spellings and exclusions, which is exactly
what the checker cannot do.

A watch that could never work is refused when made (eBay without a
keyset, ntfy with no target, unknown sources), and unspecified
sources/deliveries default to what the host can use, so a bare
"watch add" is never refused for options nobody asked for.

Tested: 25 new unit tests (announce rules, per-watch dedup, expiry,
source streaks, CLI parsing, wire names). End to end on dragon with
the built daemon (no Matrix), the real checker and pebble's ntfy: a
watch matching one real Newegg listing pushed to hardware-deals and
queued a watch_hit event; a second run announced nothing; eBay
without a keyset was refused; a 1-minute watch queued exactly one
watch_expired across two runs; `watch rm` twice reported the second.

cargo fmt was run and then undone for pre-existing code: main is not
rustfmt-clean, and reformatting it here would bury this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6HRbierL4ThD2XJP7ajKd
feat(assistant): let the assistant set up marketplace watches on bigtower
All checks were successful
/ check-format (push) Successful in 9s
/ build (push) Successful in 12m48s
d05b8b02bc
Turns on assistantd's watches on bigtower (Craigslist around Kansas
City, r/hardwareswap+homelabsales+framework as one feed, ntfy to
pebble's hardware-deals topic with the servers-wide publisher
credential) and teaches Hermes the `assistant watch` CLI.

Teaching it needed a mechanism that did not exist. Since the switch
to Hermes, assistant-CLAUDE.md reaches no model: Claude Code loaded it
as its working directory's CLAUDE.md, but the Hermes unit runs from
/home/daniel with only the stock SOUL.md. Hermes filled the gap
itself -- its self-written `assistant-reminders` skill (in
$HERMES_HOME/skills on bigtower, 2026-09-12) edits assistant.db with
python sqlite3 instead of calling `assistant remind`. For watches
that would bypass validation and defaults, so the instructions ship
as a repo skill loaded through `skills.external_dirs` from the store:
on demand rather than in every prompt (prefill time on the local
model), and not rewritable by Hermes.

Only the watch skill is added here. Reminders, mail and calendars
have the same gap; moving the rest of assistant-CLAUDE.md into skills
is a separate change.

eBay stays off until a Production keyset exists: set
watches.ebayCredentialsFile and the daemon starts accepting eBay
watches and including eBay in the defaults.

Tested: bigtower and pebble evaluate; the built hermes config carries
skills.external_dirs pointing at a store dir holding
assistant-watch/SKILL.md; the assistantd wrapper exports the checker,
its config and the ntfy settings (no credential values).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6HRbierL4ThD2XJP7ajKd
All checks were successful
/ check-format (push) Successful in 9s
Required
Details
/ build (push) Successful in 12m48s
Required
Details
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin propose/assistant-watch:propose/assistant-watch
git switch propose/assistant-watch
Sign in to join this conversation.
No reviewers
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lytedev/nix!1222
No description provided.