Both cycles snapshot the unclustered items when they start and then await between
publishes, so whichever ran second republished items the first had already done.
With the AI service unreachable the passthrough cycle runs every minute next to the
direct-publish one, so every RSS item in a category with AI turned off came out as
two identical articles. Each item is now claimed (in-process, re-checked against the
database) before publishing. Reproduced with both cycles run concurrently on 12
items: 24 articles before, 12 after.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Compose Manager's project_name file drifted to 'homefeed' while the data lived
in 'homefeed-001_backend-data', so its Up button created a fresh empty volume and
a duplicate stack. The volume is now external and named explicitly: any project
name resolves to the same data, and a missing volume fails loudly instead of
starting a blank site.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The backend container maps the public domain to the reverse proxy's LAN IP so
server-side renders stay on the LAN. That address was hardcoded to the old NAS
IP, so after the move every SSR fetch failed and each page returned a 500.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Setting ADMIN_API_KEY in the Unraid project's .env had no effect: the deployed compose.yaml
was a stale copy of docker-compose.prod.yml from before that file forwarded the variable, so
nothing referenced it, it never reached the container, and the backend invented a random key
on every recreate. The repo's prod file already had the line; the fix on the host is to bring
the deployed copy back in line with it.
The deployed copy had also drifted the other way: the extra_hosts entry that keeps the
frontend's server-side rendering on the LAN had only ever been applied on the host. It is
now in docker-compose.prod.yml, so the repo is the source of truth again and a re-copy of
the file can't silently drop it.
Backend changes so a chosen password behaves sensibly:
- A key supplied through the environment is no longer printed at startup. The banner echoed
it into the container log on every start, leaving a copy of the password in `docker logs`.
Generated and file-persisted keys are still printed, since that is the only way to learn
one you didn't choose.
- The env value is trimmed. A password pasted into a compose or env editor routinely picks up
a stray space or newline and then never matches, which looks exactly like the setting being
ignored.
- Startup warns when a supplied key is shorter than 16 characters. Nothing limits repeated
wrong guesses on /api/admin/*, so a short hand-picked key is genuinely guessable.
The README, backend README and .env.example still described a fresh random key on every
start that "isn't configurable via .env", which stopped being true when the key began to
persist; they now describe the three ways it is resolved and the Docker forwarding gotcha.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The feed list and the More page were capped at 720px and the article at 640px, so on a
desktop they stopped well short of the sidebar. The caps are gone and each now runs the full
width of the main column (862px at a 1290px viewport); the layout grid keeps that column
clear of the sidebar widgets, leaving the same 32px gutter, and the page container's own
1242px limit still bounds it on very wide screens.
The tweet and Telegram photo frames needed a matching change. They were fixed heights
(380px for one photo, 240px for two, 300px for three or four) tuned for a 640px column, so
simply widening the feed left landscape photos letterboxed with dark side bars. They now keep
the proportions they had at 640px and scale with the card, with the old heights kept as a
minimum so a narrow column is unchanged.
That is done with container-relative heights rather than aspect-ratio plus min-height. The
first attempt used aspect-ratio and, on a phone, converted the 380px minimum height into a
676px minimum width through the ratio, pushing the frames past the screen edge and making the
page scroll sideways; only caught by checking at phone width. Measured at 375px, 1024px and
1290px: no horizontal scroll at any, the old frame sizes preserved at the narrow end, and no
overlap with the sidebar.
Body text on the article page now runs about 113 characters per line at this width.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The conditions card, alerts and 7-day list were capped at 640px and the summary at 560px,
leaving about 220px of empty space between the content and the sidebar on a normal desktop.
The caps are gone, so everything runs up to the sidebar's gutter; the page container's own
1242px limit still bounds it on very wide screens. The timeline bar grows from about 608px
to 828px at a 1290px viewport, which gives the hourly view more resolution.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Three connected changes to the weather page, plus the bug the work turned up.
The header and the hourly stat panel are now one card. Hovering or dragging along the
timeline swaps every figure in it, header included, and leaving restores the live reading.
It carries wind gusts, dew point, cloud cover, visibility, cloud ceiling, indoor humidity
and a per-hour feels-like, built by one shared function for "now" and each hour so the two
cannot drift apart.
Indoor humidity follows AccuWeather's published figure and its 11-step description, neither
of which has a documented method, so both were reverse-engineered from 28 live readings
(Antarctica to Singapore) and checked against 6 more cities not used in the fit: all 34
descriptions match and every percentage is within 2 points. The description is a function
of the outdoor dew point alone, not of the percentage (Minneapolis at 41% is "Ideal" while
Reykjavik at 42% is "Slightly Dry"). The percentage is the outdoor air warmed to 68F, plus
an empirically fitted moisture allowance in cold dry weather.
Cloud ceiling comes from the National Weather Service gridpoint forecast, so it exists for
US locations only and the row is left out elsewhere. Open-Meteo has a cloud_base variable
but returns null for every model at every location, and the usual temperature/dew-point
estimate describes convective cloud bases and contradicted a real high ceiling.
The 7-day list now describes each day and each night ("A morning shower", "Night: A couple
of evening showers") composed from the hourly series, since the daily weather code names
only the single worst condition of the day. An hour at 50% chance of precipitation or more
counts as showers even when the model's one run is dry there, because AccuWeather's own
wording follows the probability and a row otherwise read "Partly sunny" beside 56%. The
low shown is now the night's low, matching AccuWeather, and the first row reads "Tonight"
once the sun has set. One extra forecast day is requested so the seventh night is complete.
The row of per-hour icon buttons is replaced by a single bar in the style of MerrySky:
consecutive hours that look alike merge into one labelled stretch, with a time and
temperature axis beneath. Mouse hover scrubs it, touch scrubs while pressed and stays
pinned until dismissed, and the keyboard steps through hours. Each hour carries a coarse
condition kind computed from the weather code rather than parsed from display text.
Also fixed along the way: "today" is anchored on the first hour actually shown, so the
daily list starts on the right date shortly after local midnight.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Three related changes to the weather widget, plus one pre-existing bug the work
exposed.
WMO code 0 was unconditionally "Clear sky" with a sun icon, so a clear night
rendered a sun at midnight. Daytime now reads "Sunny"/"Mostly sunny" and night
"Clear"/"Mostly clear" with a moon, driven by Open-Meteo's is_day rather than any
hardcoded hour, so it tracks seasonal drift, latitude and DST on its own. Partly
cloudy, drizzle and showers flip too, since their icons also draw a sun; rain, fog,
snow and thunder are identical at any hour and are left alone. The daily forecast
summarises a whole day, so wmoToCondition defaults to the daytime form.
Renaming the label broke the prose in three places that only showed up against real
cities: a dry run spanning sunset inherited the first segment's adjective and said
"sunny ... into the overnight hours"; whole-day tallies split one clear sky into
Sunny and Clear and could lose a vote to a half-cloudy day; and the "clearing"
test matched /clear/ only, so it would have said "easing off" instead.
Precipitation amounts are new. The synopsis now states the day's expected total
("Rainfall today will total around 0.2 inches"), tomorrow's clause carries its own
total, and the hourly panel shows per-hour accumulation. A precipUnit setting sits
alongside the existing wind and pressure ones; metric follows the forecasting
convention of millimetres for rain but centimetres for snow depth, which is what
Open-Meteo returns. Amounts below a trace are suppressed rather than printing
"around 0 inches". rain_sum excludes showers, so both are summed.
Separately: Open-Meteo anchors its arrays to the day the response was generated
for, which can still be yesterday in the location's own timezone shortly after
local midnight. Glasgow at 00:55 BST was served daily[0] = the previous date, so
every hour of "today" was already past, todayRemaining came out empty, and
buildDaySummary discarded the entire synopsis - current conditions and tomorrow
included. Today is now anchored on the first hour actually being shown, which also
puts today's totals and today's sunrise/sunset on the right row, and each sentence
is omitted independently rather than one gap voiding the lot.
Co-Authored-By: Claude Opus 5 <[email protected]>
Two independent production faults, both silent.
The storage-cap sweep deleted every article on the site, hourly. It freed
space by deleting articles but never their media: merged_articles has no FK to
media_assets, and every other caller pairs deleteArticle() with
deleteMediaByArticleId(). So totalStorageBytes() never moved between
iterations, the `used <= capBytes` break never fired, and the FIFO loop ran to
the end of the article list. Nothing reclaimed published-tier media either, so
the total grew monotonically until it crossed the cap and the wipe began.
Retention now deletes an article's media along with it in both the age sweep
and the cap pass, reclaims assets orphaned by earlier sweeps, and refuses to
delete anything when the cap sits below the bytes no article owns, since
deleting articles provably cannot get under that floor. It also logs what it
did; the old pass reported nothing while removing everything.
Separately, /api/tags returned all 1285 tags with their embedding vectors,
roughly 15KB apiece. The article page SSR-fetches the tag list to label its
handful of tag ids, so SvelteKit inlined ~19MB into every article page — 9.7MB
on the wire, and 79% of the site's total egress. That was enough to saturate
the uplink in bursts whenever a crawler walked the article URLs, timing out
every other service sharing the connection. No client reads the field; the
frontend's Tag type does not even declare it.
Also return 404 rather than 500 for an article that no longer exists, now that
retention removes them legitimately.
Co-Authored-By: Claude Opus 5 <[email protected]>
Three changes, kept together because they share scheduler.ts.
RSS ingestion stopped dead for two days with no log line. rss/api/telegram
adapters had no fetch timeout (only nitter/youtube/articleFetcher did), and
pollDueSources walks sources sequentially — so one call that never returned
meant every source behind it was never reached, the tick's promise never
settled, and the overlap guard skipped every later tick forever. try/catch
cannot help: a hang never rejects. Bound every adapter fetch at 30s, and
report a tick that has been running too long instead of failing silently. The
guard is deliberately not released on a stall, since a second concurrent pass
over the same items would double-publish.
New stories now unfold into the feed rather than appearing instantly: height
grows from zero so older rows are pushed rather than jumped, arrivals in a
burst cascade 90ms apart (hold-before-publish releases whole batches), and an
accent wash marks each one for ~2s so it still registers if you glance away.
No scroll compensation of our own — browsers already anchor content inserted
above the viewport, and measurement confirms the reader's position holds.
Disabled under prefers-reduced-motion, since catching the eye is exactly what
that setting asks us not to do.
The stocks poll interval moves from hardcoded 15 minutes to admin-set,
defaulting to 5, and the sidebar states it. intervalMs became a getter, so
startWidgetPolling now re-arms a timeout rather than using setInterval —
setInterval reads the value once and would pin the widget to whatever it was
at startup. Existing installs pick up the new 5-minute default on deploy.
Co-Authored-By: Claude Opus 5 <[email protected]>
SvelteKit re-runs load() only on navigation, so a tab left open never showed
anything published after it rendered — the feed had no refresh at all, and the
sidebar's was slow enough to feel like none. Poll the feed on the same ~1 minute
cadence the publish pipeline runs on, folding results into the existing list
rather than replacing it so pagination and scroll position survive, and match
the sidebar to it. All four feed routes share InfiniteFeed, so this covers home,
category, event, and tag together. Both skip polling while the tab is hidden and
catch up on return.
Articles already on screen are swapped in place when their updatedAt moves, not
just newly published ones: a merge keeps absorbing sources after it publishes,
so source counts and bodies change under rows that are already rendered.
Polling rather than SSE on purpose — an event stream needs explicit buffering
changes on the reverse proxy before it works at all, the same trap that made
/widget-assets 404 in production.
Two things this turned up while being verified. The component's re-sync effect
keyed off the load prop's object identity, which the sidebar's own invalidate
rebuilds without the feed having changed; it treated that as a navigation and
reset the list, so freshly polled stories appeared and vanished ~4ms later. It
now keys off which article ids are present. And no scroll compensation is done
here at all: browsers scroll-anchor content inserted above the viewport already,
and two hand-rolled corrections both fought that and misplaced the reader.
Co-Authored-By: Claude Opus 5 <[email protected]>
A recap came back written entirely in Chinese from all-English sources. The
title shows the mechanism: it began "US Military Warns of Un" and then emitted
the Chinese token for "sustainable" instead of the English one, after which
the whole body continued in Chinese and drifted onto an unrelated subject.
Nothing in either system prompt had ever named an output language, and the one
existing quality gate matches banned English phrases, so a fully-Chinese body
passed it clean. Schema-constrained decoding cannot catch this either: it
constrains the JSON shape, and a Chinese string is a valid string.
Add a synthesisLanguage setting (Models tab, default English) that both base
prompts now interpolate, and check the response's script against it in what is
now generateValidated, retrying once with the fault named back to the model.
The check is a ratio rather than zero-tolerance so a quoted foreign name is
still fine, and it is symmetric — selecting Japanese starts flagging a Latin
response. Title and body are checked separately, since drift can begin in the
title while its own script split stays near even. Both faults are reported in
a single retry rather than one retry each: a synthesis call runs into the
minutes on CPU and Ollama serves one request at a time, so a third call would
stall everything queued behind it.
Co-Authored-By: Claude Opus 5 <[email protected]>
The weather summary only ever described today via its LAST remaining hour's
condition, so an afternoon of rain went unmentioned while the 7-day row for
the same day read "Heavy rain" — and a day that never rained was reported as
"clear skies" even when it was overcast start to finish, since nothing
distinguished "dry" from "clear". Replace the tomorrow-only describer with a
segment engine covering both days: precipitation is classified by an exact
WMO-label table carrying an intensity rank (so "heavy rain" survives into the
prose), segments are grouped by walking hours chronologically so tonight's
overnight lands at the end rather than the front, the segment in progress
reads in present tense while later ones read as forecast, and dry stretches
report their actual sky cover.
The hourly strip's cells now preview their own humidity/precip/wind/pressure
in the stat panel on hover, marked blue against the bronze current hour. That
needed per-hour fields the forecast call wasn't requesting, so cached hourly
data stays stale until the next poll repopulates it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Ship the first pluggable widget with a real frontend bundle (a search bar
for DuckDuckGo/Google/Bing + admin-managed custom engines), which required
widening widgetOrder from a closed 4-key union to a plain string[] so an
uploaded widget can be reordered relative to the built-ins with the same
up/down arrows, rather than always rendering after them. Also documents the
/widget-assets Nginx Proxy Manager location that a widget's frontendEntry
bundle needs to load at all — missing from the reverse-proxy docs since
nothing with a real frontendEntry existed until now.
Badge, checkbox, Edit, and delete buttons in Tracked Items, Bookmarks,
Stocks, Sources, Poe2, and Widgets rows moved horizontally depending on
how long the item's name/URL was, since the text column had no
min-width/flex constraint and could push siblings around. Give the
text column flex:1/min-width:0 with ellipsis truncation and pin the
controls with flex-shrink:0 so they stay fixed regardless of content
length. Also fixes the Bookmarks "Private" checkbox sitting slightly
above Edit/× due to a stray margin-bottom meant for the add form.
Same root cause as the earlier video crop fix: object-fit: cover
crops the overflow to fill a landscape-shaped grid cell, which turns
a portrait photo into a thin cropped sliver — that fix only applied
contain to video/gif, since the immediate symptom was the crop leaking
into the browser's native fullscreen view. The crop itself was never
video-specific though; a tall image in a wide cell gets squeezed
identically. Extends contain to every .media-el (image and video
alike) in both TweetCard and TelegramCard's media grids.
Verified against a real 1080x1920 portrait image on an isolated
instance: object-fit computes to "contain" and the image's real
natural dimensions are preserved (letterboxed within the cell) rather
than cropped.
A real incident: the "Sign in" button got stuck on "Signing in…" and
never rendered the admin panel, even with a valid key. A HAR capture
of the failed attempt showed the /api/admin/settings request never
fired at all — the failure was before the network layer even started,
not a slow backend. Root cause turned out to be corrupted Firefox site
data for the domain (fixed by clearing it), but adminAuth.ts's
localStorage calls had no error handling at all: if setApiKey() throws
for any reason (full quota, storage blocked by strict privacy mode,
corrupted site data), the exception had nowhere clear to go, and the
login form's `finally { loading = false }` — the only thing that could
reset the stuck button — never got a chance to run cleanly either.
getApiKey/clearApiKey now fail quietly to "not logged in", a safe
default either way. setApiKey throws a clear, catchable message
instead, which the login form already displays via its existing catch
block. Verified in a real browser both ways: normal login still
succeeds, and a simulated storage failure (scoped to just the admin
key, so it doesn't affect the theme toggle or anything else in the
same test) now shows the error message immediately instead of hanging.
"...followed by a high of 79° and a 68% chance of rain tomorrow" read
as ambiguous (a high of 79° *when*, tonight or tomorrow?) and said
nothing about when in the day rain was actually likely. Split into two
sentences each anchored to its own time up front, and replaced the
flat percentage with a description derived from tomorrow's actual
hour-by-hour forecast: bucket the day into morning/afternoon/evening,
classify each segment as precipitating or not (>=40% of its hours
drizzle/rain/snow/shower/thunderstorm-classified), and pick from seven
transition phrasings — clearing by afternoon, moving in this evening,
on-and-off all day, etc. — falling back to the plain percentage only
when there's no clean pattern to describe.
Snow gets its own wording plus the actual accumulation in inches
(&precipitation_unit=inch, daily snowfall_sum) — "68% chance of rain"
badly undersells "6 inches of snow". Verified the segment/transition
logic against synthetic hour patterns for every branch (morning-only,
evening-only, all-day, no precip, snow with accumulation) since no
real location has snow in August to test live, plus confirmed the
sentence restructure against real Philadelphia data.
Today's hourly list used a rolling 24h-from-now window, which meant
"today" shrank to almost nothing by late evening while tomorrow showed
nothing at all. Now it's today's remaining hours (starting from the
current hour, attritioning down to nothing by midnight) plus the full
24 hours of tomorrow — grouped with a day label, its date, and a
divider between days so it's unambiguous which hours belong to which
day; the current hour gets a "Now" highlight.
Also adds a plain-English day summary ("Current conditions in X are
86° and clear with a light northwest wind at 5 mph. Tonight will turn
...") rendered between current conditions and the stats grid.
Deliberately template-composed from the same numbers already fetched,
not model-generated — there's exactly one correct way to state "75%
chance of rain," and an LLM restating numbers it's already been given
can still transpose or invent one, a far worse failure mode for a
weather report than plain phrasing.
Building this surfaced a real, pre-existing timezone bug: Open-Meteo's
`&timezone=auto` returns every timestamp as a naive local-to-the-
location string with no UTC offset, and `new Date(...)` on a string
like that parses in the RUNTIME's own timezone, not the location's.
Silently correct on a dev machine whose timezone happens to match the
weather location; silently wrong in production, where the container
has no TZ set (defaults to UTC) — every "is this hour >= now"
comparison ended up off by the location's UTC offset (4h for
Philadelphia in EDT), which is what was making "today" appear to start
hours later than the real current hour. Every timestamp this module
emits now carries an explicit UTC offset (from Open-Meteo's own
utc_offset_seconds), so parsing is correct regardless of which
timezone the parsing runtime — this backend, or a visitor's browser —
happens to be in. Verified identical, correct output under TZ=UTC,
America/New_York, and Asia/Tokyo.
Every fix this session to the title-parsing problem (numbered lists,
"Title:" labels, bare dividers, markdown headers, empty titles, a whole
article dumped into the title field) was the same underlying issue —
a small/quantized model choosing its own formatting instead of the
literal free-text ---TITLE---/---TAGS--- delimiter — wearing a
different costume each time. Patching each new costume never addressed
why the model kept improvising one.
Ollama's structured-output support (a JSON Schema passed as the
request's `format`) constrains the model's actual output tokens at the
sampling level, not just via a prompt instruction it's free to ignore.
title/body/tags now come back as real JSON fields every time, so
parseResult/stripTitleDecoration's entire decoration-stripping
apparatus is gone — there's no delimiter left to mangle. Verified live
against production Ollama: clean structured output, no performance
penalty (4.35 gen tok/s / 19.04 prompt tok/s, in line with prior
benchmarks). This also fixes a real tags bug at the root: tags is a
real array now, so a model listing topics as a bulleted list can't get
comma-split into one garbled multi-line tag anymore (found a live
example of exactly that: a tag literally reading "- World War II
Memorial\n- Donald Trump", wrongly attached to 417 articles via
embedding similarity — left as historical data, not touched here).
Also adds a way to cancel a stuck generation from the admin Logs tab.
Ollama serves one request at a time, so a recap that's failing
repeatedly (or just going to take far longer than expected) blocked
every other merge/recap with no way to intervene short of restarting
the whole process — a real incident this session. inference/stats.ts
now tracks an AbortController alongside the in-flight label;
POST /api/admin/synthesis/cancel aborts it, which drops the connection
to Ollama and stops the underlying generation server-side. The caller
sees an ordinary failed generate() call, handled by existing
catch-and-log logic — composes correctly with the earlier recap
backoff fix (a cancelled attempt still counts as a failed one).
A failed recap attempt never advanced lastRecapAt, so isDue() kept
returning true and the exact same doomed recap retried on every single
synthesis tick, forever — a real incident: "Ukraine-Russia War" failed
(empty title / swapped halves) for 6+ hours straight. Each attempt
took 10+ minutes on this hardware, and Ollama serves one request at a
time, so that one stuck event blocked every other merge and recap
during each attempt. Its constituent article set also kept growing
every cycle (since's anchor never moved either), making each retry
slower and more truncation-prone than the last.
Add lastRecapAttemptAt, set on every attempt regardless of outcome
(markRecapAttempted, called right before publishEventRecap), and have
isDue() require a 30-minute cooldown since the last attempt on top of
the existing real-interval check against lastRecapAt. Verified all
four states directly: never-attempted (due), immediately after a
failure (not due), 31 minutes after a failure (due again), and
immediately after a genuine success (not due, waits for the real
interval) — unaffected by the new cooldown.
A real admin log sample showed qwen2.5:7b breaking titles far more
often than the earlier fixes accounted for — two new patterns neither
handled: a leading divider run combined with a label on the same line
("--- Title: Alpha Wealth Funds Report..."), and a markdown header used
as its own section marker ("### Headline:", or directly prefixing the
real title like "### Tenon Medical, Inc. Q2 2026 Earnings Summary") —
the latter recurring specifically on event recaps.
stripTitleDecoration now strips both before the existing numbered-list
and "Title:"/"Headline:" label checks, so a header+label combination
like "### Headline:\n<real title>" resolves the same as a same-line
"### <real title>". Verified against all 5 distinct broken-title
samples from that log, plus re-verified no regression on the three
earlier fixes (numbered-list, label-prefix, bare-divider).
Adds MergedArticle.synthesisInfo: { model, genTokensPerSec, durationMs }
to /api/feed and /api/article/:id — null for a direct-published
(single-source) article, which never ran through synthesis at all.
Denormalized onto merged_articles (3 new nullable columns) at publish
time from the same GenerateStats already recorded to synthesis_runs
for the admin Logs tab's benchmark history, rather than joined from
that table — it's an unpruned admin-only log, not meant for
per-article-view lookups. Verified round-trip on an isolated scratch
DB for both a synthesized article (real values) and a direct-published
one (null).
A model can write a lone "---" (no "TITLE" text) instead of the real
---TITLE--- delimiter, immediately followed by the actual headline as
plain text — TITLE_DELIMITER_RE doesn't match it, so parseResult fell
into the no-delimiter fallback and took the "---" line itself as the
title, leaving the real headline sitting as the body's own first line
(published live: title "---", body opening with the actual headline).
Strip any leading bare-divider line(s) from the fallback body before
computing the title, so the first *substantive* line is what
fallbackTitle actually sees. Verified against the exact real raw text.
bulkInsert() trusted a zip's row keys to exactly match the destination
table's live columns — broke in production: tracked_events still
carries the orphaned cadence/cadence_time columns from before commit
199177a's rewrite to recap_interval_hours (this app never drops a
column once added, so an instance old enough to predate that rename
keeps them forever, even though current schema code has no idea they
exist). Exporting from that instance and importing onto a fresher one
that never had those columns crashed with "table tracked_events has no
column named cadence".
Filter each row's columns against PRAGMA table_info of the destination
table before inserting, dropping anything the destination doesn't
actually have. Verified against the exact failure: seeded a scratch DB
with the same legacy columns production has, exported, imported onto a
clean instance, confirmed no crash and the real columns still land
correctly.
Sources, Tracked Items, Categories, Settings/Connections, Telegram
credentials, and every widget's own settings/data can now be exported
as a single zip from a new Backup tab (GET /api/admin/backup/export)
and loaded onto a fresh instance (POST /api/admin/backup/import) to
reproduce the same configuration — deliberately excludes ingested
items, published articles, media, tags, logs, and synthesis history,
none of which is "how this instance is configured". Widget tables are
discovered generically via the existing widget_<id>_ naming convention
(reused from widgets/sweep.ts) rather than hardcoded per widget, so an
uploaded (non-builtin) widget's code travels too, not just built-ins.
Import is full-replace, gated behind an explicit confirm() describing
the consequences. The one real FK in the schema
(content_items.source_id ON DELETE CASCADE) would otherwise let
replacing sources silently wipe that instance's own ingested content
as a side effect — the import transaction runs with
`PRAGMA foreign_keys = OFF` specifically to prevent that, verified
directly (seeded content_items, ran an import that replaced their
source, confirmed they survived).
Telegram credentials stay encrypted in the zip: the still-encrypted
row travels with the instance's .encryption-key file (exported from
storage/crypto.ts) rather than ever writing the secret out as
plaintext.
The key used to be a fresh randomBytes() on every process start, by
design — but that means every restart invalidates whatever's saved in
a password manager or bookmarked admin session. Now it's generated
once and written to backend/.env (ADMIN_API_KEY=...) on first launch,
then read back from there on every later launch. An explicit
ADMIN_API_KEY env var still wins outright, for Docker deployments that
have no writable .env inside the container — wired an optional
pass-through into both compose files for that case, since without it
they'd keep regenerating on every recreate regardless of this change.
Updated the handful of comments (server.ts, widgets/install.ts,
index.ts's startup banner, .env.example) that documented the old
every-restart-regenerates behavior as a load-bearing security property.
docker-compose.yml's build: blocks assume the backend/frontend source
tree sits alongside it, which breaks on a deploy host that only has
the compose file itself (e.g. Unraid's Compose Manager, which stores
it under /boot/config/plugins/compose.manager/...) — Compose Manager
prefers building over pulling whenever a build: key is present, even
if the image already exists in the registry, so it fails immediately
with "unable to prepare context". Build and push from a real checkout
against docker-compose.yml, then deploy this variant (image: only) on
the host that just needs to pull.
FRONTEND_ORIGIN/ORIGIN are hardcoded to https://salastil.com here
rather than reading ${PUBLIC_ORIGIN} from .env like docker-compose.yml
does — confirmed in production that Unraid's Compose Manager doesn't
reliably source a project .env file, and a stale localhost fallback
for these two breaks the site outright (SvelteKit's SSR fetch rejects
the backend's CORS response the instant they don't match exactly).
isActive() used pathname.startsWith(href), so viewing /category/fishtanklive
also lit up a category literally named "fish" — its href is a genuine
string prefix of the visited path. Both /category/[name] and /event/[id]
are leaf routes with no sub-pages, so an exact match is correct and
simpler; startsWith was never needed here.
Several real malformed-output failure modes showed up during live
model comparisons on the production box:
- A model writing the full article before ---TITLE--- and a short
fragment after it, which published the entire article as the title.
- A model emitting the ---TITLE--- delimiter with nothing before it,
publishing with a blank headline.
- A leaked reasoning-model preamble slipping past a looser title-length
check.
- A model skipping the delimiter format entirely for a numbered list
("1. \"Headline\"\n2. body..."), or prefixing its own "Title:" label
before the real delimiter — both of which left decoration (quotes,
numbering, a redundant "Title:" prefix, or the headline repeated
as the body's own first line) in the published result.
assertWellFormed now catches the first three as hard failures instead
of publishing garbage; parseResult strips title decoration and drops a
duplicate leading headline line for the fourth instead of publishing
it verbatim.
Also add a deterministic backstop for escalation-framing clichés
("escalating tensions" and its synonyms): custom-instructions bans on
exact phrases are weak against small/quantized models, which readily
substitute a synonym that dodges the literal string while keeping the
same cliché. Scan the generated body for the phrase family and retry
once with the specific violation named back to the model rather than
relying on the prompt alone.
Loosen the length/attribution rules in the base system prompts: length
is now "as much as the source material warrants" instead of a fixed
2-4 paragraphs, and outlet attribution is conditional instead of
mandatory, both driven by real token-budget analysis of the actual
production hardware and source material rather than an arbitrary cap.
Thread GenerateStats through synthesizeArticle/synthesizeRecap so the
real per-call throughput can be persisted (see synthesis_runs).
The admin panel had one shared "AI service connection" and three model
dropdowns (embedding, synthesis, image), but the image slot was pure
vestigial plumbing — no image-selection AI call exists anywhere. Remove
it, along with the shared connection panel, and give embedding/
clustering and article synthesis each their own independent host/port/
Test/Save connection in the Models tab, so they can point at different
Ollama installs. Threads an AiProviders { embedding, synthesis } bundle
through the pipeline/queue/scheduler layers wherever both are needed
together; functions that only ever call one or the other keep a plain
InferenceProvider argument.
Also, since nomic-embed already touches every article for clustering:
- Give non-synthesized (single-source/direct-published) articles tags
too, by matching their embedding against existing tags by cosine
similarity (tags.ts's scoreTagsBySimilarity) instead of running the
reasoning-heavy LLM tag-extraction step that was starving these
articles of tags in the first place.
- Dedupe resolveTagIds's output — two different LLM-proposed labels
can resolve to the same existing tag via embedding similarity, which
was producing literal duplicate tag ids on published articles.
- Add a "disable reasoning" (think:false) toggle for the synthesis
model, since Ollama's think parameter isn't respected uniformly
across reasoning models and CPU-only inference makes the hidden
reasoning pass expensive for no quality benefit on a rewrite task.
- Record real per-call throughput (prompt/gen tokens-per-second,
duration) from Ollama's own stats into a new synthesis_runs table,
surfaced as benchmark history in the admin Logs tab, for comparing
models on the actual production hardware.
Direct-file videos (RSS enclosures, non-YouTube embeds) were hotlinked
straight from the source's CDN and rendered as a dead placeholder box
on the article page. Add /media/video-proxy so the site fetches and
streams the file itself (with Range support for seeking), and rewrite
video.url to that route in every public API response. Extract the
SSRF host/IP validation mediaProxy.ts already had into a shared
ssrfGuard.ts so videoProxy.ts doesn't duplicate it.
Also add generic JSON-LD VideoObject scraping to articleFetcher so any
site publishing a standard schema.org video block gets its video
picked up, not just the ones with bespoke handling — this has to run
before Readability.parse(), which strips <script> tags as part of
extracting the article body.
Articles with a non-YouTube video (e.g. a direct mp4 from an RSS
enclosure) fell into the default branch, which showed only the hero
image thumbnail and rendered a dead "video embed" placeholder div
further down with no actual player. Add a branch that plays the video
inline at the top with object-fit: contain, same reasoning as the
feed-card fix, and drop the non-functional placeholder.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
object-fit: cover was scaling portrait videos to fill the landscape
media-cell box, cropping most of the frame. Since object-fit persists
into native fullscreen too, maximizing didn't help. Scope video
elements to object-fit: contain so the full frame is always visible,
while images keep their existing crop-to-fill behavior.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
docker-compose.yml's backend/frontend services now carry an image: name
pointed at git.salastil.com/salastil/homefeed-{backend,frontend}, so
compose build/push/pull all target the right place — overridable via
REGISTRY/IMAGE_TAG in .env for a different registry or a specific tag.
.gitea/workflows/docker-build.yml builds and pushes both images on
every push to master/development (latest on master, branch name +
commit SHA otherwise), using Gitea Actions' GitHub Actions-compatible
runner and its built-in GITHUB_TOKEN alias for registry auth. The
frontend build pulls VITE_BACKEND_URL from the repo's Actions
variables rather than hardcoding it, since that needs to be the real
public URL.
Multi-stage Dockerfiles for both (build stage with devDependencies,
slim production runtime), a root docker-compose.yml wiring them
together, and a root .env.example documenting the compose-level vars.
The frontend container runs with network_mode: service:backend rather
than its own network — VITE_BACKEND_URL is baked into the frontend
bundle at build time and used by both the visitor's browser and this
container's own server-rendered (SSR) fetches on a page's first load;
sharing the backend's network namespace is what lets a single
http://localhost:4000 value correctly resolve for both, since no
Compose-internal service-name hostname is reachable from a browser and
no host-only address is reachable from inside a separate container.
Backend gets a healthcheck (plain node -e fetch, no extra deps) so
depends_on: condition: service_healthy actually waits for it to be
ready rather than just started. backend-data volume persists the
SQLite DB, downloaded media, and uploaded widgets across restarts.
Added a root README "Docker" section and a root .gitignore for the
compose .env file.
The merge resolution took master's widget-based bookmarks-columns
implementation (widget_kv config, see widgets/bookmarks/db.ts) over
development's own GlobalSettings field for the same feature, but
types.ts's GlobalSettings interface still declared bookmarksColumns
since it didn't conflict during the merge — nothing populates it
anymore. The now-unused bookmarks_columns DB column is left in place
(same no-DROP-COLUMN precedent as the old weather_*/poe2_* columns).
Master just absorbed the widget-plugin rework (PR #23): Weather,
Stocks, Bookmarks, and PoE2 all moved into backend/src/widgets/<id>/,
each getting its own migrate()/registerPublicRoutes/registerAdminRoutes
under the shared WidgetPlugin interface, with routes renamed to
/api/widget/<id> and /api/admin/widget/<id>. Development's own recent
commits (the bookmarks 1/2/3-column layout, npm audit fix) were built
against the old inline architecture this supersedes — the bookmarks-
columns conflicts were resolved by taking master's widget-based
implementation of the same feature (widget_kv config + dedicated
route) over development's GlobalSettings.bookmarksColumns version.
Backend: patches find-my-way (DDoS via HTTP2), ip-address (SSRF via
octal/CIDR parsing), undici (response desync, cache/cookie disclosure)
transitive deps — 0 vulnerabilities remaining.
Frontend: patches postcss (high-severity source-map path traversal)
and bumps @sveltejs/kit to a patch release. Left 3 low-severity
`cookie` advisories unaddressed — the only fix npm offers for those is
`--force`, which would downgrade @sveltejs/kit to 0.0.30 and break the
app; not worth trading a high-severity crash for three low-severity
findings.
Backend: patches find-my-way (DDoS via HTTP2), ip-address (SSRF via
octal/CIDR parsing), undici (response desync, cache/cookie disclosure)
transitive deps — 0 vulnerabilities remaining.
Frontend: patches postcss (high-severity source-map path traversal)
and bumps @sveltejs/kit to a patch release. Left 3 low-severity
`cookie` advisories unaddressed — the only fix npm offers for those is
`--force`, which would downgrade @sveltejs/kit to 0.0.30 and break the
app; not worth trading a high-severity crash for three low-severity
findings.
migrate()'s PoE2/Stocks/Bookmarks table renames assumed the old-named
table's mere existence meant the widget_<id>_-named table didn't exist
yet, and failed with "already another table or index with this name"
on any DB where both were already present (e.g. an interrupted prior
migration, or an earlier widget-system build that created the new
name directly via plugin.migrate()'s CREATE TABLE IF NOT EXISTS).
renameTableIfSafe() now checks the destination name first and drops
the redundant old-named leftover instead of colliding with it — the
widget_<id>_-named table is always the one actually read from/written
to, so it's authoritative whenever both are present.
Master had just absorbed development's pre-widget-migration commits
(force-recap, synthesis token config, per-item recap style, blank-
article fix). This branch already carries equivalent or superset
functionality for all of it via the widget-plugin architecture, so
conflicts were resolved by keeping this branch's implementation
(dedicated /api/widget/<id> routes, installed_widgets-driven enable/
order state, generic widget poll loop) and dropping master's now-
redundant inline-settings versions of the same features. No net
functional change — just recorded as merged so the PR can go in clean.
The bookmarks list can now render as 1 (default), 2, or 3 columns
instead of always being a single-column list, admin-configurable via
a pill picker on the Widgets tab. Column count is a new bookmarksColumns
field on GlobalSettings (same idiom as the other scalar admin knobs)
and rides along in the public GET /api/bookmarks response so the
sidebar picks it up without a separate request.
The bookmarks list can now render as 1 (default), 2, or 3 columns
instead of always being a single-column list, admin-configurable via
a pill picker on the Widgets tab. Column count lives in widget_kv
(same idiom as weather's config) and rides along in the public
GET /api/widget/bookmarks response so the sidebar picks it up without
a separate request.
Lets an admin trigger a tracked item's recap immediately instead of
waiting out its recapIntervalHours cadence. Still summarizes only the
real window of new constituent articles since the last recap (or the
last 24h if never recapped), returning a friendly not-published reason
when there's nothing new rather than fabricating content.
Lets an admin trigger a tracked item's recap immediately instead of
waiting out its recapIntervalHours cadence. Still summarizes only the
real window of new constituent articles since the last recap (or the
last 24h if never recapped), returning a friendly not-published reason
when there's nothing new rather than fabricating content.
Adds a "Context window" panel to the Models tab: num_ctx and
num_predict sliders, bounded by the selected synthesis model's own
detected max context (via Ollama's /api/show, GET
/api/admin/model-context) when available, falling back to a generous
default otherwise. These now drive every synthesis/recap/tag-extraction
call instead of the old fixed 8192/700 constants — too low a
num_predict is exactly why a long recap or merge sometimes cut off
mid-sentence.
Recaps also get their own per-tracked-item writing style, independent
of the global Merge-tab style: a "More" collapsible section (minimized
by default) next to each tracked item's recap cadence, with the same
preset + free-text pattern as the Merge tab. The recap system prompt no
longer caps output at "3-5 short paragraphs" — it now asks for a full,
comprehensive article sized to the material, which only works well
together with a properly-sized num_predict.
Adds a "Context window" panel to the Models tab: num_ctx and
num_predict sliders, bounded by the selected synthesis model's own
detected max context (via Ollama's /api/show, GET
/api/admin/model-context) when available, falling back to a generous
default otherwise. These now drive every synthesis/recap/tag-extraction
call instead of the old fixed 8192/700 constants — too low a
num_predict is exactly why a long recap or merge sometimes cut off
mid-sentence.
Recaps also get their own per-tracked-item writing style, independent
of the global Merge-tab style: a "More" collapsible section (minimized
by default) next to each tracked item's recap cadence, with the same
preset + free-text pattern as the Merge tab. The recap system prompt no
longer caps output at "3-5 short paragraphs" — it now asks for a full,
comprehensive article sized to the material, which only works well
together with a properly-sized num_predict.