#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.
- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
`Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
these quotes alone and only strips the two that are genuinely redundant, which this commit
strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
When no resident model cleared 20 tok/s, the resolver still recommended the
fastest one (fastest-resident). A "32 GB" machine without a discrete GPU reads
31.5 GB, where only the 27B fits, at a predicted 13 tok/s, so CPU-only and
integrated-GPU machines were steered to a slow dense model. Those machines now
get no recommendation, and every fitting model stays one click away in Browse.
The no-recommendation copy now says the model must run at full speed, the user
guide states the rule, and the unused fastest-resident tooltip string is gone
from every locale.
With no nvidia-smi answer and no discrete engine device, probe_budget budgeted
all of RAM as unified memory. Windows caps an integrated GPU at its GPU memory
(DXGI dedicated plus shared, Task Manager's figure): an Arc B390 on a 31.5 GB
machine gets 18 GB, yet Local Models showed 31.5 GB and priced the 27B as
fitting. The pool is now capped at that figure, read through DXGI for the
adapter the engine named, else the only non-NVIDIA adapter when a Vulkan or HIP
engine will run the model. The CPU engine keeps the RAM budget, whether the
user chose it or it is the only build for the machine (a Snapdragon). The
engine's own number would overshoot: ggml-vulkan sums every heap of an
integrated GPU, host-visible ones included. The DXGI reader is a cached host
fact in hermes_platform. The cap only lowers the pool; CPU-only machines,
other platforms and a failed DXGI read keep the RAM figure.
After the pull, the v2026.9.24 updater lazily imports
hermes_cli.local_runtime.processes (in _subprocess_compat.bounded_probe_run)
from the new checkout. Its process still holds the old
hermes_platform.host.facts. The package __init__ eagerly imported every
submodule, so hardware.py ran `from hermes_platform.host.facts import
gpu_class` (added in 827f4ff1d1) against the old facts module, and the
update died with ImportError. This broke e2e-upgrade (core) on main and
on every PR.
The package-level re-exports now resolve on first access through a module
__getattr__, so importing one submodule runs only that submodule. Callers
of `hermes_cli.local_runtime.<name>` are unchanged.
ggml-org's head and unsloth's share a filename, and a download is skipped when its destination
already exists. A machine holding unsloth's head, which aborts mainline llama.cpp at load, would
launch with it instead of fetching ggml-org's. The head now lands as
mtp-Qwen3.8-Flash-Next-ggml-org-Q8_0.gguf.
Co-authored-by: Ryan Hill <38674843+RyanUnderhill@users.noreply.github.com>
Downloads resolved files from main, so a re-upload on the publisher's side would change the bytes
behind the catalog's exact sizes and the pricing measured from them. AssetFile carries a revision
and the download plan resolves each file at it. Flash Next pins unsloth's weights and projector to
38bb39ee and ggml-org's head to 052beea; entries without a revision still follow main.
Co-authored-by: Ryan Hill <38674843+RyanUnderhill@users.noreply.github.com>
Older apps refresh catalog.json from main and ignore fields they do not know. Given this
catalog, main's code recommends Flash Next on an RTX Spark and launches it without its MTP head,
with the 26.8 GiB per-layer table loaded and its QSA compute buffer unpriced. Catalog schema 2
makes those apps skip the refresh and keep the catalog they shipped with; they read schema 2
once they update.
The RTX Spark ships with the 27B as its default model. Flash Next fits its 128 GB pool and stays
available, but Windows backs GPU memory with commit outside the carve-out, so Flash Next loads
only once the carve-out leaves Windows enough memory to back it: with no page file, an 8 GB
carve-out loads it and a 64 or 96 GB one does not.
recommended_entry consults a product-default table before the derived rule, keyed like the
measured-rate table on platform, effective backend and recognized GPU. The derived rule still
decides when the default is ineligible or cannot run resident, under a Vulkan or CPU engine, and
on the DGX Spark. The Recommended badge reads "The default model for this machine, chosen by its
manufacturer."
unsloth's MTP heads mark the MTP block as plain attention (compress ratio
0) beside a QSA trunk. Mainline llama.cpp then builds the k-pool inputs
with nothing reading k_idxs, and the server aborts at load with
GGML_ASSERT(buffer), on b11370 and b11430 alike.
ggml-org publishes a head that loads. On a DGX Spark at 256K and
temperature 1.0 it drafts at 39 tok/s on prose and 32 on code, against
~27.5 without MTP. AssetFile gains a repo so a companion can download
from a repo other than the model's.
Qwen4Exp's QSA attention scores the whole window each microbatch. Under
the router's unified KV every slot sees the full window, and b11370
reserves ~46 bytes per microbatch token per window token for each
context, device plus host; an MTP head is a second context. At 256K that
is 5.9 GiB per context at -ub 512 and ~23 GiB at -ub 2048, so the
prefill posture Hermes chose on a 128 GB Spark loaded the target and ran
out of memory creating the MTP context.
plan_launch now prices each posture with its own microbatch and context
count, LaunchPlan carries the profile it priced, and catalog entries
name their architecture so the price holds before download. On the
Spark, Flash Next now runs 256K at -ub 512, priced at 83.1 GiB against
81.5 GiB of measured engine buffers.
llama.cpp's --lazy-mode auto resolves to off on any device without mmap
support, which covers every integrated GPU (b11370, #28160). On a DGX
Spark, Flash Next's 26.8 GiB per-layer embedding table therefore loaded
in full (27.2 GiB resident) while Hermes priced it as left on disk.
--lazy-mode on keeps it out (0.4 GiB resident) at the same decode speed
and within 2% of the prefill speed on GB10.
The preset now passes lazy-mode = on wherever Hermes prices the table as
on disk, so the engine's per-device default never decides residency.
AMD and Intel integrated GPUs, where reading on demand halved prefill,
keep the engine default and are priced with the table loaded.
Flash Next's catalog build moves from UD-Q4_K_XL (103.6 GiB, too big for
a 128 GB unified-memory machine) to UD-IQ4_XS: 91.9 GiB on disk, ~60 GiB
loaded once the per-layer embedding table stays on disk. The model has
no MTP layers; unsloth ships the head as its own file, so the catalog
gains an mtp_head companion. It downloads with the model, is priced
before and after download, and only once it is on disk does the preset
add --spec-type draft-mtp with the head as --model-draft (depth 3, from
the vendor recipe). min_engine is b11370, the first pinned build with
Qwen4Exp MTP.
With the projector and head, Flash Next needs ~74 GiB at its native
256K window, so the derived recommendation now picks it on 96 GB
discrete and on 96 and 128 GB unified memory. The catalog test admits
4-bit IQ4 builds as Q4-class, the pane's MTP flag covers a shipped head,
and the Hugging Face browser stops listing mtp-*.gguf heads as models.
llama.cpp leaves a tensor its architecture marks TENSOR_READ_LAZY in the
file and reads its rows on demand; under the default --lazy-mode auto it
does so for a marked tensor over 4 GiB (b11370: per_layer_token_embd in
qwen4exp and gemma4). Hermes priced those tables as loaded weights, so
Qwen3.8 Flash Next's 26.8 GiB per-layer embedding table counted against
memory it never occupies.
The reader records the candidate tensors per part and applies the
architecture from part 1, so a table in a later split part is found.
profile_from_gguf prices weights without them, and a catalog build
carries lazy_bytes so the same holds before download.
A header cut short raised struct.error, which no caller of
read_gguf_header catches (they skip a model on ValueError or OSError),
so one truncated file escaped the whole preset pass. A short read now
raises ValueError.
The status row sized a side-loaded split by its first file, which can
be a metadata stub of a few MB; it now sums every part on disk. The
split fixture writes model metadata into part 1 only, as gguf-split
does: a published Flash Next split carries 67 keys in part 1 and only
its 3 split.* keys in parts 2-3.
A split model was priced by its first shard alone, so the physics check
and the residency cap admitted giants the card cannot hold: on a 256 GB
box a 175 GB model priced as 6.7 GiB and the router booted with room for
three residents, two of which then co-resided and OOM'd every session.
Sum the tensor table, the host-side embedding duplicate and the per-block
FFN map across every part on disk; architecture metadata still comes from
the first part. The truncated FFN map also disabled spill placement,
which is the lever that would have relieved the pressure.
A part that cannot be parsed is priced out rather than fatal — refusing an
incomplete split to serve remains staged_in's job.
Stopping only orphaned incumbents had also made a replacement boot give up
when the incumbent's owner was alive, adopting a router whose spawn-only
model list lacks the model just staged (e.g. the desktop downloading while
a CLI owns the server). That case now boots its replacement beside the
live owner's server exactly as before; the only behaviour change left is
that orphans are stopped.
_owner_is_dead had tightened "newer incarnation" to "any create_time
mismatch" for records without start_time. On WSL a stepped clock moves
create_time, so a live owner from an older Hermes could read as dead and
have its server stopped. Those records keep the original comparison.
_stop_state_server received _state_endpoint()'s {base_url, api_key} and
read a "pid" it never carries, so stale-preset replacement and the rescan
bounce never stopped an adopted router: the old one kept its VRAM and the
new one fell back to an ephemeral port. Its tests mocked a pid into the
endpoint. It now resolves the process from the identity-guarded state
record and stops it only when the recorded owner is gone; a live owner's
watchdog would respawn its router beside ours, so that server is left as
is and no second one boots.
Only the desktop backend stopped the server on exit; every other process
that booted it on demand (CLI, -z, `hermes model`, gateway) orphaned it
with its VRAM. The process that boots the server now stops it at exit
(atexit; one-shot's os._exit path calls it from its cleanup table).
Processes that merely adopted a server never stop it.
Verified on WSL: `hermes model`, `hermes -z` and interactive `hermes`
leave no llama-server behind after exit.
b11146 ships ubuntu-cuda-13.4-arm64 (+ cudart), so llamacpp-cuda gains
linux-arm64, auto resolves NVIDIA arm64 Linux to CUDA, and the desktop's
localModels flag ships on Linux arm64 too.
Every upstream Linux llama.cpp build (CPU included) links the system
libgomp, which minimal hosts (WSL, containers) lack, and a normal install
never touches the system package manager. Each Linux engine now pins
Ubuntu 24.04's libgomp1 .deb (glibc 2.38, the floor upstream already sets)
and stages libgomp.so.1 beside llama-server, where the $ORIGIN RUNPATH
finds it; the artifact mirror covers the pool URL after 24.04's EOL.
DebPackage's ar+tar unpack becomes pm.package.unpack_deb and reads zstd
data.tar (Ubuntu's default) with the stdlib codec PM's 3.14 runtime has.
Pre-PM engines on Linux carry only llama.cpp's own archives, so adoption
maps their manifests to those and records them as outdated (usable now,
update offered) instead of failing to match.
Verified on an RTX Spark N1X (WSL, Ubuntu 26.04, no system libgomp):
ensure_engine() for cuda, cpu and vulkan through PM's real download/stage/
verify path; ldd resolves libgomp from the entry; llama-server serves
completions (CUDA0 at 445 tok/s). linux-x64 cpu/vulkan staged through the
same path with no NEEDED library missing beyond glibc/libstdc++/OpenSSL 3.
launch_budget read free memory only from nvidia-smi, so a discrete AMD/Intel card launched at its
capacity window regardless of other programs' VRAM, the case that silently pages on Windows. It
now asks the engine's device probe afresh at launch (the polled view stays cached); the driver's
free count includes other processes (live: 16.2 GB held elsewhere read as 8.2 GB free of 26.0).
Docs: auto is a preference with the platform's pinned fallbacks, not an unconditional CUDA pick.
The managed llama-server is one per process and serves every profile it hosts, but the budget
re-derived its engine from whichever profile's config the current thread saw. The on-demand boot
thread and the Local Models job workers were bare threads, so under multiplex a named profile on
Vulkan booted a server sized from the default profile's CPU config (262144-token window instead of
98304, reproduced live by an independent review).
- bootstrap records the engine it boots (cleared on shutdown and on a failed boot); the device
probes price that engine whenever one is up or booting, config only otherwise.
- The on-demand kick and the Local Models job/refresh threads carry the caller's contextvars
(spawn_context_thread), so config=None and the post-download refresh resolve the right profile.
_boot_in_flight gated the on-demand boot on installed_engine() with the
default "auto", not the configured local_runtime.backend. With no GPU
vendor detected, auto resolves to the CPU build only, so a user on
`backend: vulkan` with only the Vulkan engine installed never got a boot
from `hermes chat` ("The local model server isn't running"), while
ensure_local_runtime itself would have booted that exact engine. Found
during the live run for this PR.
Both engine probes called get_config_value("local_runtime.backend", "auto"),
but get_config_value is the `hermes config get` printer with signature
(key, *, as_json, raw): the call raised TypeError, suppress(Exception)
swallowed it, and the probe returned None every time. That made the
discrete Vulkan/HIP budget dead code in production, and the CUDA
--list-devices pool fallback has been dead since it landed (bdb52ce711).
Found by a live run with NVIDIA hidden at the OS level: the device probe
answered, yet probe_budget still returned the RAM-as-unified budget. One
helper now resolves the configured engine via load_config_readonly(), and
the Vulkan test reads a real config.yaml instead of patching the probe out.
The previous commit budgets any Vulkan/HIP device that `llama-server
--list-devices` prints as a discrete card. That output carries no
integrated-vs-discrete verdict, so an iGPU (Intel ADL-N reports 11.8 GB of
a 16 GB machine's shared memory) would be counted twice: once as VRAM and
again as system-RAM spill.
Ask the installed engine's ggml library instead, in a bounded child
process: ggml_backend_dev_type() is GPU for a discrete card and IGPU for
an integrated one (Vulkan reads it from VkPhysicalDeviceType). llama.cpp
places layers on discrete devices whenever one exists and falls back to an
integrated GPU only otherwise, so the budget follows the same rule:
- discrete device -> its own memory, system RAM as spill (uma=False)
- integrated GPU, no answer, CPU/Metal -> RAM-as-unified, unchanged
The probe runs only when no NVIDIA device answered and the configured
engine is Vulkan/HIP, and is cached per engine binary (the hardware
endpoint polls every few seconds). The CUDA --list-devices fallback for the
NVIDIA unified-pool quirk is left as it was on main.
Co-authored-by: Belaquaa <eee_pokkk@mail.ru>
On Linux psutil derives create_time from /proc/stat's btime, which moves
whenever the wall clock is stepped. WSL re-syncs its clock within minutes,
so recorded_process() rejected a live router on exact create_time equality
and every turn failed with "The local model server isn't running."
server.json now also records start_time/owner_start_time from
gateway.status.get_process_start_time() (boot-relative ticks on Linux) and
recovery compares them with start_time_fingerprints_match(). create_time is
still written and is the fallback for records that predate start_time, so an
orphaned router stays adoptable across an update.
A console-less parent (pythonw backend, detached daemons) allocates a visible
console per bare spawn, so Windows startup flashes ~7-8 console windows
(#117781) and the GPU statusbar flashes two more every 5s (#101895). Threads
creationflags=windows_hide_flags() through every hermes_cli spawn site the
desktop reaches: gitlock (tasklist probe + git plumbing), the scheduled-task
PowerShell probe, the updater's _git_run/_no_prompt_git_kwargs/_GIT_TEXT_KW
runners, the safe-directory config probe, and consolidates the statusbar's two
nvidia-smi reads per poll into one cached hidden query. The statusbar also
stops polling while the document is hidden and resumes on visibilitychange
(#120262).
Supersedes #117805, #117833, #102047, #120285 (thanks @JoaoMarcos44, @Finn763,
@monerostar, @szzhoujiarui).
A spilled hybrid got `-ot blk\.\d+\.ffn_.*\.weight=CPU`, which moves every
block's FFN — full-attention blocks included — regardless of how many bytes
actually had to leave the GPU. The GGUF reader now records FFN weight bytes
per block, and spill_overrides picks the fewest recurrent (n_head_kv==0)
blocks whose FFNs cover the decision's spill_bytes, emitting them as an
explicit `blk\.(i|j|...)\.ffn_.*\.weight=CPU` selector. Unknown sizes fall
back to every recurrent block, never a full-attention one.
Based on #120778 by @kvnloo (recurrent-index selector).
Covers the placement half of #113329; the overhead/logits calibration
(RUNTIME_OVERHEAD_BYTES, ub_logits_bytes) is untouched pending measured data.
Refs #113329
llama.cpp permits attention.sliding_window_pattern as a scalar period as
well as a per-layer array; current writers for Gemma-family and other
architectures use the scalar form, which read_gguf_header/profile_from_gguf
silently dropped (isinstance(v, list) is False), sending unlisted
architectures back to the all-full-attention fallback this fix was meant to
avoid.
Add GGUFHeader.sliding_window_pattern_period and expand it per llama.cpp's
set_swa_pattern() semantics, including the small set of architectures
(cohere2moe, modern-bert, smallthinker, laguna) whose loader uses
dense_first=True instead of the default False.
Also add binary GGUF round-trip tests (real read_gguf_header() parsing, not
GGUFHeader built by hand): array pattern with non-bool element types, both
scalar dense_first variants, and a length-mismatched array falling back
safely.
profile_from_gguf() only consulted a hardcoded architecture -> SWA-fraction
table (_SWA_LAYER_FRACTION). Any architecture absent from that table (e.g.
gemma4) fell through to pricing every layer as full attention, wildly
inflating the estimated KV cache and causing the physics check to refuse
models that fit and run fine.
GGUF files that declare a per-layer SWA layout already carry
attention.sliding_window_pattern (plus attention.key_length_swa /
value_length_swa for the SWA layers' dimensions) — gguf.py had no accessor
for any of the three. Add them, and make profile_from_gguf() prefer the
file's own per-layer pattern over the name table, falling back to the
table and then to all-global exactly as before when the file doesn't
declare one.
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule
The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:
- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
names, restored dead definitions) and the three re-export stub modules
(gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
`plugins.allow_deprecated_imports` escape hatch
An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.
hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).
In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.
* chore: retrigger CI (zero-job startup_failure phantom)
* test: drop resolution allowlist rows for the two deleted which() sites
hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
Boot presets and in-session growth priced the context window from card
capacity: total VRAM minus a fixed max(2 GiB, 9%) reserve. That reserve
covers a light desktop only. On an RTX 5090 with 4.5-6.3 GiB held by a
browser and other apps, Qwen3.8 27B booted at 216K, overflowed the card,
and Windows paged part of it to host memory without an error: decode
fell from ~90 to ~24 tok/s.
hardware.launch_budget subtracts what other programs hold now (device
total minus nvidia-smi free, minus our own server's footprint) plus
1 GiB of headroom. fit_to_free_memory narrows a resident plan's window
to that budget, down to the floor and never further: weights are never
moved to the CPU on a live reading, which once pinned a fitting model
to the CPU while the reading still counted the outgoing server.
- Boot: preset generation narrows every window through it.
- Idle: every 30 s with no model holding memory, the presets are
re-planned and the router re-reads them (GET /models?reload=1), so a
later on-demand load gets a window for the current desktop. The file
is never rewritten while a model is loaded, because reload unloads a
loaded model whose flags changed.
- Growth: the next rung must fit beside other programs, counting the
growing model's own memory as free.
Recommendations, quant selection and catalog pricing keep the capacity
budget. Unified-memory machines and failed probes keep today's plan.
The new tests pin the catalog 27B's window from 0 to 8.5 GiB of other
programs. Every boot, idle, growth and residency-cap test records its
probe_budget calls and fails on any made without planning=True, including
calls inside paths that swallow exceptions.
The package-manager switch (#102765) made engine detection read only PM's
store and pinned every llama.cpp backend to b10362. A machine with an
engine under runtimes/llamacpp/b<tag>/<backend>/ reported no engine, so
the local models pane showed one-click setup with models already on disk.
installed_engine() now moves the newest pre-PM install into the store
once. The old manifest's archive digests become the PM identity, the
package verifier runs llama-server --version, and os.rename puts the
directory in place before facts.json records it. A store that already
holds an engine is left alone, a store lock held by another PM operation
defers the move, and an install that fails verification stays where it
is for the rest of the process.
The lock returns to b10964 on all five backends, the default before the
PM switch. With b10362 pinned, the pane offered a downgrade from the
moved engine as an update. Upstream renamed the ROCm archives to 10.0 at
b10767, and the hip assets follow. Only the CUDA build is measured at
b10964.
Models used to live at <profile home>/models (adb2fdbf5c) before the managed
runtime moved them to the machine-scoped <root>/models (43e67d872f). GGUFs
staged under a named profile's old dir silently stopped being served.
adopt_legacy_models() renames them (and their assets/) into the current dirs,
so everything downstream keeps reading one directory: listing, presets,
delete, the --models-dir fallback. It runs at boot, before the "anything
staged?" check, and in the Local Models status route so the pane lists them
while the runtime is off.
os.rename only: instant within a filesystem, while shutil.move would silently
copy tens of GB across devices at session start; a cross-device dir stays put
with a warning. An existing destination name is never replaced.
On Windows, subprocess text=True without an explicit encoding decodes
child output with the ANSI code page (e.g. 'gbk'); non-ASCII bytes then
raise UnicodeDecodeError inside subprocess._readerthread, killing the
Hermes backend before it becomes ready and surfacing as the desktop boot
timeout.
Sweep every hermes_cli text=True subprocess call to encoding='utf-8',
errors='replace', and add an AST-based regression test that fails when a
future text-mode call omits the encoding.
Fixes#55658
utf-8-sig exists to tolerate BOMs that Windows tooling adds to files
users edit. /proc and /sys files are generated by the Linux kernel, never
BOM'd and absent on Windows, so -sig there only muddies the read/write
policy. Switch every literal /proc/ and /sys/ read to utf-8 and teach
the footgun read rule that string literals starting with /proc/ or
/sys/ are exempt (user-edited files keep utf-8-sig).