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.
Wires up POST /api/admin/articles/:id/reissue (added alongside the
blank-article fix) as a UI panel instead of requiring curl: paste an
article ID, it deletes the article and requeues its source items for
re-publish. Verified live in a browser against a real backend/DB —
both the success path and the "no article with that ID" 404 case.
Wires up POST /api/admin/articles/:id/reissue (added alongside the
blank-article fix) as a UI panel instead of requiring curl: paste an
article ID, it deletes the article and requeues its source items for
re-publish. Verified live in a browser against a real backend/DB —
both the success path and the "no article with that ID" 404 case.
A quantized model can occasionally return just the delimiter scaffold
("---TITLE---" / "---TAGS---") with no real headline or article text
in between — parseResult treated that as a structurally valid response
and published a blank article with empty title/body but real sources
and a hero image attached. synthesizeArticle/synthesizeRecap now throw
on an empty parsed body instead, so the existing catch-and-retry logic
in runSynthesisCycle leaves the cluster unclustered for the next tick
rather than ever inserting one of these.
Also adds POST /api/admin/articles/:id/reissue to fix articles already
published this way: the existing per-source reissue tool explicitly
refuses to touch a multi-source article, which this failure mode always
produces (an empty synthesis only happens on an actual multi-item
merge — a single-item cluster publishes verbatim with no AI call at
all), so there was no way to recover one without this.
A quantized model can occasionally return just the delimiter scaffold
("---TITLE---" / "---TAGS---") with no real headline or article text
in between — parseResult treated that as a structurally valid response
and published a blank article with empty title/body but real sources
and a hero image attached. synthesizeArticle/synthesizeRecap now throw
on an empty parsed body instead, so the existing catch-and-retry logic
in runSynthesisCycle leaves the cluster unclustered for the next tick
rather than ever inserting one of these.
Also adds POST /api/admin/articles/:id/reissue to fix articles already
published this way: the existing per-source reissue tool explicitly
refuses to touch a multi-source article, which this failure mode always
produces (an empty synthesis only happens on an actual multi-item
merge — a single-item cluster publishes verbatim with no AI call at
all), so there was no way to recover one without this.
- New /tag/[slug] page + GET /api/tag/:slug backend route so clicking a
tag chip lists every article carrying that tag, instead of 404ing.
- /event/[id] now shows when the tracked event's next AI recap is due,
computed from lastRecapAt + recapIntervalHours (now exposed on
GET /api/events).
- publishDirect (single-source and format-direct articles) now gets
tags too, via a new lightweight extractTags() call in synthesis.ts —
previously only AI-merged articles were tagged at all. Scheduler only
offers the provider through when Ollama is reachable, and categories
with AI explicitly disabled still stay tag-free.
- New /tag/[slug] page + GET /api/tag/:slug backend route so clicking a
tag chip lists every article carrying that tag, instead of 404ing.
- /event/[id] now shows when the tracked event's next AI recap is due,
computed from lastRecapAt + recapIntervalHours (now exposed on
GET /api/events).
- publishDirect (single-source and format-direct articles) now gets
tags too, via a new lightweight extractTags() call in synthesis.ts —
previously only AI-merged articles were tagged at all. Scheduler only
offers the provider through when Ollama is reachable, and categories
with AI explicitly disabled still stay tag-free.
Admins had no visibility into how many articles were queued for AI
synthesis or waiting out the hold-before-publish window, nor how fast
Ollama could clear that backlog. GET /api/admin/pipeline-stats reports
a live backlog snapshot (items awaiting embedding, clusters on hold vs.
ready, items still held) computed straight from the DB with no AI
calls, plus real Ollama generate() throughput (tokens/sec, in-flight
call) tracked from actual requests, and estimates minutes-to-clear from
recent generate() call durations. Surfaced as a stat-tile dashboard atop
the Logs tab.
Admins had no visibility into how many articles were queued for AI
synthesis or waiting out the hold-before-publish window, nor how fast
Ollama could clear that backlog. GET /api/admin/pipeline-stats reports
a live backlog snapshot (items awaiting embedding, clusters on hold vs.
ready, items still held) computed straight from the DB with no AI
calls, plus real Ollama generate() throughput (tokens/sec, in-flight
call) tracked from actual requests, and estimates minutes-to-clear from
recent generate() call durations. Surfaced as a stat-tile dashboard atop
the Logs tab.
Reported: after clearing all articles/media and rescanning every
source, items in "No AI" categories weren't publishing instantly like
they should.
Root cause: runSynthesisCycle bundled three unrelated jobs into one
function, all guarded by a single reentrancy lock (added earlier this
session to stop the AI-merge path from racing itself into duplicate
articles): (1) YouTube/Nitter/Telegram direct-publish, (2) "No AI"
category direct-publish, (3) embed/cluster/AI-merge. A mass rescan
produces a big backlog of slow generate() calls for (3) — each one
can run minutes on this CPU-only hardware — and since the whole
function shared one guard, a newly-ingested "No AI" item had to wait
for that entire backlog to drain before its own (fast, no-AI-needed)
publish step even got a turn.
Split into two independently-scheduled, independently-guarded ticks:
runDirectPublishCycle (source-type-driven + "No AI"-category items,
regardless of Ollama's reachability) and runSynthesisCycle (now only
the embed/cluster/merge path). They operate on disjoint item sets, so
running them "concurrently" is safe — no risk of the duplicate-publish
race the shared guard was originally added to prevent.
Verified directly: with a mock provider whose generate() call takes
3 seconds (standing in for a multi-minute real one), a "No AI"
category item published in 68ms — before the slow merge was even
close to finishing — while the merge itself still completed correctly
on its own schedule.
Reported: after clearing all articles/media and rescanning every
source, items in "No AI" categories weren't publishing instantly like
they should.
Root cause: runSynthesisCycle bundled three unrelated jobs into one
function, all guarded by a single reentrancy lock (added earlier this
session to stop the AI-merge path from racing itself into duplicate
articles): (1) YouTube/Nitter/Telegram direct-publish, (2) "No AI"
category direct-publish, (3) embed/cluster/AI-merge. A mass rescan
produces a big backlog of slow generate() calls for (3) — each one
can run minutes on this CPU-only hardware — and since the whole
function shared one guard, a newly-ingested "No AI" item had to wait
for that entire backlog to drain before its own (fast, no-AI-needed)
publish step even got a turn.
Split into two independently-scheduled, independently-guarded ticks:
runDirectPublishCycle (source-type-driven + "No AI"-category items,
regardless of Ollama's reachability) and runSynthesisCycle (now only
the embed/cluster/merge path). They operate on disjoint item sets, so
running them "concurrently" is safe — no risk of the duplicate-publish
race the shared guard was originally added to prevent.
Verified directly: with a mock provider whose generate() call takes
3 seconds (standing in for a multi-minute real one), a "No AI"
category item published in 68ms — before the slow merge was even
close to finishing — while the merge itself still completed correctly
on its own schedule.
Reported symptom: articles that never got AI-merged (single source,
nothing else to combine with) simply never published at all.
Root cause: the same default-5-minute-fetch-timeout bug fixed for
generate() earlier was never applied to embed(). Ollama serves one
inference request at a time (n_slots = 1) — an embed() call issued
while a slow generate() call is in flight has to wait in queue for
that same slot, and on this CPU-only hardware a generate() call can
easily run past 5 minutes. That wait alone was enough to trip Node's
default fetch timeout on the embed request.
embedPendingItems() catches that failure and just drops the item from
its result (logged, not thrown) — clusterItems() only ever sees items
that already have an embedding, so a dropped item never joins a
cluster, never gets assignCluster() called, and stays "unclustered"
forever, retried every cycle with the same failure for as long as
Ollama stays busy. An item that happened to embed during an idle
window still merges or publishes fine — which is exactly the split
reported: synthesized articles show up, standalone ones don't.
Fix: embed() now uses the same noTimeoutDispatcher already wired into
generate(). Verified the request completes correctly end-to-end
against a real HTTP server that delays its response.
Reported symptom: articles that never got AI-merged (single source,
nothing else to combine with) simply never published at all.
Root cause: the same default-5-minute-fetch-timeout bug fixed for
generate() earlier was never applied to embed(). Ollama serves one
inference request at a time (n_slots = 1) — an embed() call issued
while a slow generate() call is in flight has to wait in queue for
that same slot, and on this CPU-only hardware a generate() call can
easily run past 5 minutes. That wait alone was enough to trip Node's
default fetch timeout on the embed request.
embedPendingItems() catches that failure and just drops the item from
its result (logged, not thrown) — clusterItems() only ever sees items
that already have an embedding, so a dropped item never joins a
cluster, never gets assignCluster() called, and stays "unclustered"
forever, retried every cycle with the same failure for as long as
Ollama stays busy. An item that happened to embed during an idle
window still merges or publishes fine — which is exactly the split
reported: synthesized articles show up, standalone ones don't.
Fix: embed() now uses the same noTimeoutDispatcher already wired into
generate(). Verified the request completes correctly end-to-end
against a real HTTP server that delays its response.
Every article title ended in "…" because there was never an actual
title — deriveTitle() just took the body's first paragraph and cut
it at 97 characters. The AI was never asked for a headline at all.
Both system prompts now ask for a response in three parts (headline,
then the article/recap, then tags), each separated by a delimiter.
parseResult() extracts all three; if the model doesn't follow the
format at all, it falls back to the old truncated-first-line
heuristic rather than breaking.
Delimiter matching is now a loose regex instead of an exact string —
production had already shown a small model reproducing "---TAGS---"
inexactly (e.g. "---\n\nTAGS---"), which the old exact-string split
missed entirely and leaked into the published body. Same tolerance
now applies to the new title delimiter.
publishCluster uses the synthesized title directly; publishEventRecap
uses it too, falling back to the previous "<event>: recap" format
only if the model returns an empty title.
Verified: exact-format output, sloppy-delimiter output, and
no-delimiter-at-all output all parse into sensible {title, body,
tags}; a full runSynthesisCycle pass against a mock provider
publishes an article with the real synthesized headline as its title.
Every article title ended in "…" because there was never an actual
title — deriveTitle() just took the body's first paragraph and cut
it at 97 characters. The AI was never asked for a headline at all.
Both system prompts now ask for a response in three parts (headline,
then the article/recap, then tags), each separated by a delimiter.
parseResult() extracts all three; if the model doesn't follow the
format at all, it falls back to the old truncated-first-line
heuristic rather than breaking.
Delimiter matching is now a loose regex instead of an exact string —
production had already shown a small model reproducing "---TAGS---"
inexactly (e.g. "---\n\nTAGS---"), which the old exact-string split
missed entirely and leaked into the published body. Same tolerance
now applies to the new title delimiter.
publishCluster uses the synthesized title directly; publishEventRecap
uses it too, falling back to the previous "<event>: recap" format
only if the model returns an empty title.
Verified: exact-format output, sloppy-delimiter output, and
no-delimiter-at-all output all parse into sensible {title, body,
tags}; a full runSynthesisCycle pass against a mock provider
publishes an article with the real synthesized headline as its title.
The synthesis system prompts were previously the only "instructions"
the AI ever got, hardcoded and invisible from the admin panel — no
way to control tone, and no way to know what was actually being sent
without reading the source.
Adds a "Writing style" panel to the Merge tab: a preset dropdown
(Default/Casual/Formal) plus a free-text field for arbitrary
additional instructions (e.g. "keep paragraphs under 3 sentences").
Both are appended as an addendum to the existing base system prompts
in synthesis.ts — the structural rules (attribution, paragraph count,
tag format) are never overridden, only style on top of them. Applies
to AI-merged articles and event recaps; single-source items still
publish verbatim with no AI involved either way.
Backend: new global_settings.synthesis_style_preset (default) and
.synthesis_custom_instructions ('') columns, migrated in for existing
installs, threaded through settings.ts and into synthesizeArticle/
synthesizeRecap's system prompt construction.
Verified: settings round-trip through GET/PATCH /api/admin/settings
with correct defaults; a captured prompt confirms 'default' with no
custom text produces the exact original prompt unchanged, while
'casual' + custom text appends both correctly; migration against an
old-schema global_settings table adds both columns with correct
defaults.
The synthesis system prompts were previously the only "instructions"
the AI ever got, hardcoded and invisible from the admin panel — no
way to control tone, and no way to know what was actually being sent
without reading the source.
Adds a "Writing style" panel to the Merge tab: a preset dropdown
(Default/Casual/Formal) plus a free-text field for arbitrary
additional instructions (e.g. "keep paragraphs under 3 sentences").
Both are appended as an addendum to the existing base system prompts
in synthesis.ts — the structural rules (attribution, paragraph count,
tag format) are never overridden, only style on top of them. Applies
to AI-merged articles and event recaps; single-source items still
publish verbatim with no AI involved either way.
Backend: new global_settings.synthesis_style_preset (default) and
.synthesis_custom_instructions ('') columns, migrated in for existing
installs, threaded through settings.ts and into synthesizeArticle/
synthesizeRecap's system prompt construction.
Verified: settings round-trip through GET/PATCH /api/admin/settings
with correct defaults; a captured prompt confirms 'default' with no
custom text produces the exact original prompt unchanged, while
'casual' + custom text appends both correctly; migration against an
old-schema global_settings table adds both columns with correct
defaults.
A cluster of one item still went through synthesizeArticle to be
"lightly rewritten" — the only recent real-world example fabricated
a fake two-outlet merge out of one genuine article (see the
opaque-sourceId attribution fix). There's no actual synthesis to do
with one source, so the rewrite step only added risk (hallucinated
attribution, subtly altered facts) for no benefit.
priorityQueue.ts's runSynthesisCycle now routes a 1-item cluster to
publishDirect instead of publishCluster — same verbatim-text path
already used for youtube/nitter/telegram items and AI-disabled
categories. publishCluster is now only ever called with 2+ items, so
its doc comment and synthesis.ts's system prompt no longer reference
the single-source case.
Verified directly: a 1-item cluster now publishes with the original
body untouched and zero calls to the model, while a 2-item cluster
still goes through the AI merge path unchanged.
A cluster of one item still went through synthesizeArticle to be
"lightly rewritten" — the only recent real-world example fabricated
a fake two-outlet merge out of one genuine article (see the
opaque-sourceId attribution fix). There's no actual synthesis to do
with one source, so the rewrite step only added risk (hallucinated
attribution, subtly altered facts) for no benefit.
priorityQueue.ts's runSynthesisCycle now routes a 1-item cluster to
publishDirect instead of publishCluster — same verbatim-text path
already used for youtube/nitter/telegram items and AI-disabled
categories. publishCluster is now only ever called with 2+ items, so
its doc comment and synthesis.ts's system prompt no longer reference
the single-source case.
Verified directly: a 1-item cluster now publishes with the original
body untouched and zero calls to the model, while a 2-item cluster
still goes through the AI merge path unchanged.
buildPrompt() labeled each source with item.sourceId — an internal
DB foreign key like "src-e8dbf745-..." — never the outlet's actual
name. The model had no real outlet to attribute to, so on a
single-source item it fell back to copying the illustrative example
names straight out of its own system prompt ("Reuters reported...",
"AP notes...") and fabricated a two-outlet merge out of one real
6abc article. The article's sources metadata (built separately from
real DB records) was correct the whole time; only the AI-written body
text invented sources that were never in the input.
synthesizeArticle now takes a sourceId->name map (built in publish.ts
via the same sources.getSource() lookup already used for the sources
metadata) and buildPrompt labels each entry with the real name.
SYSTEM_PROMPT no longer gives concrete example outlet names to copy —
it references "each source's exact name as given below" and
explicitly forbids attributing to any outlet not actually provided.
Verified directly: captured the exact prompt text sent to a mock
provider and confirmed it now contains the real source name and never
the raw internal id.
buildPrompt() labeled each source with item.sourceId — an internal
DB foreign key like "src-e8dbf745-..." — never the outlet's actual
name. The model had no real outlet to attribute to, so on a
single-source item it fell back to copying the illustrative example
names straight out of its own system prompt ("Reuters reported...",
"AP notes...") and fabricated a two-outlet merge out of one real
6abc article. The article's sources metadata (built separately from
real DB records) was correct the whole time; only the AI-written body
text invented sources that were never in the input.
synthesizeArticle now takes a sourceId->name map (built in publish.ts
via the same sources.getSource() lookup already used for the sources
metadata) and buildPrompt labels each entry with the real name.
SYSTEM_PROMPT no longer gives concrete example outlet names to copy —
it references "each source's exact name as given below" and
explicitly forbids attributing to any outlet not actually provided.
Verified directly: captured the exact prompt text sent to a mock
provider and confirmed it now contains the real source name and never
the raw internal id.
The synthesis tick fires every 60 seconds via setInterval with no
reentrancy guard. An item only gets marked "clustered" after its
article finishes synthesizing and publishing — so once generate()
calls started legitimately taking longer than 60 seconds (bigger
prompts + no client timeout, both from earlier fixes in this line of
work), the next tick would fire mid-generation, see the same item
still "unclustered", and synthesize + publish it again as a fresh,
differently-worded article. Repeated overlaps produced a run of
near-identical articles from the same single source item, seconds
apart.
everyTickSkippingOverlap() now guards all three scheduler intervals
(poll, synthesis, retention): a tick is skipped outright if the
previous invocation hasn't finished, rather than overlapping it.
Verified in isolation — a task slower than its own tick interval
never overlaps itself (measured max concurrency of 1).
The synthesis tick fires every 60 seconds via setInterval with no
reentrancy guard. An item only gets marked "clustered" after its
article finishes synthesizing and publishing — so once generate()
calls started legitimately taking longer than 60 seconds (bigger
prompts + no client timeout, both from earlier fixes in this line of
work), the next tick would fire mid-generation, see the same item
still "unclustered", and synthesize + publish it again as a fresh,
differently-worded article. Repeated overlaps produced a run of
near-identical articles from the same single source item, seconds
apart.
everyTickSkippingOverlap() now guards all three scheduler intervals
(poll, synthesis, retention): a tick is skipped outright if the
previous invocation hasn't finished, rather than overlapping it.
Verified in isolation — a task slower than its own tick interval
never overlaps itself (measured max concurrency of 1).
Nothing published for hours, every cluster failing with "fetch failed".
Ollama's own log showed the real story: requests being cancelled at
exactly 5m0s with a 500, not a model or server error. Node's global
fetch (undici) defaults to a 5-minute headers/body timeout, and CPU-only
prompt processing on the reference hardware (i5-6600K, no GPU, ~17
tok/s) legitimately takes longer than that once prompts carry full
article bodies instead of short blurbs (the previous fix in this same
line of work) — every generate() call past a few thousand tokens got
killed client-side before Ollama could finish.
OllamaProvider.generate() now passes a dedicated undici Agent with
headersTimeout/bodyTimeout disabled as the fetch dispatcher, so the
request runs as long as it actually needs to. Verified the failure
mode and the fix directly: a short-timeout dispatcher against a
deliberately slow server reproduces the exact same "fetch failed" /
UND_ERR_HEADERS_TIMEOUT error seen in production, and a zero-timeout
dispatcher completes the same slow request without issue.
undici was already a transitive dependency (via jsdom); added directly
since ollama-provider.ts now imports from it.
Nothing published for hours, every cluster failing with "fetch failed".
Ollama's own log showed the real story: requests being cancelled at
exactly 5m0s with a 500, not a model or server error. Node's global
fetch (undici) defaults to a 5-minute headers/body timeout, and CPU-only
prompt processing on the reference hardware (i5-6600K, no GPU, ~17
tok/s) legitimately takes longer than that once prompts carry full
article bodies instead of short blurbs (the previous fix in this same
line of work) — every generate() call past a few thousand tokens got
killed client-side before Ollama could finish.
OllamaProvider.generate() now passes a dedicated undici Agent with
headersTimeout/bodyTimeout disabled as the fetch dispatcher, so the
request runs as long as it actually needs to. Verified the failure
mode and the fix directly: a short-timeout dispatcher against a
deliberately slow server reproduces the exact same "fetch failed" /
UND_ERR_HEADERS_TIMEOUT error seen in production, and a zero-timeout
dispatcher completes the same slow request without issue.
undici was already a transitive dependency (via jsdom); added directly
since ollama-provider.ts now imports from it.
Category priority admin pane gains a "No AI" checkbox alongside
Private/More. When set, items whose source falls under that category
skip embedding, clustering, and LLM synthesis entirely — each
publishes on its own, verbatim from its source (title + body/summary),
the same direct-publish path YouTube/Nitter/Telegram items always use.
Backend: new categories.disable_ai column (default off, migrated in
for existing installs), threaded through categories.ts CRUD and the
POST /api/admin/categories + PATCH /api/admin/settings routes.
priorityQueue.ts's runSynthesisCycle now partitions items three ways
before clustering: source-type direct (youtube/nitter/telegram),
category-disabled direct (new), then whatever's left goes through the
normal embed/cluster/synthesize pipeline.
Tracked-event recaps are a separate, already-existing per-event
toggle (TrackedEvent.recapIntervalHours) since events aren't tied to
a single category — unaffected by this change.
Category priority admin pane gains a "No AI" checkbox alongside
Private/More. When set, items whose source falls under that category
skip embedding, clustering, and LLM synthesis entirely — each
publishes on its own, verbatim from its source (title + body/summary),
the same direct-publish path YouTube/Nitter/Telegram items always use.
Backend: new categories.disable_ai column (default off, migrated in
for existing installs), threaded through categories.ts CRUD and the
POST /api/admin/categories + PATCH /api/admin/settings routes.
priorityQueue.ts's runSynthesisCycle now partitions items three ways
before clustering: source-type direct (youtube/nitter/telegram),
category-disabled direct (new), then whatever's left goes through the
normal embed/cluster/synthesize pipeline.
Tracked-event recaps are a separate, already-existing per-event
toggle (TrackedEvent.recapIntervalHours) since events aren't tied to
a single category — unaffected by this change.
buildPrompt() only ever sent ContentItem.summary (a ~500-char RSS
description) to the model, never .body (the full article text when
the feed provides <content:encoded>) — even though publishDirect
already preferred body over summary for the no-AI-merge path. A
single-source cluster was effectively asking the model to "lightly
rewrite" a one-paragraph blurb, which it did almost verbatim,
producing a short repeated synopsis instead of an actual article.
Now mirrors publishDirect's item.body || item.summary fallback. Body
is already HTML-stripped at ingestion (ingestion/adapters/base.ts),
so no new sanitization needed. The per-entry character budget added
in the previous truncation fix now does real work here, since full
bodies can be much longer than summaries.
buildPrompt() only ever sent ContentItem.summary (a ~500-char RSS
description) to the model, never .body (the full article text when
the feed provides <content:encoded>) — even though publishDirect
already preferred body over summary for the no-AI-merge path. A
single-source cluster was effectively asking the model to "lightly
rewrite" a one-paragraph blurb, which it did almost verbatim,
producing a short repeated synopsis instead of an actual article.
Now mirrors publishDirect's item.body || item.summary fallback. Body
is already HTML-stripped at ingestion (ingestion/adapters/base.ts),
so no new sanitization needed. The per-entry character budget added
in the previous truncation fix now does real work here, since full
bodies can be much longer than summaries.
Ollama was defaulting to a 4096-token context (vs. the model's 32768
training context) and silently truncating any oversized prompt by
dropping content from the middle, with no error surfaced anywhere —
observed losing ~53% of a merge-cluster prompt in production. Two
prompt builders (buildPrompt/buildRecapPrompt) concatenated all
source summaries/article bodies with no size cap, so a cluster with
enough sources (or a recap spanning enough articles) could easily
exceed the window.
Fix: OllamaProvider.generate() now always sends explicit num_ctx/
num_predict options (sized for CPU-only inference — i5-6600K, no GPU,
~17 tok/s prompt processing) instead of leaving Ollama to pick a
default. synthesis.ts now caps prompt size itself before it ever
reaches Ollama, giving each source/article an equal character budget
and trimming individual entries rather than dropping whole ones off
the end — every source stays at least partially represented and
attributable. Trims are logged via the existing admin log stream
instead of failing silently.
Ollama was defaulting to a 4096-token context (vs. the model's 32768
training context) and silently truncating any oversized prompt by
dropping content from the middle, with no error surfaced anywhere —
observed losing ~53% of a merge-cluster prompt in production. Two
prompt builders (buildPrompt/buildRecapPrompt) concatenated all
source summaries/article bodies with no size cap, so a cluster with
enough sources (or a recap spanning enough articles) could easily
exceed the window.
Fix: OllamaProvider.generate() now always sends explicit num_ctx/
num_predict options (sized for CPU-only inference — i5-6600K, no GPU,
~17 tok/s prompt processing) instead of leaving Ollama to pick a
default. synthesis.ts now caps prompt size itself before it ever
reaches Ollama, giving each source/article an equal character budget
and trimming individual entries rather than dropping whole ones off
the end — every source stays at least partially represented and
attributable. Trims are logged via the existing admin log stream
instead of failing silently.
A widget's own custom routes previously required a full process restart to
register — impractical in practice, since the admin API key regenerates on
every restart and would log the admin out of the panel they were just using
to install the widget.
New backend/src/server.ts owns building and swapping the Fastify instance,
split into two steps: validateRoutesBuildable() builds a candidate app and
listens on a throwaway ephemeral port to catch a broken widget's route
registration (e.g. a path collision) before anything live is touched, and
swapLiveServer() does the real close-old/build-new/listen-new cycle on the
actual port. Only the HTTP server and its router are rebuilt — the DB
connection, in-memory widget registry, scheduler intervals, Telegram
session, and admin API key all stay untouched in the same running process.
The split exists because of a real bug hit in testing: the admin routes that
trigger install/delete are themselves served by the live Fastify instance, so
awaiting the full swap inline closed the connection before the response could
be sent (a DELETE that should have returned 204 came back as a bare
connection reset). Now install/uninstall only awaiit the safe ephemeral-port
validation inline (letting a broken widget be rejected and rolled back within
its own request), and the admin routes schedule the actual swap via
setImmediate after their response is already on the wire.
Verified live: uploaded a widget with a custom route, confirmed a clean 201
and the route working moments later with no restart (same PID, same admin
API key); deleted it and confirmed a clean 204, the route gone, and core
widget routes unaffected; and uploaded a deliberately broken widget whose
route collided with /health, confirming it was rejected with a 400, fully
rolled back, and /health kept responding normally throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
Weather, Stocks, and Bookmarks move into backend/src/widgets/<id>/ alongside
PoE2, each a full WidgetPlugin rather than a thin wrapper: their tables are
renamed to the widget_<id>_ convention (stock_tickers, bookmarks) or moved off
global_settings entirely into the generic widget_kv store (weather's
location/unit config and forecast cache). Every widget's routes move under a
consistent /api/widget/<id> (public) and /api/admin/widget/<id> (admin)
namespace, replacing the previous flat /api/weather, /api/admin/poe2/browse,
etc. The registry-management routes (list/upload/enable/delete any widget)
stay at /api/admin/widgets since they address the collection, not one
widget's own data.
This also closes the gap where an uploaded widget had backend data plumbing
but no visible presence anywhere: a widget's poll() can now publish to a
generic GET /api/widget/:id/report feed (registered once, works for any id
with zero per-widget route registration, so it's live immediately after
upload with no restart); the sidebar renders it via a new GenericWidgetCard,
or via a new DynamicWidgetSlot that dynamic-imports an optional pre-built
vanilla-JS frontend bundle the widget can ship (served from a new
/widget-assets/:id/* route) — plain browser import(), not blocked by
SvelteKit's ahead-of-time Svelte compilation the way raw .svelte source would
be. A widget's own custom API routes still need a restart to register
(a hard Fastify limitation), but its data/poll/report and any custom frontend
UI now work fully live. The admin Widgets tab gained an upload form and a
list of installed pluggable widgets with enable/delete.
Verified against a copy of the real dev DB (all three renames + the weather
config/cache migration fire once and are idempotent on a second boot) and
through a real browser: uploaded a widget with both a report-driven poll and
a custom frontend bundle, confirmed it renders live in the sidebar with no
backend restart, then deleted it and confirmed it disappears along with all
of its data (table/kv rows/on-disk files).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8