GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.
The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linking ipx's 431 MB test binary took Tower, 31 GB with no swap and 4 GB free
beside its containers, into page-fault thrashing for 54 minutes: load 160 with
the CPU idle and 130 processes blocked. Line tables for our crates and no debug
info for dependencies make it 109 MB, built in 2m14s with load at 20 and 6.5 GB
left. CLAUDE.md says to watch memory beside the load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo deny check found 10 RustSec advisories in Cargo.lock: ammonia
4.1.4 (three, XSS via SVG animation tags among them), rustls 0.23.44
(five), h2 0.4.15 (one) and ringbuf 0.4.8 (one). cargo update took ammonia
to 4.2.4, rustls to 0.23.46 and h2 to 0.4.20. ringbuf's fix is 0.5, which
librqbit 9.0.1 (the newest) cannot take; its double free needs an element
whose Drop panics and librqbit-utp's buffer holds u8, so deny.toml ignores
it with that reason.
deny.toml allows permissive licences only, crates.io only, and no
wildcard versions; ipx and ipx-directory say publish = false, as feed did.
cargo machete found crates/ipx still listing atom_syndication, quick-xml
and rss, which only crates/feed uses since parsing moved there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo clippy --workspace --all-targets had 24 warnings; its own fixes took
most, the rest by hand: two type aliases for long types, a struct built
whole, a checked division, a doc comment's stray blank line. Its fix for
the stale-feed fold was wrong: `any` stops at the first feed it removes,
where the fold removed every one, so that is a loop instead.
A pre-commit hook in .githooks refuses unformatted Rust; clippy, being a
build, is in CLAUDE.md's checks with -D warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code was formatted by hand, and `cargo fmt --check` differed in 866
places. rustfmt.toml keeps the house's long lines (120) and short
expressions on one line, which halves what changes; this commit is only
what rustfmt does with it, and is named in .git-blame-ignore-revs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A show inside a subscribed OPML went when the OPML stopped listing it
(orphaned only if it had downloads), and went for you when you left the
OPML. Keep, in its settings, is a per-person flag on the subscription:
when the OPML drops a kept show, or nobody subscribes to the OPML any more,
it becomes a catalogue entry outside the OPML, taking the OPML's settings
while it is there, and stays on the lists of those who kept it. Whoever had
it only through the OPML loses it as before. Leaving the OPML yourself
leaves what you kept on your list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spotify's CloudFront serves some covers as application/octet-stream and
pippa.io sends no Content-Type at all; both are JPEGs, which browsers sniff
and show, but /api/art refused them with a 502 and the podcast showed its
initials. An untyped or octet-stream answer is now read and checked for
JPEG, PNG, GIF or WebP magic; anything else, SVG included, is still refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the server paying for everyone's Signals, its bill had no names.
ipx_signals_judged_total, ipx_signals_hits_total and ipx_jev_tokens_total
now carry user (the account's name) and key (server or own); a call
shared on the server's key is split by each person's share of its
questions. Icon picks bill the person on the server's key, key checks on
their own. The dashboard has tokens and items by person and a Who used
what table over its range.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TypeSafe allows 80 requests and 100,000 input tokens a second. Six calls
in flight was a cap on concurrency, not rate: at 74 ms a call it is 81 a
second. Every call to Jev now takes a turn, a 60th of a second or its
guessed tokens' share of 80,000, so up to ten can be in flight. A 429
holds every call back for Retry-After and ends the pass. Each jev span
records paced_ms, /metrics ipx_jev_paced_seconds_total and a rate_limited
result, and the dashboard draws calls and tokens a second against the
limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deployed, the judge's query still read every entry: an expression index
has no statistics until its table is analysed, and Postgres, guessing a
third of entries were recent, chose a scan (898 ms) over entries_recent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The judge's query read all of entries (605 MB, 566 ms) on every wake; an
index on each feed's items by date, as the query asks, and the rechecks
in a half of their own. Calls ran one at a time, about 150 ms each, so a
new Signal's month of about 8,000 items took twenty minutes: six in flight
now. On the server's key everyone's Signals on an item share one call.
TypeSafe's token counts go to /metrics and each jev span.
An item's text is cut at 1,000 characters instead of 2,000: on 55 real
items of eight podcast feeds and six Signals, on the development key,
1,000 changed none of 330 verdicts (nor did the same request twice) and
saved 12% of the input tokens, 1,266 to 1,112 a call; 500 changed one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/signals.md says what the admin's setting does and that a key is
checked before it is saved; the install guide lists TYPESAFE_KEY; the API
reference has Signals' routes and types and the new fields of Me and
Settings; the home page says an admin may provide the key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/src/.envrc's TYPESAFE_KEY is a development key, so the test daemon uses it
and the server paying for Signals is tested end to end. Signals' tests spend
it, so each runs only when the Signals code or the test itself has changed
since it last passed (a hash kept under target/signals-tests);
IPX_SIGNALS_TESTS=1 runs them anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.
The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The judge had spans but nothing in Prometheus: no count of items judged or
of how calls to Jev ended, nor whether the judge was still passing. The jev
span recorded an error field without the span's status, so Tempo's failed
traces left Jev out, and a timeout recorded nothing. Calls now send
traceparent like every other call out of ipx.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tune out the noise and All talk, no dead air, each a radio idiom the dial
at the top already sets up, written with /brand-naming-strategies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drawn list's made-up titles read badly. The screenshot is a Signal
called Space on a scratch iPX on eight public feeds, with a stand-in
for Jev matching the Signal's words in titles, in Glass as the other
shots are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The site's CSP is style-src 'self', which refuses style attributes, so
the bars would all have drawn at full width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drawn in CSS like DeadAir's: the noise fades and the three items about
the words listened for stay lit. No screenshot, since one would need a
TypeSafe key on the scratch server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signals (#254) in ipx and the page, and the compression and tracing
work of #239-#253 in all three.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neither orange tile nor orange icon on the tint worked, so each
Signal's tile is back in the theme's accent tint, as it was before
#268. The Signals folder icon and the larger DeadAir tile stay. The
tile rules go above the initials' comment, which they had split from
its rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A solid orange tile was too loud. Each Signal's tile is the folder's
accent tint again, and its icon or initials are drawn in the orange of
the Signals icon's point: #ff6a1a in dark. In light it is the light
palette's needle, #c43e00, since signals.svg's #ff5500 on the tint is
about 2:1, too faint for initials (about 3.4:1 instead).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--accent2 mixed a quarter into the ground came out salmon, and is pink
or blue in other themes. Each Signal's tile is now solid #ff6a1a, or
#ff5500 in light, the point of signals.svg and signals-dark.svg, in
every theme as the icon is, with the icon or initials in the logo's
navy (#0c2238, 5:1 or better on either).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Signal's tile, icon or initials, is tinted --accent2 in place of
--accent: the orange point of the Signals folder's own icon, so the
folder and what is in it read as one. The player's DeadAir tile goes
from 22px to 26px in its 30px button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
web/signals.svg and web/deadair-{on,off}.svg, each with a -dark twin,
follow logo.svg's rules: square and opaque, the logo's gradient ground,
flat layers in its scale blues, the needle's orange, nothing thin
enough to vanish at 32px. Signals is the page's own glyph, waves going
out from a point, the point in orange. DeadAir on is the waveform
closed up with the needle at the cut; off has the silence still in its
middle, an orange flat line, and its bars muted, so the state is in
the shape as the old glyphs had it.
The Signals folder row and its page header show the Signals tile in
place of the glyph on an accent tint; the player's DeadAir switch shows
the on or off tile. tile() puts both variants on the page, and the mode
rule that picked the logo's now picks any .onlight/.ondark. The three
glyphs nothing uses any more are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xkcd's posts are an <img> whose alt and title are the comic's words. text_of dropped tags whole,
attributes and all, so Jev was sent "Feed: xkcd.com / Title: Ground Effect", ground effect being
an F1 aerodynamics term, and the comic went into the Formula 1 Signal at 0.60. With the alt text
it scores 0.19. Alt and title are read once each, and once when they say the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Font Awesome's Mandalorian icon has "disney plus" among its search terms, the only icon with
"disney" in them, so a search for Disney Parks' words offered it and Jev picked a Star Wars helmet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.
Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unjudged() limits rows, one for each item and Signal, to 50, and the pass took the queue for
empty whenever the items numbered fewer than 50. With two Signals every batch is 25 items, so the
re-judge after #259 crawled at 25 items every 5 minutes. A batch is now full by its rows. A pass
TypeSafe stopped as malformed waits for the next wake however full it was, rather than asking again
at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jev was asked "Is this item about any of these: ai, development, programming?", so any one broad
word was enough and the Signal's name, which says what is meant, was never sent. AI Development
held 54 items: Sega pulling a game remaster (0.90), a Surface Laptop review (0.95), a C++ tutorial
(0.99). It is now asked about one subject, called by the Signal's name, described by its words
and, when those are not enough, its owner's sentence. Scored against TypeSafe over those 54 with
"AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.":
5 stay, Linux kernel AI code review highest at 0.94; the remaster 0.06, the C++ tutorial 0.02.
signals.description is new, and its first start deletes every verdict, which answered the old
question, so everything in the window is asked again. Any change to a Signal's name, words or
sentence drops its verdicts, since each is part of the question. The queue query names the
Signal's sentence apart from the item's description, which share a row; the test caught the
item's winning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page fetched the Signals' unread counts only with the feed list, so reading an item, here or
in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said
2 unread over a page with none. The page now fetches them again after a read or unread lands, and
on a new signal_hits event, which the judge sends once a pass for each person it found items for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call was refused with 422: a noul question's `criteria` is an object of what true and false
mean, and it was sent a sentence. The error kept only the status, from error_for_status, so the log
and the trace said 422 and not why; TypeSafe's body said "Input should be a valid dictionary"
at questions.3.noul.criteria. The body is now in the error and on the jev span, with the status.
A request refused as malformed (400, 422) ends the pass, since every other item's would be refused
the same way: the 50 warnings a pass were the same failure 50 times. The state is text now, as
TypeSafe's docs show it, rather than an object that sent null for a feed with no title, and the
questions are keyed signal_<id>. Checked against TypeSafe with the corrected request: 0.96 for an
item about the keywords, 0.02 for one that was not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>