Commit Graph

640 Commits

Author SHA1 Message Date
chriscrosstalk
8f6b17b565
fix(drug-reference): responsive compare columns and dark-mode readability (#1163) 2026-08-04 03:39:57 +00:00
chriscrosstalk
131cb59fc1
fix(downloads): don't 500 the jobs endpoint on an orphaned BullMQ job (#1191)
A job id can outlive its payload hash. BullMQ still returns an entry for
it with empty `data`, so `normalize(job.data.filepath)` threw and took the
whole response with it.

`fetchJobsWithStates()` includes failed jobs, and failed jobs are retained
deliberately, so one orphan made GET /api/downloads/jobs throw on every
call, permanently. Content Explorer polls that endpoint every ~3s, so the
symptom was a 500 loop and a page that never loaded. It survived restarts
and only cleared by editing Redis by hand.

Drop entries with no usable payload at the source, and guard both
normalize() calls (the pmtiles-extract map had the same pattern). With no
payload there is nothing to render anyway.

Seen on NOMAD3 running rc.3: 93 occurrences in 15 minutes.

Closes #1190

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:56 +00:00
chriscrosstalk
32e2c3d10e
fix(kb): respect ingest policy when a ZIM is uploaded locally (#1184)
ZimService.registerLocalUpload() dispatched EmbedFileJob unconditionally,
gated only on whether Ollama was reachable. It never read
rag.defaultIngestPolicy, so a user who deliberately chose Manual still got
sideloaded ZIMs embedded into the knowledge base behind their back.

PR #919 fixed exactly this for the post-download dispatch path. The
local-upload path was missed.

Rather than inline a third copy of the Always/Manual conditional, this
reuses decideScanAction, the same helper the scanner uses. That also means
an existing browse_only or pending_decision row is now honored instead of
being overridden by the act of re-uploading the file, which the inline
version in run_download_job.ts does not do.

Unset policy is still treated as Always, so existing installs keep their
current behavior.

Reported by @just-jbc on #1119, which also proposed disabling ZIM
auto-discovery entirely. That larger change is not included here: turning
discovery off by default would mean a user who downloads Wikipedia through
the curated flow gets no AI answers from it and no explanation why.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:56 +00:00
chriscrosstalk
dabc869aeb
fix(chat): open full chat in place instead of a new window (#1181)
The chat modal's pop-out button called window.open('/chat', '_blank').
/chat is served by the admin app itself, so this spawned a second browser
window for a same-origin internal route.

That breaks anyone running NOMAD as an installed web app or in kiosk mode:
clicking it leaves a stray window they then have to get back out of, which
is exactly the complaint in #1123.

Navigate with router.visit instead, and relabel the button from "Open in
New Tab" to "Open Full Chat" so it describes what now happens. IconMessage
isn't in the DynamicIcon registry (deliberately curated for tree-shaking),
so use the already-registered IconArrowRight.

Refs #1123

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:55 +00:00
chriscrosstalk
cdf6c00d4c
feat: support gated downloads for self-hosted curated content (#1172)
Adds an optional `auth: 'nomad_app_key'` discriminator to curated manifest
resources so a curated collection tier can carry content we host ourselves,
gated to official release builds. Without this, only Creator Packs could use
the entitlement Worker; curated tier installs always downloaded
unauthenticated.

No behaviour change for any existing manifest entry: absent `auth` means
unauthenticated, exactly as today.

- `auth` declared on both the type and the VineJS validator. It has to be on
  the validator or VineJS strips it silently on fetch, and the gated download
  would then go out with no header and 401 for everyone. A dedicated spec
  guards that regression.
- Gated resources are pinned to their manifest URL (resolveZimDownload skips
  the catalog comparison) and excluded from catalog update checks, so a
  resource-id collision cannot let a third-party mirror overwrite our content.
  Consequence, commented rather than implied: gated content does not
  auto-update; new versions ship via the manifest.
- 401/403 on a download now reports that an official build is required instead
  of a raw axios status, which is what a fork build will hit.
- The pure `isGatedResource` predicate is deliberately split from the
  env-reading header builder: importing `#start/env` into
  zim_download_resolution triggers env validation at import time and breaks its
  unit tests.

Reuses CREATOR_PACKS_APP_KEY rather than minting a second secret — the question
it answers ("is this an official build?") is identical for both content types.

Verified end to end on a test server: `auth` survives validation into the
cached spec, the Bearer header attaches to only the gated resource, the file
lands byte-exact with an installed_resources row and a Kiwix library entry, and
an entry with a gated URL but no `auth` field fails with the intended message.

No catalog entry is included here. Manifests are fetched live from `main`, so a
gated entry must not merge until this ships and is adopted — pre-`auth` builds
strip the field and 401.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:55 +00:00
chriscrosstalk
c46d658fb5
fix(benchmark): don't block submission when the AI host is this machine (#1166)
The #1157 gate blocks leaderboard submission whenever ai.remoteOllamaUrl is
set, using bare truthiness with no exemption for addresses that point back at
this same box. Someone running Ollama natively on the host while NOMAD runs in
Docker is measuring THIS hardware, yet is permanently blocked from submitting
with no in-app indication of why.

That configuration is not exotic: it is how the AI assistant is expected to
work on macOS, so under the current gate a Mac could never submit at all.

Exempts host.docker.internal (and gateway.docker.internal) plus the loopback
forms. host.docker.internal is the meaningful one — from inside the admin
container it resolves to the host, and it is what a native host install uses.

A LAN address is deliberately NOT exempt. 192.168.1.50 is indistinguishable
from another machine on the same network, and wrongly exempting it would let a
genuinely remote GPU's throughput be attributed to this hardware. A false block
is recoverable by clearing the setting; a false pass silently corrupts the
board.

Unparseable values fall through to blocked rather than allowed, so a malformed
setting cannot be used to slip past the gate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:55 +00:00
jakeaturner
67a91ed5f6
docs: update release notes 2026-08-04 03:39:54 +00:00
chriscrosstalk
eda6560e00
feat(benchmark): official multi-arch sysbench, resolved digest, platform metadata (#1158)
Three changes that together let ARM hardware appear on the leaderboard honestly.
Shipping them separately would leave ARM half-supported either way: without the
image a Pi cannot submit at all, and without the architecture field it submits
but is indistinguishable from x86.

1. PIN THE OFFICIAL MULTI-ARCH SYSBENCH IMAGE

severalnines/sysbench publishes amd64 only, so ARM hosts could not run the
System Benchmark at all — not a graceful failure, the container simply cannot
execute. Apple Silicon could only run it under Rosetta emulation, which distorts
the measurement it is taking, and that is what drove a community macOS fork to
substitute a different benchmark and submit incomparable numbers.

Swaps to ghcr.io/crosstalk-solutions/nomad-sysbench (Debian 12 + sysbench
1.0.20+ds-5, built for linux/amd64 + linux/arm64). One digest covers both
architectures; verified that pulling the pinned manifest-list digest resolves to
arm64 on a Raspberry Pi 5 and amd64 on x86, and that RepoDigests reports the
same manifest-list digest on both — so a single allowlist entry serves both.

No rescoring: 1.0.17 -> 1.0.20 measured 1.25% apart on identical hardware with
identical flags (7170.18 vs 7259.56 events/sec), inside run-to-run noise and
~0.3% on a composite. Both digests are allowlisted server-side, so the fleet can
cross over gradually.

2. REPORT THE DIGEST ACTUALLY RESOLVED

The submission previously sent SYSBENCH_DIGEST, the constant the client was
compiled with. The leaderboard validates that field, but a constant attests to
how a client was BUILT rather than what it RAN, so any build inherits a valid
value simply by carrying the same source.

Now reads it back from the image. Uses RepoDigests (the manifest digest we
pulled by), never Id — Id is the config digest, differs per architecture, and
would never match the allowlist. Falls back to the constant if inspection yields
nothing usable, so a benchmark never fails over provenance metadata.

Still forgeable, and always will be with an open-source client. It moves the bar
from "no effort" to "deliberate", which is the distinction that matters when
judging whether a submission is a mistake or a choice.

3. RECORD CPU ARCHITECTURE AND OS

The leaderboard is a single board across instruction sets by design, with
disclosure as the fairness mechanism. Without an architecture field an ARM result
sits unlabelled beside x86 — exactly what the disclosure exists to prevent.

All three fields come from the Docker daemon, reusing the docker.info() call
_detectRunEnvironment already makes. That is deliberate: inside the admin
container os.arch() and si.osInfo() describe the CONTAINER, not the host being
benchmarked.

  cpu_architecture  Architecture       x86_64 -> amd64, aarch64 -> arm64
  os_version        OSVersion          '24.04' (already structured, no parsing)
  os_name           OperatingSystem    'Ubuntu 24.04.4 LTS' minus the version

run_environment is kept rather than replaced: "which distro" and "is this
virtualised" are different questions, and WSL2 is a real performance factor.

String handling lives in app/utils/platform_metadata.ts with unit tests, matching
the amd_hsa_override convention, so it is testable without a Docker daemon.
Unknown architectures pass through verbatim rather than being guessed at, and
os_name falls back to the full description whenever the version is missing or
absent from it — an over-long name is harmless, a wrong one is not.

Columns are nullable and the submission fields optional, so results recorded
before this shipped remain submittable.

Closes #1156
Refs #1151
2026-08-04 03:39:53 +00:00
chriscrosstalk
e4967d7729
fix(benchmark): block leaderboard submission when AI runs on a remote host (#1157)
DockerService.getServiceURL() resolves ai.remoteOllamaUrl ahead of the local
container, so when a remote AI host is configured the AI channel measures THAT
machine while every other channel measures this one. The submission then reports
someone else's tok/s under this hardware's CPU, RAM and disk.

Under v2 this matters more than it did under v1: ai_tokens_per_second carries
0.30 of the weight and the score is uncapped, so a remote GPU's throughput is no
longer limited by a clamp.

Adds a guard alongside the existing submit-time checks. Uses the same truthiness
predicate as getServiceURL, so the guard fires exactly when the remote routing
it is guarding against would occur. KVStore.clearValue() nulls the value and
getValue() returns null for that, so a cleared key correctly does not trip it.

Blocks submission only. Running the benchmark locally is still useful to the
operator — it just isn't a result about this box, so it shouldn't go on a board
that ranks hardware.

Known limitation: the check reads the KV at submit time, not at measurement
time, so benchmarking with a remote host and then clearing the setting before
submitting would still get through. That is a deliberate workaround rather than
an accident, and closing it properly needs a column on benchmark_results to
record how inference was reached. Worth doing if it ever shows up in practice;
not worth a migration on the evidence available.

Refs #1151
2026-08-04 03:39:53 +00:00
chriscrosstalk
b1d507a7ed
feat(creator-packs): add missing Modern Rogue banner asset (#1147)
The modern-rogue pack entry landed in collections/creator-packs.json
(#1145/#1146) but its banner image was never committed, so the app fell
back to /creator-packs/modern-rogue.webp which 404s and the card rendered
without a header.

Adds the 1060x175 banner (RGB webp), matching the existing
project-nomad.webp and crosstalk-solutions.webp, generated from the
pack's source banner.png built on the pack-build workspace.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:52 +00:00
chriscrosstalk
5e7da38e4a
docs: point MeshCore Web to the official meshcore.io site (#1142)
The MeshCore Web app doc listed meshcore.co.uk as the official site. The
canonical MeshCore project (github.com/meshcore-dev/MeshCore, the firmware +
protocol) declares its homepage as meshcore.io, so update the two references
in the MeshCore Web section to point there.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:41 +00:00
jakeaturner
7fc5132e9e
fix(UI): add API reference to Settings sidebar 2026-08-04 03:38:40 +00:00
jakeaturner
678bb17fbd
chore(deps): bump axios and systeminformation in /admin 2026-08-04 03:38:40 +00:00
chriscrosstalk
c3a413a049
docs: recommend Ubuntu 26.04 LTS as the default base (#1141)
Validated Project NOMAD on Ubuntu 26.04 LTS across two builds (an AMD/CPU
box and an NVIDIA RTX 5060 box), so recommend 26.04 as the default going
forward. 24.04 LTS and Debian 12 remain supported.

- bug_report.yml: add Ubuntu 26.04 (Resolute Raccoon) to the OS dropdown
- README / CONTRIBUTING / FAQ: name Ubuntu 26.04 LTS as the recommended version
- getting-started.md: correct the GPU note. NOMAD's installer sets up the
  NVIDIA Container Toolkit and Docker runtime automatically; the user only
  needs the NVIDIA driver (via "Install third-party drivers" at OS setup)
2026-08-04 03:38:39 +00:00
chriscrosstalk
9309e7460d
fix(benchmark): warm the AI model before timed runs for reproducible scores (#1140)
The AI benchmark evicted resident models (forcing the benchmark model cold)
and then went straight into the timed median-of-N loop with no warm-up. On a
cold box the model-load + GPU spin-up cost landed inside the timed runs
(observed: 173s TTFT / 5.83 tok/s vs a ~80 tok/s warm steady-state), and
consecutive runs weren't isolated (a prior run left the model warm). Because
the AI channel is uncapped and ~30% of the composite, the same machine could
post a ~2x-different NOMAD Score depending on warm/cold state (888 vs 1958
observed back-to-back).

Add one discarded warm-up inference after eviction and before the timed loop
so every timed run measures warm, steady-state throughput. Cold and warm
invocations now converge on the same score. Best-effort: a warm-up hiccup
never fails the run.

Closes #1139
2026-08-04 03:38:39 +00:00
chriscrosstalk
8c87b25343
fix(benchmark): surface a clear reason when leaderboard submission fails (#1138)
The submit flow discarded the real failure reason and always returned the
generic "Failed to submit benchmark results." to the UI. The most common
cause is the leaderboard's one-per-hour rate limit (HTTP 429), which left
users with no idea why their submission failed or that retrying shortly
would also fail.

- benchmark_controller: return a clear, actionable message. Name the rate
  limit explicitly on 429; otherwise pass through the underlying detail
  (repository error or a service validation message like "already
  submitted"), falling back to the generic only when we have nothing.
- benchmark_service: attach the raw upstream `detail` to the thrown error so
  the controller can surface it.

The frontend already renders the server `error` string, so no client change
is needed.
2026-08-04 03:38:38 +00:00
NgoQuocViet2001
223ead7c4e
fix(benchmark): remove stale progress setter (#1136) 2026-08-04 03:38:38 +00:00
chriscrosstalk
69080b3a05
feat(drug-reference): tabbed redesign with grouped search, multi-select situations, and a disclaimer gate (#1137)
* feat(drug-reference): compact header + single dashboard tile

Phase 1 of the drug-reference redesign:
- AppLayout gains an opt-in `compact` prop (small inline logo+title) so tool
  pages reclaim the ~230px the full branding block costs; drug-reference/index
  opts in.
- Consolidate the two dashboard tiles (Drug Reference + When to use what) into a
  single Drug Reference tile with a broadened description (/conditions already
  redirects to /drug-reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): tabbed redesign — search-by-drug + by-situation + FDA data

- Split /drug-reference into three tabs (Headless UI TabGroup): 'Search by
  drug', 'By situation', 'FDA data'. Each tab runs only its own direction,
  which removes the two-overlapping-sections confusion.
- Search by drug: results grouped by active ingredient (IngredientGroup,
  single-ingredient groups first, combos after), drug-first result rows, and a
  collapsible de-jargoned filter drawer (Over-the-counter / Prescription, Form,
  Sort) that auto-collapses once results land.
- By situation: multi-select symptom chips → an 'Treats all N selected'
  intersection section pinned on top (computed client-side from each
  situation's result set) + one union group per situation.
- FDA data: the download/ingest control + status moved behind its own tab
  (the pre-ingest empty state stays the prominent download prompt).
- DrugResultRow now leads with the active ingredient (drug-first) by default,
  or the brand when rendered inside an ingredient group.
- Rename 'Compare interactions' → 'Compare label warnings' to match what it does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): rank situation matches single-ingredient-first

The raw FDA indication match floods a situation (e.g. Headache) with
many-ingredient homeopathic products, burying real OTC drugs and leaving the
cross-situation intersection empty. Pull a wider result set (200) and sort by
active-ingredient count ascending — not a medical judgement, the same
'single-ingredient first' principle as the drug-search grouping. Now real
drugs (acetaminophen, ibuprofen) surface on top and the 'Treats all N selected'
intersection actually finds the shared OTC options. Per-situation cards cap the
display to the top 25 (ranked), intersection uses the full set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): first-open disclaimer gate + first-group-expanded

- Add a required disclaimer modal on first open of the Drug Reference (Jake's
  mechanism): comprehensive not-medical-advice notice the user must acknowledge;
  acceptance is saved to the browser's localStorage (versioned key) so it isn't
  shown again on that browser, while new browsers/devices see it on first open.
  Non-dismissible (no backdrop/Escape) — only the acknowledge button closes it.
- Search-by-drug: expand the first ingredient group by default, collapse all
  subsequent groups (IngredientGroup gains an explicit defaultOpen prop,
  replacing the size heuristic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): intersection-first multi-select

Multiple situations now lead with the 'Treats all N selected' intersection and
only break out per-situation sections when the intersection is empty (nothing
treats all) — with a 'No single option treats all N of these' explainer. Keeps
the view combined when there's a shared answer, and only fragments as a fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): compact header on detail/interactions/conditions pages

Apply the compact AppLayout header to the drug detail, interactions, and
condition pages so they match the redesigned index instead of the full-height
branding block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): rename interactions heading to 'Compare label warnings'

Match the page heading + title to the button label, so the name reflects what
the view does (each drug's own FDA-labeled warnings side by side, not a
cross-drug interaction checker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:37 +00:00
jakeaturner
f6c626650f
docs: update release notes 2026-08-04 03:38:37 +00:00
Experimentos em Série
48b0dfc8a0
fix(rag): stop re-creating payload indexes on every embedded document (#1135)
_ensureCollection() runs once per document on the embed path, but only
createCollection sat behind the collectionExists guard — the
getCollections probe and the three createPayloadIndex calls fired
unconditionally every time. On large ZIM ingestions those redundant
requests consumed roughly 45% of per-document Qdrant time, making jobs
look stalled while they were slowly progressing.

Memoize ensured collections in a per-instance Set, recorded only after
every step succeeds so partial failures retry. The cache is cleared
when the Qdrant health check resets the client (server may have been
recreated), and the entry is dropped before resetAndRebuild()
recreates the collection it just deleted.

Memoizing instead of moving the index calls inside the guard keeps
missing indexes healing on collections that predate the current
payload schema.

Closes #1129

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 03:38:36 +00:00
Jake Turner
90489e06da
fix(amd): coerce gfx1103 (780M) to HSA_OVERRIDE 11.0.0 so it stays on GPU (#1134)
PR #1076 stopped forcing HSA_OVERRIDE_GFX_VERSION=11.0.0 on
"natively-supported" AMD iGPUs. That was correct for gfx1150/gfx1151
(Strix 890M / Strix Halo, which are in the bundled rocblas allowlist)
but wrong for gfx1103 (Phoenix/Hawk Point 780M/760M), which is NOT in
that list. Without the override, ollama drops the 780M with "no rocblas
support for gfx target" and falls back to CPU on a fresh AI provision.

Extract the gfx→HSA mapping into a pure, unit-tested util and map
gfx1103 → 11.0.0 (gfx1100 kernels), the value that worked on v1.33.0 and
that restores full GPU offload in the field. gfx1150/1151 stay native.

Also harden the installer's 780M detection (Hawk Point / "Radeon 780M/
760M" strings) so the gfx marker isn't silently deleted, and upgrade the
no-marker fallback log from info to warn since it can mask CPU fallback.
2026-08-04 03:38:36 +00:00
caweis
6c0271cd6f
refactor(drug-reference): make collections JSON the single source for curated data (#1130)
Generate app/data/{conditions,natural_remedies,home_remedies}.ts from the
repo-root collections/*.json via `npm run gen:curated-data`, so the JSON is the
only file edited by hand. The generated modules keep the data compiled into the
image (no runtime file read, no path fragility, which is why the data was a TS
constant), and curated_data_sync.standalone.ts fails CI if a generated module
ever drifts from its JSON.

This removes the burden of hand-keeping the .json mirror and the .ts constant in
sync. Follow-up to the review discussion on #1040.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 03:38:35 +00:00
jakeaturner
a1570023ac
docs: update release notes 2026-08-04 03:38:34 +00:00
Jake Turner
04b5c1dfd9
feat: auto-generating OpenAPI docs with Scalar UI (#1128) 2026-08-04 03:38:33 +00:00
Jake Turner
8a356384d3
feat(AI): nomad.md for custom instructions (#1127)
* feat(AI): nomad.md for custom instructions
* fix(UI): broken HTML tag close
2026-08-04 03:38:33 +00:00
caweis
762a4a12ad
Add offline FDA drug reference (labels, interaction view, conditions, remedies) (#1040)
* feat(drug-reference): offline FDA drug labels, conditions, and remedies

Adds an offline medical-reference feature with three coupled layers:

- Drug Reference: full-text search over openFDA drug-label indications,
  a detail page per label, and a side-by-side single-drug comparison view.
  A two-phase background pipeline downloads the openFDA label parts to the
  storage volume (resumable) and ingests them into the search table.
- Conditions ("When to use what"): a curated spine of first-aid situations
  that maps each situation to matching OTC drugs, each linking back to its
  Drug Reference detail page.
- Curated remedies: hand-authored natural and home-remedy entries drawn from
  US-government public-domain sources (NCCIH, CDC, MedlinePlus, FDA), shown
  with their source links and the same safety disclaimers as the rest of the
  feature.

The drug-reference and conditions layers are intentionally coupled: the drug
detail page shows the situations a drug treats, and the conditions controller
reads the same drug_labels table.

Safety surfaces ship as written. The amber SafetyBanner ("informational only,
not medical advice, not an FDA endorsement, not a drug-interaction checker, in
an emergency call emergency services") renders on the condition pages and the
search page; the detail and comparison pages carry their own "not a cross-drug
interaction checker" callout; and every page carries the openFDA CC0 source
citation and no-FDA-affiliation footer.

Wiring on this branch:
- start/routes.ts: the /drug-reference and /conditions page GETs plus their
  /api/* groups.
- commands/queue/work.ts: the drug-download and drug-ingest queues, both at
  concurrency 1. The two drug queues get a per-queue stall override
  (lockDuration 1_800_000, maxStalledCount 3) because each part is one long
  stream; every other queue keeps the existing 300000 default.
- inertia/pages/home.tsx: Drug Reference and "When to use what" tiles. The
  icon and display_order are a starting point, open to change.
- types/kv_store.ts: the two drugReference.* keys the pipeline reads and writes.
- package.json: yauzl and stream-json (plus their @types), used by the ingest
  job to stream the label JSON out of the downloaded zips.

The app reads the conditions and remedy data from the compiled TS constants in
app/data/; the repo-root collections/*.json files are the browseable mirrors.
The natural-remedies standalone test reads collections/natural_remedies.json to
assert the two stay in sync, so that file is also a test fixture.

The four standalone tests pass (drug_interactions, drug_ingest_status,
conditions, natural_remedies). tsc reports no errors in the feature code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(drug-reference): drop a fork-internal comment reference

* fix(drug-reference): address review — defer FULLTEXT, fallback remedies prop, strip fork refs

- migration: wrap the FULLTEXT ALTER in this.defer so it runs after the
  deferred createTable (was silently swallowed, index never created)
- controller: add remedies:[] to the index() error fallback (required prop)
- strip fork-internal issue/spec references from ported comments
- tsconfig: exclude tests/standalone (node --experimental-strip-types only)
- correct the varchar(768) byte-math comment; extend remedy-spine test

* fix(drug-reference): make the interaction comparison readable at five drugs

The comparison view laid its columns out on an equal-fraction CSS grid
(repeat(N, minmax(0, 1fr))), so each added drug shrank every column; at the
five-drug maximum the FDA interaction text was squeezed into unreadable slivers.

Lay the columns out with flex instead: full-width and stacked on phones, then
fixed-width columns that scroll sideways from the sm: breakpoint up, so they
never shrink below a readable width. Theme the columns with the same palette as
the rest of the page (they were on stock gray), and give the headers a fixed
min-height so columns line up when drug names wrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(collections): route a 'dataset' tier resource to the drug pipeline

Add an optional `type` discriminator to SpecResource ('zim' | 'dataset',
absent == 'zim'), so the tier installer can carry a DB-ingested resource
alongside ZIM files. ZimService.downloadCategoryTier branches on it: a
'dataset' resource dispatches the existing FDA download+ingest pipeline
instead of RunDownloadJob, guarded against duplicate dispatch by the drug
ingest status. Every existing manifest entry has no `type` and keeps the
exact ZIM path.

Widen InstalledResource.resource_type to include 'dataset' and exclude
dataset rows from the ZIM/map catalog-update scan (datasets aren't
filename-versioned; their freshness path is separate). No dataset rows are
written yet: the InstalledResource 'dataset' row on ingest-ready, the
manifest entry, install-gating, and the downloads-aggregator integration
are follow-up commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): rework into an opt-in medicine-standard tier dataset

Reshapes the offline FDA drug reference from a built-in feature into curated
content installed by selecting the Medicine / Standard tier, per maintainer
direction.

- install-state: the ingest writes an installed_resources 'dataset' row on
  ready (version = the openFDA export_date), threaded installer to download to
  ingest; the tier-status math and the home-tile gate read it. Manual ingests
  write no row, so install-state stays tied to the curated path.
- manifest: declare the dataset in the medicine-standard tier (runtime fetches
  the remote manifest, so this also needs to land upstream).
- install-gating: the drug-reference home tiles render only when installed.
- uninstall: DrugReferenceService.uninstall() stops the two drug queues, deletes
  the on-disk parts, truncates drug_labels (schema kept), clears the KV markers,
  and drops the install row. Best-effort, logged, scoped to drug data only.
- downloads: the download phase reports the canonical {percent, downloadedBytes,
  totalBytes} shape as one drug-data card in the Active Downloads aggregator with
  cancel/remove; the heavy ingest stays in the IngestStatus surface with an
  Indexing handoff on the card.
- auto-update: a daily DrugAutoUpdateJob compares the manifest export_date and
  re-downloads when newer, gated on installed + no active job.

typecheck clean; the drug standalone suites pass. Three points are flagged in
code for the maintainer: the InstalledResource 'dataset' approach, the tier
home, and the export_date string format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(downloads): keep the drug download card live across all parts

The Active Downloads card filtered to the deterministic jobId, but the
download's continuations run under auto-generated jobIds (only part 0 uses the
deterministic one). So the card tracked part 0 and then vanished while parts
2..N kept downloading. The queue is concurrency 1, so collapse to whichever
single part is in flight and report the deterministic jobId: one card tracks
aggregate progress through the whole download and cancel/remove still routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): co-locate a persistent safety note with affirmative remedy guidance

Add RemedySafetyNote at the head of every natural-remedy section — the two on
Drug Reference and the one on "When to use what" — so the "informational only,
not medical advice, seek real medical care in an emergency" framing appears with
the guidance itself, not only in the page-top banner. Replaces the terse
per-section caveat with the same amber alert language as SafetyBanner.

Addresses the upstream #1040 review request that the disclaimer be unmistakable
and present wherever affirmative self-care guidance appears, not a one-time banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drug-reference): fold dataset freshness into the content-auto-update path

The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.

Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.

Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): strengthen the remedy note to consult a clinician before combining with meds

Widen the affirmative-remedy safety note from "talk to a clinician before use"
to explicitly cover using a remedy AND combining one with a medication the user
already takes — the interaction case is the higher-risk path for an off-grid
user self-treating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): gate affirmative remedy content behind an off-by-default flag

Add drugReference.remediesEnabled (default off), independent of the tier
install. When off, the server emits no remedy data at any boundary — the
drug-reference page prop, conditions show, and the /api/conditions/drugs
situation search — and the "Natural" filter is hidden, so installing the
medicine-standard tier lights up the verbatim FDA label search and the
condition-to-OTC matching but not the hand-authored self-care and herbal
sections. No user-facing toggle: it is flipped on after a clinician content-pass.

Implements the upstream #1040 split-by-risk request: the regulated label content
ships with the tier; the authored remedy guidance stays gated until sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
2026-08-04 03:38:33 +00:00
jakeaturner
abc5730667
fix(KVStore): missing apps.homebox key 2026-08-04 03:38:32 +00:00
jakeaturner
b005a671c6
fix(Benchmark): various typescript errors 2026-08-04 03:38:32 +00:00
chriscrosstalk
e711134f04
feat(benchmark): lock Score v2 AI reference to 13.2 (measured, was placeholder) (#1097)
Replaces the stale 40.5 placeholder (a llama3.2:1b CPU-era number) with 13.2,
measured on the Reference Build (NOMAD6, 780M) under the v2 AI harness with
llama3.1:8b and iGPU acceleration (OLLAMA_IGPU_ENABLE — the provisioning fix in
PR #1074). This is the AI channel's reference for the uncapped v2 score.

Must ship together with #1074 in v1.34.0: the reference assumes iGPU-accelerated
AMD boxes, so shipping it without the iGPU fix would score AMD installs on CPU
numbers against a GPU bar. The exact value gets a final confirm against the
shipping Ollama config before GA.

Must stay byte-identical to the leaderboard's score_service.ts REFERENCE_SCORES_V2
(the server recomputes the score on submit); the matching leaderboard change is a
sibling PR. Changing the leaderboard reference has no effect on existing rows —
the v1->v2 backfill neutralizes the AI channel (ratio 1 regardless of the
reference), and there are no real v2 submissions yet.

Sibling to the Score v2 app client (#1094); stacked on it (same file/constant).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:31 +00:00
chriscrosstalk
0a2666a2da
feat(benchmark): dashboard re-run banner prompting a Score v2 re-run (#1096)
Shows a dismissible dashboard banner to users who submitted a benchmark under
v1 scoring but don't yet have a Score v2 result, nudging them to re-run so the
community leaderboard gets their v2 number.

- Show logic (benchmark_service.shouldShowRerunBanner): has a result with
  submitted_to_repository=true AND no result with nomad_score_v2 AND the
  dismiss KV isn't set. Self-clears two ways — dismiss sets the KV, and any v2
  run gives a result a nomad_score_v2 so the condition flips off on its own.
- New GET /api/benchmark/rerun-banner + useBenchmarkRerunBanner hook (mirrors
  useUpdateAvailable), rendered as a dismissible Alert on the dashboard with a
  "Re-run benchmark" CTA to /settings/benchmark.
- New KV key benchmark.rerunBannerDismissed (boolean) in both KV_STORE_SCHEMA
  and the SETTINGS_KEYS whitelist (the PATCH /system/settings validator enum);
  dismiss writes it via the existing updateSetting endpoint + invalidates the
  query.

Sibling to the Score v2 app client (#1094); stacked on it for the
nomad_score_v2 column. Browser-verified on the NOMAD3 dev env: show/dismiss/
reload-persist/self-clear-on-v2 all correct.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:31 +00:00
Chris Sherwood
363a99818c
feat(benchmark): Score v2 app client — raws, uncapped score, v2 payload + UI
Phase 4 of the NOMAD Score v2 effort (stacked on #1089). Captures the raw
channel values the leaderboard scores from, computes the uncapped v2 score
in-app (byte-matching the leaderboard's server-side recompute), submits the
v2 payload, and surfaces it in the benchmark UI.

- Capture raws: per-channel sysbench values + O_DIRECT disk (W4), single/multi
  thread CPU + memory, W6 consistency companions, and run-environment metadata
  (run_environment, storage_path_type, gpu_compute_detected, #1016).
- AI hardening: reference model llama3.1:8b, num_predict=256, VRAM eviction
  before the run + unload own model after, pre-flight disk check (~6.5GB, only
  when the model is uncached), median-of-3 run (W7).
- Score: _calculateNomadScoreV2 (uncapped, reference->1000) byte-matches the
  leaderboard recompute. nomad_score_v2 stays null unless a full run with AI.
- Payload: submitToRepository sends the v2 raws + score alongside legacy v1.
- UI: v2 headline score, legacy v1 as secondary, raws/env in details.
- Migration: 13 nullable columns; double (not knex float(8,2)) so millions-scale
  raws keep full precision and don't break the leaderboard byte-match.

Reference constant REFERENCE_SCORES_V2.ai_tokens_per_second is still the stale
40.5 placeholder (TODO in source); locking it to the measured 13.2 is a
follow-up gated on the AMD iGPU fix (#1074). Live prod v2 submit is separately
blocked by the leaderboard TTFT floor (tracked in the leaderboard follow-up).

Verified on NOMAD3 (RTX 5060) dev env: migration applies, raws recorded,
v2 score computed, backend + benchmark.tsx typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:30 +00:00
NgoQuocViet2001
42e1d584ef
fix(content): resolve current ZIM URL before download (#1091)
* fix(content): resolve current ZIM URL before download
* fix: compare ZIM catalog versions numerically
2026-08-04 03:38:30 +00:00
Roberto
dd2b5ce3e7
fix(maps): warn when world basemap missing instead of silent grey map (#1104)
An install that never went online while visiting /maps never provisions
world.pmtiles (it is lazily extracted from the internet), leaving the map
blank/grey offline outside any downloaded regional extracts.

Detect the world basemap's presence on disk and surface it to the UI so:
- /maps shows a clear notice explaining the base map isn't downloaded and
  how to fix it, instead of a silent grey screen.
- /settings/maps offers an explicit "Download Base Map" action (~15 MB) to
  provision it deliberately while online.

Adds MapService.checkWorldBasemapExists() / provisionWorldBasemap(), a
POST /maps/setup-world-basemap endpoint, and threads worldBasemapExists
through the maps page props.

Refs #1030

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 03:38:29 +00:00
chriscrosstalk
8068e05770
feat(AI): per-model thinking toggle with global default (off) (#1079)
* feat(AI): per-model thinking toggle with global default (off)

Stop forcing thinking on for every capable model. Adds a global default
(ai.autoThinking, ships OFF) and a per-model override in the chat window,
shown only for thinking-capable models and remembered client-side.

The /v1 (OpenAI-compat) endpoint ignores `think`; reasoning_effort is the
real lever. The controller resolves per-request preference -> global default
-> OFF (gated on capability), and the service maps that to reasoning_effort
('none' to suppress on a capable model, 'medium' for gpt-oss, unset to let a
capable model default thinking on). A new thinkingCapable flag keeps
non-Ollama backends from ever receiving reasoning_effort.

installed-models is enriched with a `thinking` flag (checkModelHasThinking,
now memoized) so the picker knows which models get the toggle.

Stacks on #1078 (reasoning-field read + client-disconnect abort).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(AI): thinking toggle as NOMAD Switch + info tooltip

Address review feedback on the per-model thinking control:
- Use the shared Switch component (matches AI Assistant settings) instead
  of a raw checkbox.
- Label "Thinking:" with a colon to match the adjacent "Model:" label.
- Add an InfoTooltip explaining what thinking does and where the default
  lives. Extend InfoTooltip with optional `position` ('top'|'bottom') and
  `align` ('center'|'right') so it opens downward and expands leftward from
  the right-edge header slot instead of being clipped/crushed against the
  viewport edge. Defaults preserve existing (benchmark page) behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:29 +00:00
chriscrosstalk
12b0a77873
feat(creator-packs): gated per-creator video packs, offline via Kiwix (#1106)
* feat(creator-packs): backend rail for gated creator content packs

Phase 1 of Creator Packs: the app-side download rail for per-creator
video ZIMs served from the private R2 bucket behind the entitlement
Worker. No UI yet (Phase 2).

- Thread optional requestHeaders through the download util + job so the
  ZIM fetch can carry `Authorization: Bearer <APP_KEY>` on both the HEAD
  probe and the GET stream (Worker streams the ZIM directly, gated).
- Add resourceMetadata.skip_embedding and gate the RAG/EmbedFileJob
  branch on it so video ZIMs are never sent to the knowledge base.
- New creator_packs ManifestType + creatorPacksSpecSchema (display
  metadata only, no url) + CollectionManifestService wiring and
  getCreatorPacksWithStatus() status join.
- collections/creator-packs.json catalog with the two seed packs.
- CreatorPackService.installPack: ensure Kiwix (auto-install if absent),
  resolve the stable Worker URL, dispatch the gated download with
  skip_embedding. Typed result codes.
- CreatorPacksController + GET/POST /api/creator-packs routes.
- CREATOR_PACKS_APP_KEY / CREATOR_PACKS_WORKER_BASE env (release-injected
  key; app degrades to a clear not_configured error when unset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(creator-packs): Phase 2 UI — Content Explorer, settings page, wizard step

Adds the three user-facing surfaces for Creator Packs on top of the Phase 1
backend rail. All surfaces HIDE entirely on builds without the release-injected
key (configured=false) so a fork never shows a broken install button.

- Backend: CreatorPackService.isConfigured() + `configured` flag on the
  GET /api/creator-packs response; /settings/creator-packs route + controller
  action.
- api client + useCreatorPacks hook (shared cached source of configured/packs/
  downloads for every surface) + CreatorPackCard (status badges, art fallback,
  update pill) + CreatorPacksSection (install-on-click grid + confirm modal with
  the license note), reused by the Content Explorer block and settings page.
- Content Explorer: Creator Packs section before Additional Content.
- Settings: new /settings/creator-packs manage page + conditional nav item.
- Easy Setup wizard: new Creator Packs step after Content. Replaces the fragile
  hardcoded AI-skip math with a computed activeSteps list so the two optional
  steps (Creator Packs + AI) navigate correctly in every on/off combination;
  selection state, storage projection, review summary, and finish-install wired.

Browser-tested on NOMAD3 across all surfaces (configured + fork paths), incl.
live install→download→cancel and AI-on/off wizard renumbering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(creator-packs): use branded pack banners on the cards

Replace the generic colored-block cards with the branded 1060x175 banners we
build into each pack ZIM, shown on both the settings page and the Easy Setup
wizard step (and the Content Explorer block, via the shared card).

- Bundle the seed-pack banners as local webp under admin/public/creator-packs/
  so they render offline (no external image dependency, matching the existing
  /rogue-support-banner.webp convention).
- CreatorPackCard is now banner-forward: the banner is the hero, with a compact
  status/metadata footer (videos · size, Installed/Downloading/Selected/Install,
  update pill). Falls back to a simple header if an image is ever missing.
- Prefer an optional catalog `banner_url` (future remote creators) over the
  bundled-by-id path; add banner_url to the CreatorPack type + validator.
- Widen the card grids to 1–2 columns since banners are wide.

Browser-tested on NOMAD3: banners render on the settings page and wizard step;
selection/installed/downloading states and storage projection still correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(creator-packs): Phase 3 — uninstall, license, release key-injection

Completes the Creator Packs feature.

- Uninstall: CreatorPackService.uninstallPack reuses ZimService.delete (removes
  the ZIM, drops it from the Kiwix library, clears the InstalledResource) behind
  DELETE /api/creator-packs/:id. A trash control on installed cards appears only
  on the settings "manage" surface (allowUninstall) — never on the Content
  Explorer block or the wizard — with a danger confirm modal.
- License: draft "Project NOMAD Creator Pack License v1.0" at
  collections/creator-pack-license.md (marked DRAFT — pending legal review;
  personal-use, no-redistribution, official-channel-only, creators retain
  copyright). The install modal links to it ("View license").
- Release key-injection: Dockerfile ARG/ENV CREATOR_PACKS_APP_KEY in the runtime
  stage (empty default → source/CI builds ship unconfigured and hide the UI) +
  build-primary-image.yml passes it from the CREATOR_PACKS_APP_KEY CI secret.
  NOTE FOR JAKE: add that repo secret = the entitlement Worker's key.
- Update flow was already functional (installed-with-update cards → "Update
  pack"); verified.

Browser-tested on NOMAD3: uninstall round-trip (file + DB row + kiwix library
cleaned, then reinstalled), settings-only uninstall control, install-modal
license link, and the "Update available" pill via a temporary catalog bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(downloads): clean up header merge logic

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-04 03:38:27 +00:00
Andrew Barnes
e175c5cb4a
fix(chat): make conversation layout responsive (#1090) 2026-08-04 03:37:42 +00:00
just-jbc
4a12049931
fix(rag): add proper .docx text extraction via mammoth (#1100)
* fix(rag): add proper .docx text extraction via mammoth

.docx files were classified as plain text and routed through raw-text
extraction (extractTXTText). Since .docx is a ZIP archive containing XML,
this produced garbage content in the Knowledge Base — XML tags and binary
noise instead of the actual document text.

Adds a dedicated 'docx' file type (split out of the generic 'text' bucket
in determineFileType) and a mammoth-based extractor that parses the
document XML properly.

* fix: clean up package-lock.json diff

* chore(deps): pin mammoth version

---------

Co-authored-by: John Cortright <jcortright@zscaler.com>
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-04 03:37:42 +00:00
just-jbc
ac141a33b4
feat(rag): add subject/collection organization to knowledge base (#1063)
* feat(rag): add subject/collection organization to knowledge base

- Add nullable collection field to KbIngestState, propagated through the
  embed job, RAG service, and Qdrant point payloads (indexed for filtering)
- Add upload-time category selection and per-file collection reassignment
  in the Knowledge Base modal, with a filterable Stored Files table
- Add a 'Search in' collection filter to the chat interface, threaded
  through to searchSimilarDocuments as an optional Qdrant filter
- Fix .docx extraction: previously routed through raw-text extraction
  (garbage output for a ZIP-based XML format); adds a proper mammoth-based
  extractor and a dedicated 'docx' file-type case

* feat(rag): support dynamic KB collection creation, rename, and removal

Extends collection organization with a Manage Collections UI: collections
are created on the fly when a file is assigned to a new name, can be
renamed (bulk-updates every tagged file and Qdrant point), and can be
removed (reassigns tagged files back to Uncategorized rather than
deleting anything).

* fix(rag): use dynamic collections query in chat search filter

chat/index.tsx still imported the static KB_COLLECTIONS constant for its
'Search in' dropdown, inconsistent with KnowledgeBaseModal.tsx which already
uses the live getKnowledgeCollections() query. Renamed/added collections
via the new Manage Collections UI weren't reflected in the chat filter.

* feat(rag): broaden preset tags and add creatable collection combobox

Replaces the survival-specific preset list with general-purpose starter
tags (recipes, diy, health, technology, finance, travel, hobbies,
reference, survival, energy) so the Knowledge Base reads well for
home-lab/reference use, not just prepping.

Adds sanitizeCollectionName() (trim, lowercase, length cap) applied on
every write path server-side, and a dependency-free CollectionCombobox
component replacing the plain <select> + window.prompt pattern for
tagging — autocompletes against presets + tags already in use, with a
'+ Create' option for anything new.

* chore(rag): remove .docx fix from this branch, split into #1100

Per review feedback, the .docx extraction fix is unrelated to the
collections feature and can merge independently. Moved to a standalone
PR (Crosstalk-Solutions/project-nomad#1100) off dev.

* chore: remove unrelated diff noise (lockfile, comments, indentation)

---------

Co-authored-by: John Cortright <jcortright@zscaler.com>
2026-08-04 03:37:42 +00:00
chriscrosstalk
3f450a41ae
feat(benchmark): harness hardening — fail loudly + pin sysbench + record provenance (#1089)
Score v2 Phase 1. Safe under v1 — no scoring, weight, reference, or
submission-payload changes; only the benchmark's failure behavior and
forensic metadata.

- Fail-on-parse-miss (W3): the four SCORED sysbench metrics (CPU
  events/sec, memory ops/sec, disk read/write MiB/s) now THROW when the
  regex misses or the value is <= 0, instead of silently returning 0.
  A parse failure (e.g. an upstream image output-format change) now
  fails the run with a clear error via the existing _runBenchmark
  try/catch, rather than submitting a phantom zero sub-score. Secondary/
  informational fields keep their existing defaults.
- Pin sysbench by digest (W3): severalnines/sysbench@sha256:64cd003b...
  (was :latest), so a latest-tag format change can't break the parsers
  fleet-wide. Digest validated on the NOMAD6 reference build.
- Record provenance (W7): sysbench_digest + ollama_version (from Ollama
  /api/version, null-tolerant) stored on each result. New nullable
  columns + additive migration.

Verified: pinned digest pulls/runs/parses on NOMAD3; migration applies
(columns present); typecheck clean.

Part of the NOMAD Score v2 effort.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:41 +00:00
chriscrosstalk
03db9da24a
fix(benchmark): partial runs are not the NOMAD Score (relabel + renormalize) (#1088)
A System-Only or AI-Only run is a partial result, not the NOMAD Score
(which is the full-benchmark composite). Two problems addressed:

1. Scoring bug: AI-only runs were NOT renormalized. _calculateNomadScore
   always added the system weights (0.60) to the denominator even for an
   AI-only run (default-zero system scores), so an excellent AI-only run
   scored ~39.8 -- scaled against the full NOMAD 100 where AI is only 40%
   -- while system-only already renormalized correctly. Fix: pass
   systemScores only when the system benchmarks actually ran, so AI-only
   renormalizes to its own 0-100 (39.8 -> ~99.7). Full and System-only
   scores are unchanged.

2. Presentation: partial runs were shown with the full "NOMAD Score"
   label + big gauge, outweighing the small "Partial" notice. Now partial
   runs are relabelled "System Score" / "AI Score" with a PARTIAL badge,
   a muted (neutral) gauge + number, and a "run a Full Benchmark for your
   NOMAD Score" CTA -- applied to both the persistent score section and
   the Phase 3 ScoreReveal via a shared getScoreDisplay() helper. Adds a
   `muted` prop to CircularGauge.

Browser-validated on NOMAD3: AI-only now shows "AI Score" + PARTIAL,
muted gauge, 99.7 (was 39.8); Full still shows "NOMAD Score" green.
Implements the display-layer fix from the Score v2 red-team's W2; the
AI-ceiling saturation (W1) remains v2 work.

Part of #1082.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:41 +00:00
chriscrosstalk
c5cfcbf238
feat(benchmark): end-of-run score reveal + NVIDIA GPU-util overlay (#1087)
Phase 3 of the live benchmark run experience.

- ScoreReveal: replaces the abrupt run-view unmount with a deliberate
  end-of-run "REPORT" card -- animated NOMAD score gauge + odometer
  count-up number, sub-score gauges cascading in, Continue button +
  5s auto-dismiss. Takes score scale as a prop so it survives Score v2.
- GPU-util overlay (NVIDIA): during the AI stage, a ~1Hz nvidia-smi poll
  inside the Ollama container feeds live GPU utilization + VRAM into the
  telemetry frames; shown in the AI hero, hidden when absent (AMD/none).
  Poller is side-effect-only and cleared in a finally -- scored numbers
  unchanged.
- Disk polish: reset in-test buffers on stage transition so the write
  stage no longer briefly shows the carried disk-read value.

Browser-validated on NOMAD3 (RTX 5060): GPU overlay live (1% util,
0.2/8.0 GB VRAM during model load); reveal cascade + count-up; AI-only
score 39.8 (scored path unchanged).

Part of #1082 (tracker stays open).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:40 +00:00
chriscrosstalk
f211a72c62
feat(benchmark): authoritative in-test sysbench numbers + results strip (#1085)
Phase 2 of the live benchmark run experience. Streams sysbench
--report-interval=1 interim lines over a Docker attach so the run view
shows authoritative in-test CPU events/sec and disk read/write MiB/s
(overlaying the Phase 1 host-proxy disk numbers), and fills a
"results so far" strip as each stage completes.

- _runSysbenchCommandStreaming: attaches to the container output for
  live onLine callbacks, but returns the authoritative output via
  container.logs() after exit -- byte-identical to _runSysbenchCommand,
  so the SCORED numbers are unchanged (attach 'data' can flush after
  container.wait() resolves, which would truncate the final report).
- CPU + disk stages stream interim eps: / reads:/writes: MiB/s into
  setStageMetric; memory stays non-streaming (too fast to sample).
- _emitPartialResult broadcasts each finished stage's raw result on the
  progress channel; useBenchmarkRun accumulates them; ResultsSoFar
  renders the chip strip.
- Frontend: live CPU ev/s readout+sparkline, disk hero switches to
  "Benchmark throughput" when in-test numbers arrive.

Browser-validated on NOMAD3 (System-Only): CPU 6536 ev/s live, disk
10399 MB/s benchmark throughput, results strip, final score 68.3.

Part of #1082 (tracker stays open for Phase 3).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:40 +00:00
chriscrosstalk
8d6b2045ef
feat(benchmark): live telemetry during benchmark runs (#1082) (#1084)
Phase 1 of the live benchmark-run experience. Replaces the opaque (and
in sync mode, simulated) progress bar with a real-time run view driven by
actual host telemetry.

- Async run path: the UI now dispatches to the queue worker and keys off
  SSE instead of faking stage progress with client-side timers.
- New BenchmarkTelemetrySampler broadcasts per-core CPU load, CPU temp
  (best-effort, hidden when unavailable), and disk MB/s at 1 Hz over a new
  benchmark-telemetry SSE channel. Runs in the orchestration process, never
  the sysbench container, so it cannot affect scores.
- BenchmarkProgress carries the ordered stage plan + index so the frontend
  renders a live stage rail.
- AI benchmark streams /api/generate for live tokens/sec and true TTFT; the
  scored numbers still come from Ollama's authoritative final eval fields.
- Frontend: useBenchmarkRun hook owns both subscriptions; self-contained SVG
  components (StageRail, CoreGrid, Sparkline, LiveReadout) + BenchmarkRunView,
  styled in the desert palette. No chart library added.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:39 +00:00
jakeaturner
ee1b6a594e
chore(deps): bump tar, vite, and dockerode in admin 2026-08-04 03:37:39 +00:00
chriscrosstalk
063c784889
fix(content): refresh installed ZIMs when a download completes to prune ghost entries (#1099)
The Content Explorer remote list already filters out installed ZIMs client-side
(flatData excludes localNames), but the installed-files query has
refetchOnWindowFocus disabled and was never invalidated on download completion.
So when a ZIM finished downloading it dropped off the active-downloads list
(isDownloading -> false) while localFiles stayed stale (isPresent -> false),
letting the just-installed ZIM reappear as a ghost entry until the page remounted.

Add an effect that invalidates the ['zim-files'] query whenever a job drops off
the polled downloads list, so the completed install is picked up and pruned.

Fix originally diagnosed by @johno10661 in #771; reimplemented focused onto
current dev (the accumulated-page/filter groundwork already landed via #731).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:38 +00:00
chriscrosstalk
3096c1e81a
fix(downloads): send a descriptive User-Agent so Wikimedia mirrors don't 403 (#1114)
download.kiwix.org routes the large Wikimedia-family ZIMs (Wikipedia,
Wikiversity, Wikibooks — including the flagship full Wikipedia) to
dumps.wikimedia.org, which enforces a User-Agent policy and returns HTTP
403 for requests with a missing or generic (axios/x) User-Agent. Because
doResumableDownload sent no User-Agent, every Wikimedia-hosted ZIM failed
to download while Kiwix-mirror-hosted ZIMs succeeded — so a curated set or
Easy Setup run would silently stall on exactly the highest-value content.

Add a descriptive User-Agent to the HEAD and GET requests. Verified
against the live mirror: default/empty UA -> 403, ProjectNOMAD UA -> 200/206.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:38 +00:00
chriscrosstalk
f679182218
feat(dashboard): add dismissable "What's new" banner for v1.34 (#1112)
Surface post-upgrade release highlights on the Command Center: a
dismissable banner keyed to the running build's release line (major.minor).
It appears when a user upgrades into the line and every patch within it,
then disappears on the next minor. Dismissal is remembered per-line in
localStorage, so dismissing v1.34 won't suppress a future v1.35 note.

Reuses the existing dismissable Alert component and the dashboard's top
banner slot; reads the running version from the appVersion shared prop.
No schema or migration needed — to surface a new release's highlights,
bump WHATS_NEW.version and replace its highlights.

Initial content: Creator Packs and the offline Medication Reference.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:37 +00:00
chriscrosstalk
23b5125404
fix(easy-setup): streamline wizard + robust model recommendations (#1110)
Step 1: remove the "Additional Tools" (Notes/Data Tools) section from
onboarding and point users to Supply Depot, the browsable app catalog,
for everything beyond the three core capabilities.

Step 2: add a note that individual countries and a full global map can
be installed any time from the Maps Manager.

Step 4: default the KB auto-index policy to "Ask me first" (Manual)
instead of "Yes, always" — auto-indexing has cost/resource implications
a non-technical user won't anticipate from the toggle alone.

ollama_service: the recommended-models fallback only fired on a null
result, so a successful-but-empty upstream response (models: []) showed
"No recommended AI models available" and poisoned the 24h cache. Now the
fallback fires on empty too, empty results are never cached, empty caches
are ignored, and the upstream request has a 10s timeout.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:37 +00:00
chriscrosstalk
b620adbf66
feat(debug-info): add storage, docker, GPU health, and auto-update diagnostics (#1102)
The Debug Info bundle carried no storage-path info, which stalled diagnosis of
relocation issues like #1050 (moved data not seen by the admin). Add the fields
support actually asks for, all best-effort so one failure never blanks the bundle:

- Storage: resolved host storage root (#938), container path, whether
  NOMAD_STORAGE_PATH is set, and the Kiwix library book count (0 books is the
  tell for an empty/wrong-path library).
- Docker Engine version (reporters currently paste it by hand; needed for
  container/updater issues).
- GPU passthrough health (gpuHealth.status + detected gpu.type) for the
  passthrough-lost-after-update class (#755/#878).
- Auto-update status for core/apps/content plus any auto-disabled reason,
  since the auto-update trilogy shipped.

Adds a public DockerService.getHostStorageRoot() wrapper over the existing
#938 resolver.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:36 +00:00
chriscrosstalk
5a6735eaf1
fix(install): define missing header_red + colors in uninstall/update scripts (#1098)
The uninstall and update scripts called header_red() and referenced color
variables (GREEN/RED/RESET) that were never defined, so running them printed
"header_red: command not found" instead of the section banners. Also corrects
the update script's success message (it said "installation completed" and
pointed at an undefined ${nomad_dir} path) and fixes two spelling typos.

- uninstall_nomad.sh: add Color Codes block + header_red()
- update_nomad.sh: add header_red(); fix success wording and start_nomad.sh path
- CONTRIBUTING.md, admin/constants/ollama.ts: spelling fixes

Reported by @fix2015 in #1058; reimplemented in-house.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:35 +00:00
chriscrosstalk
c4c2bdc02a
fix(AI): stream thinking from /v1 reasoning field + abort on client disconnect (#1078)
Two defects made chat hang forever with thinking-capable models on the OpenAI-compat
(/v1) path, which NOMAD uses for both local and remote Ollama:

1. Field mismatch. chatStream()/chat() read `delta.thinking` / `message.thinking`,
   but Ollama's /v1 endpoint emits thinking tokens as `reasoning`. All thinking output
   was silently dropped, so the SSE stream was nothing but empty content+thinking chunks
   and never reached done. Now read `thinking ?? reasoning` in both paths (the inline
   <think>-tag parser for other backends is unchanged).

2. No abort on client disconnect. When the user gave up and closed the chat, the
   upstream generation kept decoding server-side. With Ollama's default
   OLLAMA_NUM_PARALLEL=1 that abandoned request occupied the only slot, so every later
   chat/RAG request queued behind it and the whole assistant appeared dead. The
   controller now wires an AbortController to the response 'close' event and threads the
   signal into the OpenAI SDK request, so a disconnect aborts the upstream generation.

Verified on NOMAD2 (qwen3:0.6b, which reports the `thinking` capability and emits
`reasoning` on /v1): before, the stream was endless empty chunks; after, thinking streams
visibly and reaches done. On disconnect, Ollama's decode counter freezes and the server
logs `cancel task` / `slot release`, freeing the slot for the next request.

Note: thinking is still force-on for capable models here; a user-facing per-model
thinking toggle (default off) is a planned follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:34 +00:00
chriscrosstalk
5e58b596e7
fix(supply-depot): generate Homebox API key pepper so it stops crash-looping (#1077)
Homebox >= 0.26 (ghcr.io/sysadminsmedia/homebox:0.26.2) panics at boot unless
HBOX_AUTH_API_KEY_PEPPER is set to a >= 32-byte value. The seeder ships no Env
block, so every clean install crash-loops immediately.

Generate a per-install pepper, persist it in the KV store
(apps.homebox.apiKeyPepper), and inject it as HBOX_AUTH_API_KEY_PEPPER at every
container-create path. The pepper is generated once and reused: rotating it would
invalidate every API key a user has issued from Homebox.

Inject at all three create paths so no lifecycle action drops it:
- _createContainer (install / force-reinstall)
- the service update path (heals a pre-fix container on update)
- recreateCustomAppContainer (the Edit / reconfigure rebuild — the in-app docs
  tell users to add HBOX_OPTIONS_ALLOW_REGISTRATION=false via Manage > Edit,
  which rebuilds Env from container_config alone and would otherwise drop the
  pepper and re-trigger the crash loop)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:34 +00:00
chriscrosstalk
535f2e6778
fix(AI): stop forcing HSA_OVERRIDE=11.0.0 on natively-supported AMD iGPUs (#1076)
The current ollama:rocm image bundles ROCm 7.2, which natively supports the
RDNA 3/3.5 mobile parts (780M gfx1103, 890M gfx1150, Strix Halo gfx1151).
Forcing HSA_OVERRIDE_GFX_VERSION=11.0.0 coerces them onto gfx1100's kernels:
unnecessary on the 890M and a source of faults on the 780M, which lacks the
gfx1100 WMMA instructions those kernels use.

- Map gfx1103/gfx1150/gfx1151 to no override (was 11.0.0) so ROCm discovers
  them natively.
- Change the no-marker default from 11.0.0 to null. A hardcoded default gets
  more wrong as ROCm adds native targets; native discovery is the safer
  forward-looking default. Hardware that still needs coercion (e.g. RDNA 2
  iGPUs on an install without the gfx marker) can force a value via the
  ai.amdHsaOverride KV.
- Keep gfx1031..gfx1036 (RDNA 2 iGPUs like the 680M) on 10.3.0 — still not
  natively supported.

Verified on NOMAD2 (Radeon 890M): after a Force Reinstall the container has
no HSA_OVERRIDE, Ollama discovers the GPU as its native gfx1150 with the
rocm_v7_2 libraries, and a model loads at 100% GPU with no faults.

Depends on #1056 context; see companion issue for the 780M follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:33 +00:00
chriscrosstalk
dfa68fac6d
fix(AI): set OLLAMA_IGPU_ENABLE on AMD provisioning so iGPUs are used (#1074)
Ollama's scheduler drops integrated GPUs unless OLLAMA_IGPU_ENABLE=1 is set.
NOMAD sets HSA_OVERRIDE_GFX_VERSION for AMD but never this flag, so AMD APUs
(780M/890M/8060S) silently fell back to CPU-only inference despite correct
/dev/kfd and /dev/dri passthrough.

Set OLLAMA_IGPU_ENABLE=1 whenever AMD acceleration is configured, on both the
install and update provisioning paths. The flag is a no-op on discrete AMD
cards, so it's safe to set unconditionally within the AMD branch. On the update
path we also strip any prior value so containers provisioned before this change
pick up the flag on their next update.

Verified on NOMAD2 (Ryzen AI 9 HX 370 / Radeon 890M): before, Ollama logged
"dropping integrated GPU" and ran on CPU; after, it reports the 890M as an
iGPU ROCm inference device and models load at 100% GPU.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:33 +00:00
Chris Sherwood
15e66e511d
feat(brand): add ™ to Project NOMAD wordmark on prominent surfaces
Wordmark application filed (USPTO serial 99912179). Mark the
persistent Command Center footer wordmark (both the AppLayout footer
and the settings/docs sidebar footer) and the first mention on the
Legal Notices page. Browser-tab titles and body copy left unmarked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:33 +00:00
Chris Sherwood
957e79bbc2
chore: standardize brand name to Project NOMAD, retire backronym
Retire the dotted "N.O.M.A.D." styling everywhere in favor of
"Project NOMAD" (no periods) ahead of the trademark filing, and
remove the "Node for Offline Media, Archives, and Data" backronym
from all copy except a single origin-story line in the About page
(corrected to "Maps" rather than "Media").

Scope is display strings only: docs, admin UI labels/titles, install
script output, Dockerfile labels, package.json description, and issue
templates. Code identifiers, container/service names, env vars, CSS
tokens, URLs, and the project-nomad slug are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:32 +00:00
jarvisxyz
5d85b67132
feat: Expandable rows in Kiwix Library browser (#1060)
* feat: expandable rows in Kiwix Library browser (rebased onto dev)

Click a row in the Content Explorer's 'Browse the Kiwix Library' table
to expand it and reveal the full description (no longer truncated) along
with additional metadata: author, publisher, language, category,
article count, media count, issue date, file size, tags, and file name.

Changes:
- Extend RemoteZimFileEntry type with optional metadata fields
  (language, publisher, category, tags, article_count, media_count, issued)
- Update zim_service.ts listRemote() to map these fields from the raw
  Kiwix API response in the paginated accumulator loop
- Remove @tanstack/react-virtual virtualization from remote-explorer.tsx
  (12 items per page — virtualization not needed and incompatible with
  variable-height expanded rows)
- Use StyledTable's built-in expandable prop with expandedRowRender
  to show full details when a row is clicked
- Preserve upstream's custom libraries source selector and directory browser

* fix(RemoteExplorer): guard against invalid dates being rendered

---------

Co-authored-by: eizus <hello@cdr.xyz>
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-04 03:37:32 +00:00
jarvisxyz
4db0010a0d
fix(downloads): add retry button and resource download link for failed downloads (#1059)
Previously, failed downloads showed only an alert icon and a dismiss (X)
button — the user had no way to retry or reach the resource page without
manually re-adding the download and finding the source URL elsewhere.

This commit adds:
- POST /api/downloads/jobs/:jobId/retry endpoint (controller + service)
- retryDownloadJob() API method on the frontend
- Failed-state UI in ActiveDownloads.tsx now shows:
  - Retry button (re-dispatches the original download job)
  - 'Download page' external link (when the download URL is an HTTP(S) URL)
- Loading state on the retry button while the request is in-flight
- Screenshots documenting before/after UI

Co-authored-by: eizus <hello@cdr.xyz>
2026-08-04 03:37:31 +00:00
chriscrosstalk
8ceb2535c2
chore(catalog): sunset orphaned Meshtastic Daemon card (#1049)
Meshtastic Daemon was pulled from DEFAULT_SERVICES because it can't work
without hands-on setup (radio MAC address, etc.). The seeder never deletes,
so every early-access deployment keeps an orphaned nomad_meshtasticd row and
still shows the broken card.

Migration mirrors the legacy-Kolibri sunset: drop the row where installed=0,
flag is_deprecated where installed=1 (keeps it manageable, hides from catalog).
Runs automatically on each box's next update.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:31 +00:00
chriscrosstalk
ab87e29dd5
chore(KB): filter non-content sections + render tables in ZIM extraction (#1044)
Closes #902.

Two gaps in the structured ZIM extraction path:

1. NON_CONTENT_HEADING_PATTERNS was only used by the structure heuristic to
   count meaningful sections, never at section-emit time. Sections under
   "See also" / "References" / "External links" / etc. were still chunked and
   embedded. They're now flagged when the heading opens and dropped.

2. <table> elements were run through cheerio's `.text()`, concatenating every
   cell with no separators ("AgeDoseAdult500mg") into unsearchable word salad.
   New tableToText() joins cells with " | " and rows with newlines so
   row/column structure survives into the chunk.

Refactor: moved extractStructuredContent out of ZIMExtractionService into a
pure, cheerio-only util (app/utils/zim_html.ts) so it can be unit-tested
without the native @openzim/libzim binding. Service delegates to it; behavior
is otherwise unchanged. Adds tests/unit/zim_html.spec.ts (6 tests).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:30 +00:00
chriscrosstalk
ed1fed8d2a
feat(maps): add notes input to map pin placement popup (#926)
The map_markers backend has accepted a `notes` column since PR #770 and
the popup display path was wired up to render it (commit 6328256), but
the placement UI never got an input. Result: notes are stored,
displayed when present, and impossible to actually enter via the UI.

Add a notes textarea below the name input in the placement popup,
thread the value through `addMarker` and `createMapMarker`, and trim +
null-coalesce on save. Notes display in the marker popup on click is
unchanged and now actually reachable.

- admin/inertia/lib/api.ts: extend createMapMarker request type with
  optional notes
- admin/inertia/hooks/useMapMarkers.ts: addMarker accepts and forwards
  notes (response already populated notes into local state, so no
  display-side change needed)
- admin/inertia/components/maps/MapComponent.tsx: markerNotes state,
  textarea after name input, threaded into handleSaveMarker

Edit-mode for existing markers (so users can backfill notes on
already-placed pins) is intentionally out of scope here - selected-marker
popup is still read-only. That's a follow-up PR if there's demand.
2026-08-04 03:37:30 +00:00
cosmistack-bot
6a4f02dd46 docs(release): finalize v1.33.0 release notes [skip ci] 2026-06-23 17:52:28 +00:00
jakeaturner
587600ba78
Revert "docs(release): finalize v1.33.0 release notes [skip ci]"
This reverts commit c9a86790de.
2026-06-23 17:49:36 +00:00
jakeaturner
f4af81ef02
fix: lazy-connect + retry for ioredis to avoid blocked startup 2026-06-23 17:40:10 +00:00
cosmistack-bot
c9a86790de docs(release): finalize v1.33.0 release notes [skip ci] 2026-06-23 04:49:49 +00:00
jakeaturner
9609edc281
chore(docs): update release notes 2026-06-23 04:47:12 +00:00
Jake Turner
0143afe7cc
fix(supply-depot): bump default Ollama and CyberChef image versions (#1036) 2026-06-23 04:47:12 +00:00
Jake Turner
5af27e9904
fix(supply-depot): ensure all curated images pinned to specific versions (#1033) 2026-06-23 04:47:12 +00:00
Jake Turner
88ac4d5ec4
feat(RAG): adds the ability to cancel all embedding jobs (#1034) 2026-06-23 04:47:11 +00:00
jakeaturner
f0142b67f8
chore(docs): update release notes 2026-06-23 04:47:11 +00:00
jakeaturner
4a795df793
feat: configurable internet test url override in new Advanced Settings page 2026-06-23 04:47:10 +00:00
Jake Turner
02c9f72bf0
fix(UI): unifies Supply Depot icon and improves loading UX (#1022) 2026-06-23 04:47:10 +00:00
Benjamin Smith
5181637926
feat(KnowledgeBase): add document viewer, download, metadata, and sorting (#721)
Rebuilt on top of dev's RFC #883 state-machine UI rather than the now-defunct
StoredFile shape:

- Extend StoredFileInfo with fileName/size/uploadedAt/isUserUpload
- Populate metadata from on-disk stats in RagService.getStoredFiles
- Add fileSourceSchema validator + getFileContent/downloadFile endpoints
  scoped to the uploads directory only (tighter than the original PR — matches
  docs_service traversal pattern)
- KnowledgeBaseModal: sortable Size and Uploaded columns; View/Download
  buttons on upload-bucket rows; new FileViewerModal for in-browser text
  preview. Bucket grouping preserved — sort applies within each bucket.
- Use formatBytes from ~/lib/util rather than redefining
2026-06-23 04:47:09 +00:00
jakeaturner
fad9e30ddc
fix(zim): stabilize uploader state to avoid cancel on tab refocus
Also improves surfacing of upload error messages
2026-06-23 04:47:09 +00:00
Henry Estela
a7d05859d4
feat(zim): add zim uploader in content manager
adds a collapsible file uploader to accept zim file uploads into kiwix.
2026-06-23 04:47:08 +00:00
Lorenzo Galassi
fe6735fdcb
fix(queue): share one ioredis connection across BullMQ queues and workers (#1009)
BullMQ instantiates a fresh ioredis client per Queue/Worker when handed a
plain {host, port} config object, and under sustained ZIM ingestion the
embed pipeline leaked ~1 client/sec until Redis maxclients was exhausted.
Pass a single shared ioredis instance (maxRetriesPerRequest: null, as
required by BullMQ) so all queues and workers reuse one client pool.
Workers still duplicate the connection once for their blocking client,
which is expected and bounded.

Closes #885
2026-06-23 04:47:08 +00:00
Jake Turner
b507b8bd4e
chore(deps): bump mysql2 in admin (#1021) 2026-06-23 04:47:07 +00:00
Jake Turner
8982d93a31
feat: replace legacy Kolibri image default with latest v19 image (#1019)
* feat: replace legacy Kolibri image default with latest v19 image
* feat(supply-depot): add content migration instructions for Edu Platform Gen 1 to 2
2026-06-23 04:47:07 +00:00
jakeaturner
1b040c8b9a
fix: pin default meshcore web image 2026-06-23 04:47:07 +00:00
Chris Sherwood
bd65c885be
feat(supply-depot): add MeshCore Web with self-signed HTTPS
Adds the MeshCore web client to the Supply Depot catalog (host port 8500),
alongside the existing Meshtastic apps. Uses aXistem's prebuilt image of Liam
Cottle's MeshCore client (MeshCore is a sibling LoRa mesh project to Meshtastic).

The image is stock nginx serving a static Flutter build over HTTP, but the
client reaches radios via Web Bluetooth / Web Serial, which browsers only allow
from a secure (HTTPS) context. So we serve it over HTTPS: a new preinstall hook
generates a self-signed cert + a small SSL nginx config into storage/meshcore-web,
both bind-mounted into the container (the config over the image's default.conf),
publishing 443. Same one-time browser-warning approach as Vaultwarden, whose
openssl cert generation is refactored into a shared _ensureSelfSignedCert helper.

Also adds a NOMAD-specific docs section + Manage>Docs anchor, and registers the
IconAntenna icon. Meshtastic Web left unchanged.

Validated on NOMAD3 (v1.33.0-rc.1): the image + SSL config + self-signed cert
serves the MeshCore Flutter app over HTTPS 200 with working SPA fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:06 +00:00
johno10661
83576ec33d
feat(supply-depot): add uninstall for curated apps (#1006)
Curated catalog apps could be installed, stopped, and force-reinstalled,
but never removed — the only path off a device was manual docker + DB
surgery. Custom apps already had delete; this adds the equivalent for
curated apps.

POST /api/system/services/uninstall stops and removes the app's
container (optionally its image, same best-effort semantics as custom
app delete) and flips the record back to not-installed so the card
returns to the available catalog. Host bind-mount data is deliberately
left on disk, so a later reinstall picks the app back up where it left
off — unlike force-reinstall, which clears volumes.

Guards: custom apps are rejected (use delete), dependency services are
rejected, and uninstalling a not-installed app is a 409.

UI: installed curated cards get an Uninstall action in the card menu,
with a confirm modal that explains data is preserved and offers the
same remove-image checkbox as custom app delete.
2026-06-23 04:47:05 +00:00
Chris Sherwood
536ad49277
docs: update in-app docs for v1.33 Supply Depot + auto-updates
Refresh the in-app Markdoc docs for the v1.33 feature set:

- Repoint dead /settings/apps links to the Supply Depot (/supply-depot)
  across home, getting-started, and faq; reword "Apps page" / "Settings
  -> Apps" to "Supply Depot". The old /apps route now redirects to the
  Supply Depot.
- Expand supply-depot-apps.md with a "Managing your apps" section (Docs/
  Edit/Logs/Stats/Update/Remove, version + update-available visibility,
  custom launch URLs, per-app auto-update toggle) and a "Bringing your
  own app" section for custom Docker containers.
- Add a new "Updates" doc (updates.md) covering the auto-update trilogy
  (core/apps/content), manual updates, and the Early Access channel;
  wire it into DOC_ORDER and cross-link from home, getting-started, faq.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:04 +00:00
jakeaturner
5eb208fb50
docs: update release notes 2026-06-23 04:47:03 +00:00
jakeaturner
37ad684fbd
fix(bullmq): bump to 5.77.6 and update set calls w new args shape 2026-06-23 04:47:03 +00:00
jakeaturner
d4972445d9
chore(deps): bump autoprefixer 2026-06-23 04:47:02 +00:00
jakeaturner
b6a08058e8
chore(deps): bump react and react-dom 2026-06-23 04:47:02 +00:00
jakeaturner
6a2d4c2bf6
feat(content): opt-in automatic updates for installed ZIM & map content 2026-06-23 04:47:01 +00:00
akashsalan
9de6473f6a
fix(system): prevent false offline reports when Cloudflare endpoint is unreachable 2026-06-23 04:47:01 +00:00
Chris Sherwood
bbd62d8ed1
fix(content): remove superseded curated map/ZIM files when a new version installs
Only Wikipedia had version cleanup; every other curated map and non-Wikipedia
ZIM left its prior version on disk when a newer one installed, so users silently
accumulated orphaned content (potentially hundreds of GB). (#634)

The install paths already record each resource via InstalledResource
{resource_id, resource_type, version, file_path}, so the authoritative old-file
path for a resource is known. On install of a new version we now capture the
prior row before updateOrCreate repoints it, then delete the old file — gated
behind a pure, fully unit-tested decision function with strict safety rails:

  - tracked-only: requires a prior InstalledResource row for the same
    resource_id, so sideloaded/untracked files are never touched
  - genuine replacement: old and new file paths must differ
  - new-file-verified: the new file must be confirmed on disk first
  - strictly-newer: a re-install or downgrade can't wipe a newer file
  - within-storage-dir: the old path must resolve under the content store

ZIM cleanup deletes the old file directly (NOT via this.delete(), which would
drop the InstalledResource row by resource_id that updateOrCreate just
repointed) and rebuilds the Kiwix library only if a file was actually removed,
so its XML never references a deleted ZIM. Maps need no library step. Wikipedia
keeps its own existing cleanup path. All deletions are best-effort and logged;
a failure never breaks the install.

Closes #634

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:01 +00:00
John Onysko
2ae30a4abb
fix(chat): prefer selected model for suggestions, fall back to smallest
`getChatSuggestions` previously picked the largest installed model by file
size, on the assumption that bigger models give better suggestions. This
is unsafe: if any installed model exceeds available VRAM (e.g.
llama3.1:405b on a 96 GB GPU), Ollama spends minutes trying to load it
and the request 500s — making the chat page unusable for anyone who
happens to keep a flagship-sized model on disk.

Chat suggestions are short prompts that don't benefit from a flagship
model anyway. Prefer the user's selected `chat.lastModel` when set, and
fall back to the smallest installed model otherwise. `OllamaService.getModels()`
already excludes embedders, so the fallback always picks a chat model.
2026-06-23 04:47:00 +00:00
jakeaturner
e5565d1e95
refactor(supply-depot): extract version and subtitle helpers instead of IIFE for readability 2026-06-23 04:47:00 +00:00
Chris Sherwood
4ece29b6d4
feat(supply-depot): show installed version on cards + make Update pill stand out
Two card tweaks for the update workflow:

- Show the installed image tag next to the powered-by name (e.g. "Kiwix ·
  3.7.0"), so the running version is visible at a glance. Only rendered for
  installed apps; falls back to just the version when powered_by is unset.
- Change the "Update available" pill from a muted light-green tint to a solid
  desert-orange fill with white text, so an available update actually draws
  the eye instead of blending into the card.
2026-06-23 04:46:59 +00:00
Chris Sherwood
5de58da4b7
fix(updates): resolve relative registry pagination URL so tag listing doesn't crash (#945)
listTags() follows the registry's Link-header pagination, but the next-page
URL is relative per the OCI/Docker registry spec (e.g.
"/v2/ollama/ollama/tags/list?last=0.9.3-rc5&n=1000"). The code assigned that
raw relative path straight back to `url` and re-fetched it, so fetch() threw
"Failed to parse URL from /v2/...". Any image repo with more than 1000 tags
paginates, so the entire tag list — and therefore the update check — failed
silently for ollama/ollama and filebrowser/filebrowser.

That's the root cause of #945 ("won't update past 0.24.0"): the Ollama
update check never completed, so no newer version was ever offered.

Resolve the next-page URL against the registry origin with
new URL(next, `https://${registry}`), which also passes absolute next-URLs
through unchanged for registries that return those.

Closes #945
2026-06-23 04:46:59 +00:00
Chris Sherwood
da60c6ce9c
feat(maps): persist map view across refresh
The maps page reset to the default US-wide view on every refresh because
initialViewState was hardcoded. Save the position and zoom to localStorage
on each move-end (key nomad:map-view, matching the existing scale-unit
pattern) and restore it at mount: saved view → default. The restore is
bounds-checked so a corrupt value falls through to the default.

Replaces #815, whose branch had drifted far out of scope (56 files of
stale-base/merged-commit noise plus unrelated map-feature WIP). This is
just the persist-view improvement, ported cleanly onto current dev. The
null-island and coordinate-search parts of #815 targeted URL-param code
that never landed on dev, so they don't apply here.
2026-06-23 04:46:58 +00:00
Chris Sherwood
9b84d3aa54
fix(maps): dedupe map sources by region so duplicate files don't blank the map
The map style names each source by its date-stripped region (both
"washington.pmtiles" and "washington_2025-12.pmtiles" -> "washington").
When an old and new copy of the same region are both on disk, the style
emitted two sources with the same key and duplicate layer ids, which
MapLibre rejects outright -- blanking the ENTIRE map, not just that region.

Old copies linger when a newer curated version installs (#634), so a user
who updates maps can silently lose all map rendering until the stale file
is removed by hand.

generateSourcesArray() now keeps only the newest file per region: a dated
build beats an undated legacy file, and between two dated builds the later
YYYY-MM wins. The skipped duplicate is logged. The style stays valid even
when stale files are present.

Complements #981, which removes superseded curated files on install. This
is the runtime safety net that also recovers installs already in the broken
state (which a cleanup-on-install alone can't reach).

Refs #634
2026-06-23 04:46:58 +00:00
Chris Sherwood
a315ce0f54
fix(AI): truncate-and-retry oversized embed chunks; stop 30x retry storm (#881)
Dense source content produces chunks that exceed the embedding model's
context window (nomic-embed-text:v1.5 defaults to 2048 tokens). Two paths
hit this even after the prior pre-cap:

  - Older Ollama (e.g. 0.18.1, #944) ignores the num_ctx=8192 we send on
    /api/embed, so it stays at the model's 2048 default.
  - The OpenAI-compat /v1/embeddings fallback didn't pass num_ctx/truncate
    at all, so any Ollama drops to 2048 whenever it lands on the fallback.

When a chunk overflowed, the 400 was swallowed and the chunk was silently
dropped from Qdrant. Worse, the failure propagated to EmbedFileJob, which
re-embeds the entire file on each of its 30 BullMQ attempts — the "endless
queue loop" / "api/embed for weeks" / pegged GPU reported in #944/#959.

Fix:
  - OllamaService.embed(): on a context-length error, retry once with an
    aggressive 2048-safe cap (EMBED_CONTEXT_SAFE_CHARS = 2000) so the chunk
    is embedded (start-of-chunk) instead of dropped. Native-path context
    errors now bubble to this retry instead of falling through to the
    smaller-context fallback. Split the native+fallback attempt into
    _embedWithFallback().
  - Pass truncate/num_ctx on the /v1/embeddings fallback too (Ollama's
    OpenAI-compat shim forwards them).
  - EmbedFileJob: classify "input length exceeds context length" as an
    UnrecoverableError so one permanently-oversized chunk can't trigger 30
    full-file re-embeds.
  - Add OllamaService.isContextLengthError() shared by both.

Graceful degradation: a truncated chunk loses its tail but is kept in the
index, which is strictly better than today's silent drop + retry storm.

Refs #881. Supersedes the #369/#670 symptom closures that never fixed the
fallback path.
2026-06-23 04:46:57 +00:00
Chris Sherwood
df47139846
fix(content): narrow Wikipedia reconcile-skip to the managed selection file
reconcileFromFilesystem() skipped every ZIM whose filename starts with
`wikipedia_en_`, on the assumption that all such files are managed by the
WikipediaSelection model. But curated category tiers ship Wikipedia-themed
ZIMs (e.g. Medicine → Comprehensive includes `wikipedia_en_medicine_maxi`),
so those files were skipped during reconcile and their InstalledResource
rows got wiped on every restart — silently downgrading the detected tier.

Skip only the single file actually tracked by WikipediaSelection, matched
by exact filename instead of the `wikipedia_en_` prefix.

Reimplemented in-house from @johno10661's PR #774 (which was trapped on a
stale base); credit to them for the diagnosis and fix.

Closes #774
2026-06-23 04:46:57 +00:00
Chris Sherwood
663c1593df
fix(system): disable Update button while a service update is in flight (#931)
A multi-GB service update (e.g. nomad_ollama pulling ~6.5 GB) left the
Update button clickable with no feedback, so users clicked again thinking
it was stuck. The second click raced a concurrent updateContainer run into
Docker 304/400 errors (stop/rename on a container the first run had already
moved). The backend lock was in-memory only and never written to the DB, so
nothing durable signaled "update in progress" to the UI, and a page reload
mid-pull re-enabled the button.

Backend (docker_service.updateContainer):
- Set installation_status='installing' when the update starts and reset it
  to 'idle' in a finally on every exit path. This mirrors the install path,
  survives a page reload, and is visible to other tabs/clients.
- Reject a second update with a clear message when installation_status is
  already 'installing', instead of letting it race into Docker errors.

Frontend (settings/apps.tsx):
- Track in-flight updates per service. Seed optimistically on click and
  reconcile with the durable installation_status from the server.
- Disable the per-service Update button and show "Updating..." while in
  flight. Drop the fullscreen spinner for updates so the table and the
  activity feed (live pull/stop/start progress) stay visible.

Closes #931
2026-06-23 04:46:56 +00:00
Chris Sherwood
1b9f4f30f2
fix(kiwix): self-heal a missing or corrupt library XML on startup
Kiwix runs in library mode reading kiwix-library.xml via --monitorLibrary.
Today a missing or corrupt XML is only repaired on the download path
(rebuildFromDisk after a completed download), so if the file is lost or
truncated outside that flow — storage relocation, an interrupted write, manual
deletion — Kiwix comes up serving an empty library with no path to recovery.

Add KiwixLibraryService.ensureLibraryXmlHealthy(): reads the XML, and if it's
missing (ENOENT) or fails to parse / lacks a <library> root, rebuilds it from
the ZIM files on disk. A well-formed but empty library is treated as valid (no
spurious rebuild), and filesystem errors other than ENOENT are surfaced rather
than masked. The boot provider calls it on the already-in-library-mode path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:56 +00:00
jakeaturner
026ab6df8f
feat(supply-depot): add custom launch URLs for apps
Let users override an app's "Open" link with a reverse-proxy or
local-DNS address (e.g. https://jellyfin.myhomelab.net). Falls back to
the default host+port when unset. Metadata-only — no container changes.
2026-06-23 04:46:56 +00:00
Chris Sherwood
ca5ec1767f
fix(security): harden assertNotPrivateUrl with ipaddr.js + host normalization
Replaces the regex blocklist in assertNotPrivateUrl with ipaddr.js range
classification and normalizes the host before checking it. Consolidates two
community proposals (#930 ipaddr.js parsing, #912 trailing-dot normalization)
into one validator so the SSRF-critical path lives in-house with full tests.

- Classify literal IPs by range (loopback / linkLocal / unspecified) via
  ipaddr.js instead of a hand-maintained regex list, which also catches
  alternate IPv4 encodings and avoids over-blocking mapped public IPs (the old
  `::ffff:` regex blocked every mapped address, including public ones). IPv4-
  mapped IPv6 is reduced to its embedded IPv4 before classification.
- Strip a trailing root dot from the host so `localhost.` / `127.0.0.1.` can't
  bypass the checks (they resolve to the same target as the dotless form, #911).
- Strip IPv6 brackets and lowercase for the localhost comparison.
- RFC1918, bare LAN hostnames (e.g. `nomad3`), and external FQDNs remain
  allowed — LAN appliances need them, and DNS rebinding is a fetch-time concern
  outside this guard's scope.

Adds a consolidated unit spec covering loopback/link-local/unspecified literals,
alternate encodings, IPv4-mapped v6, mixed-case + trailing-dot localhost, and
the allowed LAN/FQDN/mapped-public cases.

Resolves #922. Supersedes #930 and #912 (thanks @Gujiassh and @luyua9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:55 +00:00
Chris Sherwood
98d235679e
fix(docker): reject failed image pulls instead of treating them as success
Every Docker pull went through `followProgress(pullStream, resolve)`, passing
the Promise's resolve as dockerode's onFinished(err, output) callback — so the
error argument was ignored and a failed pull (dropped/metered connection, bad
manifest, registry error, disk full mid-pull) resolved as if it had succeeded.
The code then tried to create/start a container from a missing or partial
image, surfacing a confusing downstream error rather than the real cause. (#790)

Add a DockerService.pullImage() helper that rejects when followProgress reports
an error, and route all five pull sites through it:
  - service install
  - AMD ROCm image pull
  - service update
  - force-reinstall / recreate (forcePull)
  - sysbench benchmark image pull

Closes #790

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:55 +00:00
Chris Sherwood
36068c645e
fix(KB): stop ZIM ingestion progress freezing at 99% on multi-page archives
On ZIMs that pack one logical article as several sub-pages (e.g. iFixit),
iterByPath yields more entries passing our isArticleEntry() filter than
archive.articleCount reports. The inter-batch progress used nextOffset /
articleCount, so the numerator outran the denominator, the ratio overflowed past
100%, and the UI (which clamps at 99%) pinned the file at 99% for the entire
remaining tail, making it look hung.

Grow the denominator to max(articleCount, nextOffset + ZIM_BATCH_SIZE) once we
pass the reported article count, so progress keeps creeping forward monotonically
instead of freezing, and clamp to 99% so only the genuinely-final batch reports
100%.

This is a graceful heuristic, not exact progress (true accuracy would require a
pre-scan to count isArticleEntry matches up front); it removes the user-visible
"stuck at 99%" symptom with no change to batch semantics.

Closes #903

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:54 +00:00
Chris Sherwood
3977c723c2
fix(KB): stop partial_stall warning firing on atypical ZIMs (link-out/PDF-heavy)
The Stored Files "partial stall" warning compares chunks in Qdrant against an
expected count from the ratio registry. The registry has an empty-pattern
catch-all (100 chunks/MB) that matches any filename, so a ZIM that matches no
specific pattern still gets a size-based estimate. For archives that are mostly
PDFs, images, or link-out stubs (e.g. irp.fas.org military-medicine), byte size
wildly over-predicts embeddable text: a 75 MB ZIM estimates ~7,236 chunks but
produces ~1, tripping a false "ingestion may have stalled" warning that re-embed
can't clear.

The catch-all is fine for rough aggregate disk-cost estimates, but it should not
drive a per-file stall signal. Add an `ignoreCatchAll` option to the ratio
lookup that excludes the empty-pattern row (returning null when only the
fallback would match), and use it in the warnings path so partial_stall only
fires when the registry has a *specific* expectation for the file. Files that
match a real pattern (wikipedia_, devdocs_, ifixit_, ...) are unaffected;
disk-cost/batch estimates keep using the fallback.

Closes #913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:54 +00:00
Chris Sherwood
25000b9869
fix(system): roll back service update when the new container fails to start
The service update path stopped and renamed the old container aside before
the new one was confirmed running, but only wired up rollback if
createContainer threw or the 5s health check failed. A throw from
newContainer.start() itself (bad device/GPU config, host port already bound,
image incompatibility) bubbled straight to the outer catch, which returned a
generic 400 and never restored the old container, leaving the service down.

Retrying then wedged: the failed new container still held the service name, so
the next attempt's rename to `<name>_old` collided with the leftover from the
first attempt and threw the same error every time.

- Wrap newContainer.start() so a start failure removes the half-created
  container and rolls back to the previous one.
- Clear any stale `<name>_old` before renaming so retries can't collide.
- Dedupe the three rollback sites into a single rollbackToOld() helper (also
  removes a non-null assertion in the create-failure path that could itself
  throw when no `_old` existed).

Refs #949

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:53 +00:00
jakeaturner
dfc284c34d
docs: fix out of place JSDoc comment 2026-06-23 04:46:53 +00:00
Chris Sherwood
3f574e4003
fix(system): show a clear message when a service port is already in use
When a service fails to install because something on the host already binds its
port, the user saw the raw dockerode error ("Bind for 0.0.0.0:11434 failed:
port is already allocated"), which is meaningless to a non-technical user. The
most common case is a native Ollama install holding 11434.

Add _humanizeDockerError() to map host port-conflict errors to an actionable
message that names the port and, for Ollama/11434, points at the likely cause
(a host Ollama service) with the commands to stop it. Unrecognized errors pass
through unchanged. Wired into the install failure broadcast and the thrown
error.

Closes #934

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:52 +00:00
jakeaturner
cf8db6218d
feat(supply-depot): opt-in automatic updates for installed apps 2026-06-23 04:46:52 +00:00
jakeaturner
02d985db5e
feat(system): add opt-in automatic updates for the core NOMAD app
Adds a self-update path for the NOMAD admin/core image that runs without a
human in the loop, gated by layered safety checks. Recreation remains the
sidecar's job; this only decides *whether* to update now and requests it.

An hourly job (AutoUpdateJob) evaluates a side-effect-free decision pipeline
(AutoUpdateService.evaluate):
 - opt-in: disabled by default
 - in a user-configured local-time window (handles midnight wrap)
 - an eligible release: same major (major bumps stay manual), strictly newer,
   past a configurable cool-off, GA only (no drafts/prereleases), strict semver
 - pre-flight: sidecar present, no in-flight system update / downloads / app
   installs, and sufficient host disk (estimated from the registry manifest)
When all pass, it drives SystemUpdateService.requestUpdate() with a vetted tag.

Failure backoff auto-disables after 3 genuine update-request failures; transient
release-lookup errors are skips (offline-first appliances are routinely without
connectivity). Re-enabling clears the backoff state.

Settings UI exposes the toggle, window, and cool-off, with live status (eligible
target, in/out of window, last result/error). A `node ace auto-update:dry-run`
command exercises the full pipeline — and a deterministic --scenarios suite the
pure decision logic — without ever triggering an update.

New KVStore keys under autoUpdate.* hold config + last-run state.
2026-06-23 04:46:51 +00:00
Metbcy
d175259219
fix(KB): persist accumulated chunk count across batched ZIM dispatches
The continuation dispatch in EmbedFileJob did not pass the running chunk
count forward, so each batch started with job.data.chunks undefined.
On the final batch, totalChunks collapsed to just that batch's result
and KbIngestState.markIndexed stored a value far below what Qdrant
actually held.

Add chunksSoFar to EmbedFileJobParams and thread it through the
continuation chain so the final markIndexed call reflects the true
total across all batches.

Closes #933
2026-06-23 04:46:51 +00:00
John Onysko
f553a0d57d
feat(config): respect REDIS_DB env var for queue and transmit
Allow operators to select a Redis logical database index via the
REDIS_DB environment variable. Without this, the BullMQ queue and the
@adonisjs/transmit Redis transport both implicitly used db 0, causing
key collisions when sharing a Redis instance across multiple services
or environments.

REDIS_DB is added to the env schema as an optional number; both
config/queue.ts and config/transmit.ts fall back to db 0 when unset,
preserving existing behavior.
2026-06-23 04:46:51 +00:00
Chris Sherwood
ccc221f88d
chore: align package license with Apache-2.0 and fix README docs links/typos
Correct package metadata and documentation accuracy:

- Set license to Apache-2.0 in package.json, admin/package.json and both
  lockfiles (project has been Apache-2.0 since #197; metadata still said ISC)
- Replace the placeholder root package.json description with a real one
- Remove the dead README "Troubleshooting Guide" link (TROUBLESHOOTING.md
  does not exist; FAQ.md already has its own entry)
- Fix README typos: "harware", "LLM's", "of of", "uses cases"

Carries forward the worthwhile fixes from #849 by @aqilaziz, minus the
version bump that conflicted with the current release line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:50 +00:00
jakeaturner
057fb693f6
fix(storage): match default prefix too when relocating child-app binds
_applyHostStorageRoot only rewrote binds carrying the current
NOMAD_STORAGE_PATH and short-circuited when env == resolved root. That
left user-modified/custom apps — whose binds freeze the default prefix at
edit time — mounting an empty dir on the documented "set NOMAD_STORAGE_PATH
+ relocate the volume" path (#938).

- Match either the env value or the hardcoded default prefix; drop the
  root == seededRoot no-op, excluding root from the candidates instead so
  rewrites stay idempotent.
- Add ADMIN_STORAGE_DEST / DEFAULT_HOST_STORAGE_ROOT constants; replace the
  cwd-derived storage dest so admin-mount lookup can't silently break.
- Comment why a transient inspect failure is deliberately not cached.
2026-06-23 04:46:49 +00:00
Chris Sherwood
fab15c68a9
fix(storage): derive child-app bind paths from the admin's actual storage mount
Child services (Kiwix, Ollama, Qdrant, Flatnotes, Kolibri) are created via the
Docker socket, so their bind mounts use a HOST path. That path was baked into
the services table at seed time from NOMAD_STORAGE_PATH (default
/opt/project-nomad/storage) — and NOMAD_STORAGE_PATH wasn't even present in
management_compose.yaml, so it always fell back to the default.

Result: relocating the admin storage volume in compose (e.g.
/mnt/big/storage:/app/storage) moved the admin's own data but left child apps
mounting the old, now-empty /opt/project-nomad/storage. Kiwix would come up
with no content. (#938)

- Add _resolveHostStorageRoot(): inspect the admin's own container, find the
  bind backing /app/storage, and use its host Source as the single source of
  truth (cached). Falls back to NOMAD_STORAGE_PATH/default if it can't be
  inspected.
- Add _applyHostStorageRoot(): rewrite the host-side prefix of each storage bind
  to that root. No-op when it already matches the seeded prefix, so default
  installs are unaffected.
- Apply it in _createContainer (covers installs + dependency recursion) and in
  the Kiwix library-mode recreate path.
- management_compose.yaml: add an explicit NOMAD_STORAGE_PATH knob and rewrite
  the comments so the admin volume, env var, and disk-collector volume that must
  agree are spelled out.

Closes #938

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:49 +00:00
jakeaturner
b2bea191a8
fix: minor type guarding fixes after axios bump 2026-06-23 04:46:48 +00:00
jakeaturner
555ffa8790
fix(supply-depot): reintroduce app update UI 2026-06-23 04:46:48 +00:00
Chris Sherwood
f488f08c96
feat(supply-depot): per-app onboarding docs, install fixes, and in-app Docs links
Add NOMAD-specific getting-started docs for all 9 curated Supply Depot apps,
the catalog/install fixes each one surfaced, and a way to reach the docs from
each app card.

Docs:
- New in-app Markdoc page admin/docs/supply-depot-apps.md covering all 9 apps
  (Stirling PDF, File Browser, Calibre-Web, IT Tools, Excalidraw, Homebox,
  Vaultwarden, Jellyfin, Meshtastic Web): first run/login, where data lives,
  and offline behaviour. Registered in docs_service DOC_ORDER.
- Manage > Docs dropdown item linking each app to its section
  (/docs/supply-depot-apps#<anchor>): anchor map in constants/supply_depot_docs.ts,
  heading anchors via Markdoc {% #id %}, and hash-scroll on the docs page.

Install / catalog fixes:
- Stirling PDF: open straight to the tools (SECURITY_ENABLELOGIN=false; the old
  v1 DOCKER_ENABLE_SECURITY flag was dead).
- File Browser: seed a known admin/nomad login (bcrypt) instead of a random
  log-only password; scope visibility to content folders via mount selection and
  move the DB out of the browsable root.
- Calibre-Web: bundle an empty Calibre library and seed it on install so setup
  doesn't dead-end at db config (_runPreinstallActions__CalibreWeb).
- Homebox: swap the archived hay-kot image for the maintained sysadminsmedia fork.
- Vaultwarden: generate a self-signed cert on install and serve HTTPS by default
  (_runPreinstallActions__Vaultwarden + ROCKET_TLS + ui_location https:8480), so
  the web vault has the secure context it requires.
- Jellyfin: pre-create storage/media/{Movies,TV Shows,Music,Photos} so each
  library points at its own subfolder, avoiding the overlapping-path issue that
  silently hides content (_runPreinstallActions__Jellyfin).
- Seeder run() now also syncs ui_location for non-modified curated services, so a
  catalog link/scheme/port change reaches existing installs on update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:47 +00:00
Chris Sherwood
b8674193da
fix(supply-depot): show clean port + lock on card pill for https:port ui_location 2026-06-23 04:46:47 +00:00
Chris Sherwood
3501144caa
feat(supply-depot): scheme-aware service links (https:port) for TLS-serving apps 2026-06-23 04:46:47 +00:00
Chris Sherwood
7b1b480c5e
fix(supply-depot): correct Meshtastic Web internal port (80->8080); add catalog port audit script 2026-06-23 04:46:46 +00:00
Chris Sherwood
02c33b277e
feat: edit curated apps + fix dropdown clip + stale _old rollback 2026-06-23 04:46:46 +00:00
Jake Turner
be434d755a
feat: supply depot 2026-06-23 04:46:45 +00:00
Chris Sherwood
e38dbf8a65
feat(zim): add "Rescan Library" button for sideloaded ZIM files
Adds a user-facing trigger to rebuild the Kiwix library index from the
ZIM files currently on disk. Covers the sideload case: a user copies a
.zim onto the box (USB, SSH, network share) outside NOMAD's download
flow, and Kiwix has no way to discover it without regenerating the
library index.

Reuses the existing native KiwixLibraryService.rebuildFromDisk() (which
also extracts embedded favicons natively), so no kiwix-tools container
and no icon-patch step are needed. In library mode (--monitorLibrary)
kiwix-serve hot-reloads the XML automatically; only legacy glob-mode
containers are restarted.

- KiwixLibraryService.rebuildFromDisk now returns the book count; adds
  getBookCount() for the before/after delta
- ZimService.rescanLibrary() orchestrates rebuild + legacy restart
- POST /api/zim/rescan-library + ZimController.rescanLibrary
- "Rescan Library" button on the Content Manager page (shown when Kiwix
  is installed) with a success toast reporting books found

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:45 +00:00
jakeaturner
7943ae5d1f
chore(deps): bump axios to 1.17.0 in admin 2026-06-23 04:46:44 +00:00
jakeaturner
b8961d27dd
fix(rag): improve context-reliance hedging and use heading metadata at query time
Users often saw the assistant disclaim ("Sorry, I couldn't find specific
context regarding X, but here's a general answer...") even when material
that directly answered the query was embedded. Two compounding causes,
both on the read/generation side:

1. The rag_context system prompt explicitly authorized the hedge: it told
   the model to fall back to general knowledge and "acknowledge the
   limitations," and mandated "According to the information available..."
   citations that pushed small models into meta-commentary preambles.
   Rewrote it to treat the retrieved context as the primary, authoritative
   source, lead with the answer, fall back to general knowledge *silently*,
   and never emit "couldn't find specific context" phrasing, while still allowing
   the model itself to make some "sanity-check" decisions if the retrieved context
   seems wildly incorrect or unrelated to the user's query.

2. The injected context labeled each block with a raw relevance score
   (e.g. "Relevance: 42.3%"). nomic-embed cosine scores for genuinely
   relevant passages sit ~0.4-0.6, so the number primed the model to
   distrust correct context. Dropped the model-visible score (it stays in
   the logs) and replaced it with a neutral source-title label.

Also added a conservative, score-scaled heading boost in rerankResults:
when query keywords match a chunk's section/article title (ZIM metadata we
already fetch), nudge its rank. Same diminishing-returns shape as the
existing boosts and gated behind the existing semantic-quality threshold,
so it can't promote a weak match.

Scope note: this removes the visible hedge and improves ranking of
already-retrieved chunks. It does NOT fix retrieval recall — diluted
1500-token chunks that never reach dense search's top-k are unaffected.
That's a follow-up (smaller chunks + BM25/RRF hybrid).
2026-06-23 04:46:43 +00:00
Jake Turner
17630c048f
docs: update release notes 2026-06-23 04:46:43 +00:00
cosmistack-bot
d5f7c3f615 docs(release): finalize v1.32.1 release notes [skip ci] 2026-05-27 22:36:20 +00:00
jakeaturner
e26ce4f1f6 docs: update release notes 2026-05-27 15:32:28 -07:00
jakeaturner
8d2bf785e1 chore(deps): bump various deps 2026-05-27 15:32:28 -07:00
chriscrosstalk
97c65cca67 perf(KB): swap Qdrant full-scroll for facet on source enumeration (#928)
The Stored Files modal and per-file warnings panel both enumerate
distinct 'source' values in the qdrant content collection. The
existing implementation scrolls every point in the collection, 100 per
page, just to learn the unique sources. On a fully-ingested NOMAD this
is brutally slow: NOMAD3's 3.1M-point collection takes 50+ seconds to
return its ~40 distinct sources, and the same scan runs twice per modal
open (getStoredFiles + computeFileWarnings each pay it independently).

Qdrant 1.10+ supports a facet aggregation that returns the distinct
values of a payload field with their counts in a single call.
@qdrant/js-client-rest@1.16.2 (already pinned) exposes this as
`client.facet()`. Swap the three scroll loops:

- RagService.getStoredFiles
- RagService.computeFileWarnings
- RagService.\_scanAndSync (the source-set used for skip-already-embedded
  decisions)

As a bonus, computeFileWarnings's per-source chunk count comes back
inline from the facet, so the secondary scroll that was incrementing a
counter is also gone.

`exact: true` everywhere because the counts feed Warning A's
zero_chunks decision and could mis-fire near thresholds otherwise. New
RagService.FACET_SOURCE_LIMIT = 10000 caps the facet response; real
NOMADs run ~100 sources max, this is comfortable headroom.

Expected impact on NOMAD3 (3M points, 40 sources): ~50s -> ~100ms per
endpoint. On an empty/fresh KB: no measurable change either way.
2026-05-27 15:32:28 -07:00
chriscrosstalk
5bf5bc33b7 fix(KB): cursor on Always/Manual ingest policy buttons (#927)
Tailwind v4 dropped the default `button { cursor: pointer }` preflight
rule. The Always/Manual toggle in the KB modal uses inline <button>
elements (not the StyledButton wrapper that sets cursor-pointer
explicitly), so hovering them shows the default arrow cursor instead of
a pointer like every other clickable in the modal. Reported on NOMAD1
during v1.32.0 dogfooding.

Add cursor-pointer when not pending; keep cursor-not-allowed when the
mutation is in flight.
2026-05-27 15:32:28 -07:00
chriscrosstalk
737e6784f0 fix(logging): also write production logs to stdout for docker visibility (#870)
In production, the logger was configured with a single file target writing to
`/app/storage/logs/admin.log`. Because the production pino transport had no
stdout target, `docker logs nomad_admin` only saw startup banner lines from
non-pino sources — every `logger.info`/`logger.debug` call from controllers,
services, and providers was effectively invisible from outside the container.

That's been silently masking diagnostics for anyone trying to debug a running
NOMAD without exec-ing into the admin container and tailing the log file.
RAG retrieval scores, query rewrites, container preflight decisions, version
check results — all of it was there in `admin.log` but absent from any
external observation point (docker logs, container log aggregators, etc.).

This adds a second production target writing JSON to stdout (fd 1) via the
same pino/file transport. Effect: `docker logs nomad_admin` now shows the
full runtime telemetry, the persisted log file is unchanged (so Debug Info
bundle export keeps working), and external log aggregators that scrape
container stdout now have something to scrape.

Verified on NOMAD8 (v1.32.0-rc.3): post-patch `docker logs --tail 15
nomad_admin` shows the structured `{"level":30,...,"msg":"[VersionCheckProvider]
Checking for stale updateAvailable..."}` lines that previously only existed
in admin.log. File destination still receives writes (size delta confirmed
after an `/api/system/info` hit).

This is the unblock for the AI Quality eval work — without log visibility,
every diagnostic conversation about RAG retrieval and query rewriting is
guesswork.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:32:28 -07:00
jakeaturner
d2dd8b4f9e docs: update release notes 2026-05-20 10:16:00 -07:00
jakeaturner
95bd05ed78 chore(deps): pin ipaddr.js version 2026-05-20 10:16:00 -07:00
jakeaturner
736c9bd672 fix(security): canonicalize hostnames to block IPv4-mapped IPv6 IMDS bypass
Replace literal string matching with ipaddr.js parsing
so equivalent encodings of 169.254.169.254
(::ffff:169.254.169.254, ::ffff:a9fe:a9fe,fully-expanded forms)
and fd00:ec2::254 are all rejected.
2026-05-20 10:16:00 -07:00
jakeaturner
b3dac9b324 fix(security): match IPv6 SSRF patterns against unbracketed hostnames 2026-05-20 10:16:00 -07:00
jakeaturner
989a401f28 fix(AI): improve remote Ollama url validation to prevent SSRF vulnerability 2026-05-20 10:16:00 -07:00
Jake Turner
82f67debc1 fix(models): correct inverted belongsTo keys on ChatMessage.session (#921)
foreignKey/localKey were swapped on the ChatMessage → ChatSession
relation. Per Lucid's belongsTo contract, foreignKey is the column
on the child model and localKey is on the parent — so this must be
{ foreignKey: 'session_id', localKey: 'id' }, mirroring the inverse
hasMany on ChatSession.

The relation is not currently preloaded anywhere in the codebase, so
no runtime behavior changes today; this closes a latent bug that
would have broken any future preload('session') call.
2026-05-20 10:16:00 -07:00
Chris Sherwood
a5fe52f66f fix(KB): respect Manual ingest policy on post-download dispatch
RunDownloadJob's onComplete handler was unconditionally firing
EmbedFileJob.dispatch after every ZIM download, gated only by "is
Ollama installed?". The rag.defaultIngestPolicy KV setting was never
consulted, so users who explicitly set Auto-index to Manual still got
every newly-downloaded ZIM auto-embedded.

RagService.scanAndSync already handles Manual correctly by recording
pending_decision rows instead of dispatching (rag_service.ts:1587-1638
via decideScanAction). The post-download path skipped that gate.

Mirror the same check at the dispatch site: read the policy KV; if
Manual, firstOrCreate a pending_decision row in kb_ingest_state so the
per-file Index affordance from PR #909 surfaces the file the same way
scan-time-discovered Manual files do. firstOrCreate (not create) so a
re-download doesn't demote an existing indexed/failed row — the user
can explicitly re-index from the KB panel if they want fresh content.

Verified on NOMAD3: with rag.defaultIngestPolicy='Manual', every ZIM
downloaded today via Content Explorer (agriculture-essential +
computing-essential, ~62 MB across 7 files) wrote kb_ingest_state
rows with state='indexed' instead of pending_decision. Real bug,
not a hot-patch artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:16:00 -07:00
Chris Sherwood
059cf2afbe fix(content): show selected tier on cards while downloads are in flight
Since PR #36b6d8e moved tier-installation tracking from a client-side
persistence model to a server-side derive-from-disk model, the card
display only ever updates once every file in a tier is fully on disk.
A user who picks Standard sees a blank card for the duration of the
download (often hours for large tiers like Wikibooks). Worse, if some
files finish before others, the card briefly shows a lower tier (e.g.
Essential) before promoting to the selected tier on completion, which
reads as "the system didn't accept my pick."

Backend: compute a sibling `downloadingTierSlug` by unioning installed
resource IDs with the IDs from active RunDownloadJob queue entries
(waiting + active + delayed, failed deliberately excluded), then
resolving the highest tier whose every resource is in that union. Set
only when it differs from `installedTierSlug` — no point reporting
"downloading Standard" when Standard is already fully installed.

Frontend: unify the prominent corner badge logic in CategoryCard to a
single `badgeTier` derived from selectedTier > downloadingTier >
installedTier. Spinner + "(downloading)" suffix when in flight,
checkmark for installed/selected. The pill row and lime border follow
the same source.

Verified on NOMAD3: backend correctly resolves the downloading tier
from in-flight BullMQ jobs; CategoryCard shows the spinner badge
immediately on Submit and switches to the checkmark variant when
downloads complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:16:00 -07:00
Chris Sherwood
6e5284e563 fix(KB): TierSelectionModal hook order + register IconLibrary
Two related fixes surfaced by armandoescalante in #915 when clicking a
Content Explorer category card (e.g. Medicine) on v1.32.0-rc.6:

1. TierSelectionModal placed a useMemo for freeBytes *after* the
   `if (!category) return null` early return (introduced in PR #901's
   guardrail integration). When `category` transitioned from null to
   non-null on first open, React saw a different hook count between
   renders and crashed the entire component tree with "Rendered more
   hooks than during the previous render", blanking the modal. Moved
   the freeBytes useMemo above the early return so hook order is
   constant.

2. `IconLibrary` was used as the icon prop on the Manage Custom
   Libraries button in remote-explorer.tsx but never registered in
   the DynamicIcon allowlist at admin/inertia/lib/icons.ts. Added it
   to both the import block and the icons map so the warning stops
   firing and the icon renders.

Closes #915.
2026-05-20 10:16:00 -07:00
jakeaturner
a9c48fc098 refactor(AI): single source of truth for embedding model name
Lift the hardcoded 'nomic-embed-text:v1.5' string out of both
RagService and OllamaService into a shared EMBEDDING_MODEL_NAME
constant in constants/ollama.ts. The duplicate in OllamaService
existed only to dodge a circular import with RagService; the
constants module has no service imports, so a shared constant
eliminates both the duplication and the drift risk called out
in the inline "keep in sync" comment.
2026-05-20 10:16:00 -07:00
Chris Sherwood
ffa70a54bc feat(chat): confirm-on-switch + one-chat-model-at-a-time enforcement
Surfaces NOMAD's previously-silent model-stacking behavior and enforces a
"one chat model in VRAM at a time" invariant (the embedding model is
always exempt). Addresses Chris's NOMAD3 testing observation that
switching the dropdown in the chat header was invisibly slow on low-VRAM
hardware because the prior model was never unloaded — Ollama would
either evict it under memory pressure or load the new one on CPU after
the runner choked.

Three integration points all funnel through one new helper:

- **User changes the model dropdown** in an active chat session →
  confirm modal "Switch to {newModel}? Switching to {newModel} will
  start a new chat. Your current conversation stays available in the
  sidebar." On confirm, fire `keep_alive: 0` against the previous chat
  model, clear active session, set the new selection. Cancel snaps the
  visible dropdown back to the previous value (no popup state leaks
  into `selectedModel`).

- **User clicks a session in the sidebar** → no popup (system-initiated).
  Restore the session's stored model into the dropdown and fire
  `unloadChatModels(targetModel)` so anything that isn't the target
  gets the unload hint.

- **Chat page first mount** → page-load normalization. Anything stacked
  from a prior session gets the unload hint with the current selected
  model as the target-to-preserve. Guarded by a ref so it only fires
  once per page lifetime; gated on `selectedModel` being populated.

Backend surface is a single new helper and a single new route:

  `OllamaService.unloadAllChatModelsExcept(targetModel: string | null)`
  → queries `/api/ps`, filters out (a) the embedding model name
  (hardcoded `nomic-embed-text:v1.5` to avoid the RagService circular
  import) and (b) `targetModel`, fires `POST /api/generate` with empty
  prompt + `keep_alive: 0` in parallel against everything else.
  Returns the names that were hinted. Best-effort: network or Ollama
  errors are logged and swallowed so callers don't fail on housekeeping.

  `POST /api/ollama/unload-chat-models` → thin wrapper validating
  `{ targetModel?: string | null }`.

Why `keep_alive: 0` is safe against in-flight inference: per Ollama's
scheduler semantics, the hint sets the post-completion eviction timer
to zero — the runner is not terminated. If Session A is mid-response
on gemma when Session B fires the unload, gemma stays resident until
A's request completes, then evicts. The user-visible worst case is the
race where A's longer-running request re-extends the timer back to the
default and the unload is no-op'd; the next transition (or page reload)
gets another chance, and Ollama's own LRU catches up under memory
pressure regardless. Robust in-flight tracking deferred to a follow-up
if we see stale-state in the wild.

Base `rc`: v1.40.0 will inherit everything from rc.6 via the backmerge.
Frontend tests deferred to a follow-up PR; existing inertia tsconfig
errors are pre-existing and unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:16:00 -07:00
jakeaturner
9356443d73 refactor(KB): typed failure codes for embedSingleFile + accurate HTTP status
Return a discriminated `EmbedSingleFileResult` from `RagService.embedSingleFile`
with `code: 'not_found' | 'inflight' | 'delete_failed' | 'dispatch_failed'` on
failure. `RagController.embedFile` now maps those codes to the correct status
instead of collapsing every failure to 409:

- not_found       → 404
- inflight        → 409
- delete_failed   → 500
- dispatch_failed → 500

The `code` is also included in the JSON body so clients can branch without
string-matching `error`.
2026-05-20 10:16:00 -07:00
Chris Sherwood
d850cb9588 feat(KB): per-file ingest action + state indicator on Stored Files (RFC #883 §5)
Closes the Manual-mode UX dead-end: after toggling 'Auto-index new content
for AI?' to Manual, a freshly-downloaded ZIM (or any pending_decision file)
had no UI path to opt in for embedding short of the global Sync Storage /
Re-embed All bulk actions. Per RFC #883 §5, each Stored Files row now
carries a state pill and an adaptive single-button action.

State pill (left of any existing warning chips):
  - 'Indexed'    — green; row had chunks in Qdrant or state row is 'indexed'
  - 'Not Indexed' — neutral; state is pending_decision or browse_only
  - 'Failed'     — red
  - 'Stalled'    — amber
  - admin_docs collapsed row has no pill ('Managed by NOMAD' carries it)

Adaptive action button (paired with the existing Delete button per row):
  - pending_decision         → 'Index' (force=false)
  - browse_only              → 'Index' (force=true)
  - failed / stalled         → 'Retry' (force=true)
  - indexed + warning chip   → 'Re-embed' (force=true; confirm modal first)
  - indexed healthy / null   → no action button (bulk Re-embed All covers it)

Backend: GET /api/rag/files now returns
  { files: Array<{ source, state, chunksEmbedded }> }
instead of a flat string[]. State + chunk-count come from a single
KbIngestState query unioned into the existing Qdrant-derived source list
(no new round trips). New POST /api/rag/files/embed validates the source is
known, refuses if any inflight job already targets the same filePath
(prevents double-click duplicate-chunk hazard), pre-deletes Qdrant points
when force=true, then dispatches via the existing _dispatchEmbedJobsFor
helper used by reembedAll.

Per-file Re-embed (force=true on an already-indexed file) routes through a
StyledModal confirmation since it deletes existing vectors before queueing
a fresh job — same destructive-action weight as Delete's inline confirm but
heavier since it affects search until the rebuild finishes.

Folds in PR #907's blank-screen fix because my new render needs the same
generic restored: `<StyledTable<KbFileGroup>>` and `record.displayName`
(instead of the unresolved `sourceToDisplayName(record.source)` that ships
in rc.5 and ReferenceErrors on modal open). PR #907 also adds title
tooltips on the three bulk-action buttons; those tooltips are NOT included
here — let PR #907 land first or independently for that part.

Multi-select bulk-opt-in deferred per discussion: most Manual-mode users
ingest 1-2 files at a time, the existing global toggle covers the bulk
case, and checkboxes would expand scope past what rc.6 should hold. Will
file a follow-up issue for an 'Index N pending files' single-click button
once this lands.

Tests-in-PR scope was limited to keeping `kb_file_grouping.spec.ts` green
after the StoredFileInfo[] signature change (added asInfos() wrapper).
Dedicated unit tests for embedSingleFile (unknown source / inflight refused
/ force=true delete-then-dispatch) and the new state-pill rendering will
land in a follow-up PR alongside Playwright coverage of the row actions.

Verification path: NOMAD3 currently runs project-nomad-admin:integration-
rc6-preview (PRs #907 + #908 atop rc.5). After this branch is built into a
new integration tag, I'll re-run targeted Playwright UAT on the KB modal
covering: state pill rendering per state, Index click on pending_decision
opts in cleanly, Retry on failed re-dispatches successfully, Re-embed
confirmation modal copy + delete-then-dispatch on the military-medicine
partial-stall row, and Delete flow untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:16:00 -07:00
Chris Sherwood
0617d54762 feat(easy-setup): split AI into its own conditional step (issue #905)
Easy Setup wizard previously bundled AI model selection + the new
ingest-policy radio into Step 3 alongside Wikipedia/ZIM tiers and curated
content. Three problems with that:

1. Predicate divergence: "is AI selected?" was answered three different
   ways across Step 3 radio, Step 4 review card, and handleFinish
   persistence. Surfaced in @jakeaturner's review of PR #900. The three
   predicates disagree in real cases (e.g. Ollama already installed but
   user didn't re-select any models -- handleFinish writes the ingest KV
   while the review hides the AI summary).

2. Step 3 was overloaded -- ZIM tiers + curated content + AI models +
   ingest policy in one screen.

3. No way to opt out of seeing the AI policy radio when AI isn't part of
   the user's setup.

This restructure makes step 4 a dedicated, conditional AI step:

  Step 1 (Apps)     -- unchanged (services + remote Ollama toggle/URL)
  Step 2 (Maps)     -- unchanged
  Step 3 (Content)  -- Wikipedia + curated tiers only
  Step 4 (AI)       -- NEW, conditional: model picker (or remote notice)
                        + auto-index policy radio. Skipped entirely when
                        AI isn't in the setup.
  Step 5 (Review)   -- summary, reads back step 4's output via the same
                        canonical predicate

Decisions per issue #905 discussion:

- Canonical predicate `isAiInSetup` as a useMemo. Single source consumed
  by step indicator, nav skip logic, review summary, and handleFinish.
  Both prior divergence cases collapse.

- Step indicator renders dynamically: 4 dots when AI is off (positional
  display numbers 1..4), 5 dots when AI is on. WizardStep semantic values
  (1=Apps, 2=Maps, 3=Content, 4=AI, 5=Review) stay stable so nav handlers
  don't have to translate; the dot's `displayNumber` is decoupled from
  its `step` so users see sequential 1..N with no gap.

- handleNext / handleBack are symmetric: 3 -> 5 forward, 5 -> 3 back,
  when !isAiInSetup. Same predicate gate.

- Toggling AI capability off in Step 1 after AI step selections were
  made fires a confirm dialog ("Turning off AI will discard your AI
  model picks, indexing policy, and remote Ollama configuration") and
  clears selectedAiModels / ingestPolicy / remoteOllamaEnabled on
  confirm. Silent clear when nothing was set.

- Remote Ollama toggle stays in Step 1 alongside the capability card.
  Don't fragment "am I using remote AI?" across two steps.

The bundled review summary (renderStep5, was renderStep4) now uses
`isAiInSetup` for the auto-index card visibility instead of the
divergent `(selectedAiModels.length > 0 || remoteOllamaEnabled)`.

Inertia tsconfig clean for this file (the only outstanding errors are
the 3 KnowledgeBaseModal ones from issue tracked in PR #907 and the
~64 pre-existing errors elsewhere).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:16:00 -07:00
Chris Sherwood
633a3c3500 fix(KB): blank-screen on panel open + tooltips on bulk-action buttons
The Stored Knowledge Base Files render crashed on first open in v1.32.0-rc.5
with `ReferenceError: sourceToDisplayName is not defined`. The table column's
render() called `sourceToDisplayName(record.source)` but the function was
extracted to `lib/kb_file_grouping.ts` in PR #892 and never imported in
KnowledgeBaseModal.tsx. The unhandled error unmounts the entire React tree,
so users see a blank screen ~20s after opening the panel.

Root cause: PR #895 (conditional warnings) rewrote the render() and used
`sourceToDisplayName(record.source)` instead of `record.displayName`, which
KbFileGroup already carries from groupAndSortKbFiles(). PR #895's review
follow-up (cbae48a) compounded this by narrowing the StyledTable generic
from `KbFileGroup` to `{source: string}`, hiding the type drift from tsc.

This restores the post-#892 pattern:
- StyledTable generic back to `KbFileGroup`
- Render uses `record.displayName` (works for both per-file rows and the
  collapsed admin-docs row; calling sourceToDisplayName on the synthetic
  `__admin_docs_group__` would have rendered that literal as the row name).

Also folds in tooltip copy on the three bulk-action buttons (Reset & Rebuild,
Re-embed All, Sync Storage) so the difference in destructiveness is visible
on hover. Uses native `title` attribute via StyledButton's prop pass-through;
no new component dependency.

Inertia tsconfig catches this regression cleanly (TS2304 + TS2339); the
pre-push hook only runs the backend tsconfig which excludes inertia/**, so
the bug shipped. Tracking the typecheck-coverage gap as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:16:00 -07:00
Jake Turner
7e768f3d09 fix(KB): guardrail bypass during estimate load + Transition sibling (PR #901 review)
- Disable TierSelectionModal Submit while the embed-estimate query is in
flight, so a fast click can't slip past the guardrail with an undefined
estimate.
- Move KbGuardrailModal out of the outer <Transition> and render it as a
Fragment sibling — Headless UI's Transition expects Transition.Child
descendants, not raw conditional siblings.
2026-05-20 10:16:00 -07:00
Chris Sherwood
cf3a924b9f feat(KB): guardrail modal at 50GB / 10%-free thresholds (RFC #883 §7)
One-time confirmation step gating bulk indexing actions that would
consume a substantial amount of disk for embedding storage. Fires only
when the user has policy=Always (i.e., the system would auto-index)
AND the estimate trips either:

  - GUARDRAIL_ABSOLUTE_BYTES = 50 GB embedding cost, OR
  - GUARDRAIL_FREE_DISK_RATIO = 10% of current free disk space

Under policy=Manual the guardrail is silent because the user has
already opted out of automatic ingestion — the files would just queue
as pending_decision either way.

Pieces
- inertia/lib/kb_guardrail.ts: pure decision helper with two constants
  and an evaluateGuardrail() that returns a verdict + reasons. No I/O
  on the helper itself so the logic is trivially testable
- inertia/components/KbGuardrailModal.tsx: confirmation dialog. Headless
  UI Transition + Dialog, amber 'large operation' header, plain-English
  estimate summary, [Cancel] / [Proceed anyway] footer. z-[60] so it
  layers above the tier modal underneath instead of replacing it
- inertia/components/TierSelectionModal.tsx integration: handleSubmit
  now evaluates the guardrail when policy=Always and embedEstimate is
  available; if it trips, we stash the verdict in state and render the
  guardrail modal as an overlay. Confirm runs finalizeSubmit (which is
  the pre-existing onSelectTier + onClose path); Cancel just closes the
  guardrail and leaves the tier modal as-is so the user can change
  their tier choice or flip the policy

The disk-free signal comes from the existing useSystemInfo hook +
getPrimaryDiskInfo helper. Passing freeBytes=0 (unknown) skips the
relative-disk check, so the modal still works on hosts whose disk
introspection failed — just relies on the absolute 50 GB threshold

Tests
- 9 cases in tests/unit/kb_guardrail.spec.ts: standard small batch (no
  trip), exact absolute threshold trips, over-absolute trips, over 10%
  free trips, both-at-once trips with two reasons, freeBytes=0 skip,
  freeBytes=0 + over-absolute trip, exact-10% boundary trips, just-
  under-both safe. All green.

Stacks on feat/kb-tier-estimate-on-disk (#897) — consumes that PR's
estimate endpoint to compute the verdict input. Auto-rebases to rc
when #897 merges.

Pairs with #894 (policy toggle) and #899 (JIT prompt): together the
three PRs cover the 'how do I avoid surprising the user with auto-
indexing they didn't ask for?' arc.

Out of scope (deferred)
- 6 hr time threshold (RFC §7): needs a per-host chunks-per-second
  metric we don't capture yet; would be a follow-up after Phase 4
  self-calibration (RFC §15) lands
- Wider integration (KbPolicyPromptBanner 'Index now' button, manual
  KB-modal sync): TierSelectionModal is the dominant bulk-decision
  surface and the right place to land this first
2026-05-20 10:16:00 -07:00
Chris Sherwood
7a681d04ab feat(KB): wizard AI policy step (RFC #883 Phase 3 task 13)
Adds an inline auto-index policy choice inside the Easy Setup wizard's
existing AI section (Step 3 'Content', alongside AI model selection).
The selection is persisted to KVStore['rag.defaultIngestPolicy'] on
wizard submit — same key #894's KB modal toggle reads/writes — so a
user who completes the wizard never sees the first-chat JIT prompt
(#899); their decision is already recorded.

Default is 'Always' so new users who keep the default get the 'just
works' experience: content downloaded by the wizard becomes searchable
as soon as it finishes embedding, without a follow-up step. Users who
prefer the explicit-opt-in flow can flip to 'Manual' before submitting.

Skipped when the user doesn't select the AI capability — the KV stays
null and the JIT prompt handles the decision later if/when they enable
AI from settings.

UI placement
- Step 3 'Content': new section below AI Models grid (only when AI is
  selected), two-button radio matching #894's KB-modal toggle pattern
  for visual consistency
- Step 4 'Review': new 'Auto-index Setting' card summarizing the choice
  in plain English ('New content will be indexed automatically' vs
  'New content will wait for you to opt in') so the user knows what
  they're agreeing to before clicking Complete Setup

handleFinish
- New api.updateSetting('rag.defaultIngestPolicy', ingestPolicy) call
  runs first, before service installs/downloads, so any content that
  finishes embedding during this same wizard run sees the right policy
- Wrapped in its own try/catch so a transient KV write failure doesn't
  abort the rest of the wizard

Stacks on feat/kb-policy-toggle (#894) — uses the policy KV mechanism
that PR introduces. Auto-rebases to rc when #894 merges.

Pairs with #899 (JIT prompt): wizard users decide here; non-wizard
users decide at first chat. Together they cover every entry path
to v1.32.0 without double-prompting.
2026-05-20 10:16:00 -07:00