Compare commits

...
522 Commits
Author SHA1 Message Date
Niko1221 628f74955a release manifest test: a drifting product version is reported when no terminal_version is given 2026-11-09 15:43:28 +01:00
Niko1221 b5bff964a0 release manifest: a Control-only hotfix (terminal_version 0.2.13.1, control_version 0.1.59.1, engines 0.1.59) passes the check; UPDATES.md example lines follow 2026-11-09 15:42:37 +01:00
Niko1221 74f7764e01 Merge main (f33c8c305: CAPABILITIES page, README supporters) into f-0213-arcfix 2026-11-09 15:35:47 +01:00
Niko1221 f33c8c3058 README: Loyal supporters adds kod88vn, seoulworker, jackyini and router0mail (public GitHub sponsors) in all 8 languages 2026-11-09 14:19:12 +01:00
Niko1221 b08a069d62 Control 0.1.59.1 / Terminal 0.2.13.1: version bump for the Control-only (Linux x86_64) fix of the app on an Intel Arc card (app stays 0.2.13, engine packages stay 0.1.59) 2026-11-09 13:36:36 +01:00
Niko1221 1130d824aa App: set up a model on an Intel Arc card (Linux, SYCL engine)
The app's install job ran setup.py with no --backend, so on an Arc PC it
stopped with "no NVIDIA GPU found (nvidia-smi did not answer)" while the
Terminal worked. tools/strata_mcp.py knew only cuda and hip.

- strata_mcp: the hardware report lists an Intel Arc when the PC has no
  usable NVIDIA or AMD card and Control's own engine choice is SYCL;
  recommend() and install_plan() pick sycl, pass --backend sycl, force
  pictures off, send no --drafter, and refuse (plain words) when the model's
  files are not on the computer, as Intel cards do not download models;
  the tool schema enum gains sycl.
- setup.py: --backend sycl in CONTROL mode (the app and the Terminal's
  installer mode) prepares the pack and writes the run config the way
  strata launch does (strata_cli/launch/adopt.py). A checkout keeps the
  from-source sycl_setup.
- strata_control/intel_app.py: the shared card rule and the wording.
- Tests: tests/terminal/test_intel_app_install.py (vendor choice, args,
  NVIDIA/AMD requests unchanged, the setup path, the dispatch) and
  apps/host/tests/arcfix-updated-control.test.mjs (the app runs a
  feed-updated Control for model setup).
2026-11-09 13:33:12 +01:00
Niko1221 37d5c9e845 docs: regenerate CAPABILITIES.md for release v0.2.13 (the version bump updated docs/data/release.json but not the generated page; the Docs workflow's capability-table check failed on it) 2026-11-09 11:14:34 +01:00
Niko1221 d2f0964758 Merge main (9522b5249: f-0213-mmr, make_model_release.py case B) into the 0.2.13 release candidate 2026-11-08 23:44:23 +01:00
Niko1221 9522b52499 Merge f-0213-mmr (e8f2380f3): make_model_release.py case B plans a new-shape Qwen4 engine as its own component (strata-q4-<shape> packages, build-feed --shapes, no Strata engine/Control/app rebuild, entry family qwen4exp_<shape>), with the Mac shape engine in case B (owner signs); full tools/check on Windows and Linux with STRATA_TEST_LLAMA_CPP set: no new failures (p2006's two known base failures; the known flaky test_tuiqa_wizard) 2026-11-08 23:44:15 +01:00
Niko1221 712fec6b42 Version 0.2.13 / engine 0.1.59: Terminal and app 0.2.13, Control and every engine package 0.1.59 (the 0.2.12 bump list, plus the v0.1.59 line in the setup.py version history and docs/data/release.json for v0.2.13) 2026-11-08 23:22:26 +01:00
Niko1221 e8f2380f36 make_model_release: the Mac engine of the shape is part of case B (the owner signs it); no Mac skip 2026-11-08 22:20:42 +01:00
Niko1221 93e0abb845 make_model_release: case B plans the engine of the new shape as its own component (--shape, strata-q4-<shape> packages, build-feed --shapes), the Strata engine, Controls and app are not rebuilt; the entry's family is qwen4exp_<shape>
For cherry-picking onto main after f-0213-p8merge and f-0213-shape (it edits the case-B plan that f-0213-p8merge rewrote).
2026-11-08 22:20:42 +01:00
Niko1221 d5d5b08379 Merge f-0213-shape (61d883315): a new-shape Qwen4 engine ships as its own component strata-q4-<shape> beside the Strata engine, picked by the model (owner 11-08: separate components; wording approved 'A ok, 1 yes, 2 later, 3 yes') - feed member shape_engines, setup/launch/update/Mac paths, strata --geometry checked against the signed manifest at install, older Controls never offer the model; l620 e2e (Flash-Next engine hash unchanged), Windows long-path install, Mac m1002 run 2026-11-08 22:20:12 +01:00
Niko1221 b82a368102 Merge f-0213-p8merge (b5f5e38a4): the Qwen4 rehearsal's fixes - F1-F5 (make_model_release schema, mmproj KeyError, drafter: none honoured, neutral expert profile for another shape with the owner's line, budget for feed qwen4exp sizes), STRATA_CMAKE_EXTRA in the Linux packaging scripts, G14 plan line, G12 release plan, G10/G3 docs, and G2: a real-file feed-model install gate in tools/check (tools/feed_model_install_gate.py; fails if any of F2-F5 is reverted); version bump left for the release candidate 2026-11-08 22:20:06 +01:00
Niko1221 b7bcbcde99 Merge f-0213-fixux (82d3a0345): user screenshots - a data folder on an external drive under /run/media is no longer refused as a system folder (real system folders still are; the doubled sentence is gone); one fit label everywhere (owner 11-08): 'fits' when the model files without the n-gram/PLE/Engram table fit graphics memory plus RAM, else 'may run slower' (the Unsloth sets were measured against RAM only; DeepSeek Needs shows the whole size; no-card PCs and small Macs say 'may run slower'; NVFP4-QAD sized by its 76.8 GB pack) 2026-11-08 22:14:42 +01:00
Niko1221 e3bd5cfdef Merge f-0213-defaults (a04860fad): DFlash2 per-device defaults - Strix Halo and Blackwell cc 12.0 cards with >= 48 GB draft window 6 -> 8 (all sets), conf 0.55 for IQ3_S only on that Blackwell class; end to end through the Terminal's flags, 3 interleaved rounds: Strix UD-Q4_K_XL x1.052 (output differs, deterministic; owner 11-08: ship as is), Strix UD-IQ4_XS-v31 x1.020 (identical), l620 IQ3_S x1.028 (identical); every other card unchanged 2026-11-08 22:09:21 +01:00
Niko1221 61d8833155 tests: the geometry tests that only run where a script is a program (Linux, Mac) no longer clash on a folder and test the refusal line through the start test 2026-11-08 21:31:40 +01:00
Niko1221 9f1550c3cf shape engine: a refused install says the approved line as it is, and the newer-engine line carries only the plain reason (found by the geometry mismatch run) 2026-11-08 21:27:06 +01:00
Niko1221 46ea3947e6 Merge f-0213-g16 (e1da007f8): Strix Halo Terminal launch with no VRAM reserve never started (pictures off or no picture reader) - the engine no longer puts its Windows 2560 MiB reserve floor on an integrated HIP device (d362bc5f0), and setup writes --vram-reserve-mib 700 on a single-card Windows gfx1151 config with no other reserve (e1da007f8); Strix: stale config starts in 77 s, 3 chats 20.5/24.3/23.9 tok/s; CUDA sm_120 binary bit-identical; snapshot changes only the 3 Strix cells 2026-11-08 21:15:45 +01:00
Niko1221 e1da007f82 Setup: write --vram-reserve-mib 700 on a single-card Windows Strix Halo config with no other reserve (G16)
A Strix config with pictures off, or for a model with no picture reader, had no --vram-reserve-mib, and the 0.2.12 engine then applied its Windows floor of
2560 MiB, which on the shared pool only moves memory from the expert cache into the RAM budget's page-locked complement: strata launch of UD-Q4_K_XL never
started (Q4-P8 step A15). Pictures on already write 700 (VISION table), and the app writes its own reserve, so this is one rule for every size: the engine's own
default, written down, on a Windows gfx1151 (uma) config that has no other --vram-reserve-mib. Existing saved configs keep what they have until setup runs again;
the engine commit before this one covers them after `strata update`.

Snapshot: three strix_halo_win cells gain --vram-reserve-mib 700 (setup entry, config and argv): ud_q4_k_xl, ud_iq4_xs_v31, iq3_s. Every other class and set is
byte-identical. tests/snapshot/test_strix_reserve.py pins all three sizes.
2026-11-08 21:14:00 +01:00
Niko1221 d362bc5f00 Engine: no Windows 2560 MiB reserve floor on an integrated (UMA) HIP device (G16)
On Windows HIP, an auto expert cache with no --vram-reserve-mib kept a floor of 2560 MiB free on any card of 15 GiB or more (#1376, made for a 20 GB discrete
card that paged). On a Strix Halo that room is not free: the RAM budget's page-locked complement takes it straight back (cache + complement 71.72 GiB at 2560 and
71.73 GiB at 700 on UD-Q4_K_XL), so the prompt path's own buffers found 0 MiB and the engine stepped its chunk down 16384 ... 1024 for ever; strata launch of a
UD-Q4_K_XL config with no reserve (pictures off, or a model with no picture reader) never started.

The floor now goes through strata::core::wddm_auto_reserve_mib(integrated, total, current, floor): a discrete card of 15 GiB or more still gets 2560, an
integrated GPU (the device property the UMA sizing already reads) keeps the engine's default 700. Discrete cards, Linux and every config that passes
--vram-reserve-mib are unchanged (the block is under _WIN32 + STRATA_USE_HIP and behind !vram_reserve_given). Unit test in uma_cache_test.

CUDA sm_120 sm build (vendored ggml), base vs change from the same path: the binaries are bit-identical (sha256 2b603395...).
Strix (gfx1151, public-0.2.12-style config with no reserve) with the new engine: starts in 77 s and answers 3 chats; the old engine never started it.
2026-11-08 21:13:59 +01:00
Niko1221 90ca808a20 UPDATES.md: the install-time strata --geometry check and the header label 2026-11-08 21:11:22 +01:00
Niko1221 63de72ce17 Mac: a feed model of another shape than the shipped expert profile gets the neutral profile on the Mac path too (the engine refused the shipped one at load) 2026-11-08 21:09:56 +01:00
Niko1221 ab56b84831 Mac: a model of its own shape installs on a Mac too: setup's Mac path fetches the shape engine (shape_engine_fetch, shared with the other platforms), the Mac rows are registered, a one-file model uses its own PLE table and a model whose entry says drafter none gets no draft layer (the approved line) 2026-11-08 20:59:37 +01:00
Niko1221 8920bd493b shape engine: the owner's approved lines (11-08), the update label Model engine, a test that pins them; at install Control checks strata --geometry against the signed manifest's shape and refuses a mismatch (an engine from before the flag is accepted) 2026-11-08 20:56:46 +01:00
Niko1221 febc0226f5 strata --geometry: prints the name of the model shape the kernels are compiled for (the geometry header's kGeometryName; flash_next for the default build). One host line beside --families; no compile flag, no kernel changes. 2026-11-08 20:56:46 +01:00
Niko1221 82d3a03458 NVFP4-QAD is sized by the pack that runs (76.8 GB), not by its 102.2 GB checkpoint minus the table 2026-11-08 20:52:43 +01:00
Niko1221 f16e297e5e Fit label: size is the download minus the lookup table's file; DeepSeek shows its whole size; no card or a small Mac says "may run slower"
Owner's answers of 11-08 evening:
- size = the model's download minus the n-gram / PLE / Engram table's file (28.8 GB for most sets, 54.4 GB for UD-Q5_K_XL / UD-Q6_K_XL / Q8_0, 51.2 GB for NVFP4, the stated
  memory figure for Gyro, the plan's Engram file for DeepSeek), against VRAM + RAM. UD-IQ4_XS and UD-IQ4_XS-v31 are 64.9 GB.
- the Terminal's "Needs" column and the app's version line show that same figure (DeepSeek: the whole size, not the CPU's share).
- no graphics card, or a Mac with too little memory: "may run slower" instead of "too big" / "Doesn't fit"; the app's "Doesn't fit" state and its "Uses SSD N GB" figure are gone.
  DeepSeek's "tight: needs more disk space" and the feed's "not available on this engine" rows are unchanged.
- docs/UNSLOTH_Q4.md names the rule instead of a 48 GB minimum.
2026-11-08 20:52:43 +01:00
Niko1221 a16f3fe39e One fit label for every model: "fits" or "may run slower" (a V100 32 GB with 31 GB of RAM no longer says "too big" for the Unsloth sets)
On a Tesla V100-PCIE-32GB with 31 GB of RAM every Unsloth set said "too big (can still try)" while IQ3_S, with the same 50 GB of experts, said "fits, a bit slower". The
catalog compared an Unsloth set's 48 GB RAM floor with the RAM alone and never counted the card. Not a hardware limit: UD-IQ4_XS is measured on that card.

Owner's rule (11-08): the model's weight files WITHOUT the n-gram / PLE lookup table (the "Needs (RAM + VRAM)" figure) against the graphics memory plus the RAM, nothing
taken off either side: "fits", else "may run slower". strata_control/fit_label.py holds it; the Terminal model menu, `setup.py --check`, the catalog's `on_this_pc` (tools/strata_mcp.py),
the install plan's RAM line, setup's size menus and the app's version list use it. Replaced: "fits, a bit slower", "too big (can still try)", "does not fit", the SSD-GB suffix,
the RAM-floor menu line ("<- needs 48 GB RAM, you have 31") and the install's RAM-floor warning where the set fits. Kept: "(recommended)" / "(installed)", DeepSeek's
"tight: needs more disk space" and "needs one or more graphics cards", a Mac's "too little memory", the feed's "not available on this engine", NVFP4's AMD notes in --check.
2026-11-08 20:52:43 +01:00
Niko1221 a1b9cc89ac A data folder on an external drive is no longer refused as a system folder (Linux)
/run/media/<user>/<drive> (udisks) sat inside /run, which was refused whole, so the AppImage, the Terminal and the AI-tool installer all said "is a system folder" for a
drive the person plugged in. A path strictly inside /run/media is not a system folder now (strata_control/folders.py, tools/strata_mcp.py, the chat project folder in
apps/host/server/chat-files.mjs); whether the drive is theirs is the existing write test. /run, /run/media itself, /run/user, /run/systemd, /usr, /etc, /var/lib and the
rest are still refused, /media/<user> and /mnt stay allowed when writable.

The refusal from setup's data folder said "Nothing was downloaded or changed." twice (the folder words end with it and setup added its own sentence); the first is dropped
for that caller only.
2026-11-08 20:52:43 +01:00
Niko1221 b5f5e38a44 feed model install gate: a stand-in nvidia-smi on a box with no NVIDIA card (setup's step 1), so the Linux CPU runners can run it 2026-11-08 20:50:40 +01:00
Niko1221 6dba262794 shape engine: the lines a person reads name the model's title (Q4 Lite Shape), not the geometry (q4_lite); one switch (shape_engine.USE_TITLE) puts the geometry name back. strata components engine and the JSON keep the shape 2026-11-08 20:41:51 +01:00
Niko1221 a65e14cc05 Leave the engine version bump (0.1.59) out of this branch: the 0.2.13 release candidate bumps every version in one commit; STRATA_CMAKE_EXTRA stays 2026-11-08 20:40:03 +01:00
Niko1221 201ee46b46 UPDATES.md and ENGINE-COMPONENT.md: the shape_engines feed member and package_engine.py --shape 2026-11-08 20:31:11 +01:00
Niko1221 8a9feaedde A new-shape Qwen4 model ships with its own engine component (strata-q4-<shape>), picked by the model, next to the Strata engine
An engine is compiled for one geometry and refuses a GGUF of another, so the engine of a new Qwen4 shape must not replace the Strata engine (Flash-Next users keep theirs).

* feed: member shape_engines (engine entries plus shape); a feed model of family qwen4exp_<shape> runs on that shape's engine; build-feed --shapes refuses a model of a shape with no engine
* packaging: package_engine.py --shape (manifest id strata-q4-lite and the shape in the signed manifest), pack-engine names the archive by the component
* Control: strata_control/shape_engine.py installs it into <components>/shapes/<shape>/engine/ (resumable download, archive and manifest hashes, signature, shape, one test start), the run config says component:strata-q4-lite and engine_component.expand resolves it there; strata update brings an installed shape engine up to date beside the older one; strata components engine reports it
* setup (the app's Download, the Terminal's launch) fetches it when the model is chosen; the Terminal's first step checks it instead of the Strata engine; a shape model's min_engine is not compared with the Strata engine; not offered on a Mac yet
* older Controls ignore the member, read only strata-engine manifests and do not know the family, so they never offer the model

Tests: tests/terminal/test_shape_engine.py, tools/test_setup_shape.py.
2026-11-08 20:18:24 +01:00
Niko1221 a04860fadf DFlash2 defaults: conf 0.55 on the large-VRAM Blackwell class for IQ3_S only (the measured set); window 8 stays for every set there
Per the coordinator: SetDefault gets big_blackwell_conf (set on ("cuda", "iq3_s") = 0.55), applied
with the tuned pack on cc 12.0 with >= 48 GB. UD-IQ4_XS-v31 and UD-Q4_K_XL on that class keep conf
0.35 (they were not measured there); the window of 8 is set-independent in the engine and stays.
Snapshot goldens: nv_resident ud_iq4_xs_v31 and ud_q4_k_xl conf back to 0.35 (spec stays 8);
tests updated.
2026-11-08 20:13:27 +01:00
Niko1221 6ec4d68ab2 DFlash2 per-device defaults (11-08 CONF-SWEEP): Strix Halo window 5 -> 8; Blackwell cc 12.0 with >= 48 GB conf 0.35 -> 0.55 and window 6 -> 8
Strix Halo (gfx1151): engine cap 5 -> 8 and the Terminal writes --spec 8; conf stays 0.35.
Measured (5 interleaved pairs, 5/5 faster): UD-Q4_K_XL +5.3%, UD-IQ4_XS-v31 +2.1%.
UD-Q4_K_XL greedy output differs from the old width's (deterministic run to run); v31 is identical.

Blackwell sm_120 (cc 12.0) with at least 48 GB: cap 6 -> 8, --draft-conf 0.55 (RTX PRO 5000 72 GB,
IQ3_S +2.4%, 5/5 pairs, output identical). The DGX Spark (cc 12.1) and consumer cards under 48 GB keep
conf 0.35 and cap 6. dflash2_auto_cap now takes the compute minor and the card's memory; drafting.py
writes --spec 8 only on these two classes (window_for), so no other card's effective window changes.

draft_cap_test, tests/terminal drafting tests (new test_drafting_card_window.py) and the snapshot
goldens (nv_resident and strix_halo_win only) are updated.
2026-11-08 20:04:02 +01:00
Niko1221 60d0f66de7 feed model install gate: the engine step runs serve.server with the run config 2026-11-08 19:32:34 +01:00
Niko1221 1ecb9de791 UPDATES.md: the feed-model install gate 2026-11-08 19:29:19 +01:00
Niko1221 51b75ea25f tools/test_feed_model_install_realfile.py: the install gate as a test of tools/check (py-tools) 2026-11-08 19:29:09 +01:00
Niko1221 c47e9b9929 tools/feed_model_install_gate.py: a feed model installed from a real synthetic file through setup (release gate G2) 2026-11-08 19:26:39 +01:00
Niko1221 35b776c34d Owner's wording for the two new setup lines; the plan line for a drafter-none feed model (G14); make_model_release plan: right boxes, no app rebuild for case A (G12); tests 2026-11-08 19:23:20 +01:00
Niko1221 132c5809b8 Merge f-q4-p8-fixes: engine 0.1.59 and STRATA_CMAKE_EXTRA in the Linux packaging scripts
# Conflicts:
#	metal/CMakeLists.txt
2026-11-08 19:16:57 +01:00
Niko1221 e6569ae4df Merge f-q4-p8-fixes-a: the Qwen4 rehearsal's setup and feed fixes F1-F5 2026-11-08 19:16:47 +01:00
Niko1221 3289d8280d Merge f-0213-pr5531 (9947e7d6f): community PR #5531 by pjgmobile for #5327 - on Linux the expert arena loads again when free pages, droppable file cache or a reserved hugetlb pool can serve a RAM shortfall (their measure: 1M ctx 47 -> 124 tok/s); review fixes: mlocked file pages are not droppable, stricter hugetlb credit (keeps #4051's refusal); plain warning text, stdout only with --serve; normal starts unchanged (tokens and output identical) 2026-11-08 19:07:24 +01:00
Niko1221 9947e7d6f5 docs/DETAILS.md: the #4051 section no longer says the load never refuses; says when the experts load anyway (#5327) 2026-11-08 19:02:59 +01:00
Niko1221 b12773820f #5327: the owner's wording for the "loads anyway" warning, and the RAM notices on stdout in --serve mode only
The warning now reads "the experts need X GiB of RAM and Y GiB is free, but free memory, file cache and reserved huge pages can hold
them, so they load anyway (same answers). STRATA_ARENA_FILE_CACHE=0 reads them from the file instead."  It and the "not enough RAM for
the expert arena ... reads the experts from the file" notice go to stdout only with --serve (before READY, which the server ignores; a
service log has them).  A plain generate run keeps its token stream clean: stderr only, as before the PR.
2026-11-08 19:02:58 +01:00
Niko1221 458c312c9c settings register: STRATA_ARENA_FILE_CACHE (#5327), which the settings-register check asks for 2026-11-08 17:41:35 +01:00
Niko1221 4890fd9539 #5327 review: the arena shortfall credit leaves out locked file pages, reserved huge pages, a non-2 MiB pool and a pool smaller than the padded mapping
Review of #5531 on top of the contributor's two commits.  The credit read Cached - Shmem - Dirty as droppable, but file pages the
process itself or an engine still exiting has locked (the PLE table's mlock is a mapping of the model file) sit in Cached and the kernel
cannot drop them; MemAvailable leaves them out.  An engine with --ple-io ram and a short arena was told its own locked table could serve
the arena - the shape of #4051's stall.  Now: Mlocked is subtracted; HugePages_Rsvd is subtracted from the free pool; the pool counts only
at the 2 MiB size the arena maps; STRATA_NO_LARGEPAGES (no MAP_HUGETLB try) credits no pool; and the pool must hold the padded mapping
(the pin-by-need stride padding plus the spare blob), not want + blob.  Tests for each.
2026-11-08 17:39:12 +01:00
pjgmobile 41e20da530 #5327: correct the comments - MemAvailable does not credit "only about half" of the page cache (it counts nearly all of it, minus the low watermark); the false alarm this fixes is the reserved hugetlb pool, which MemAvailable does not count at all. The file-cache credit stays as a small secondary margin. Comments only, no code change. 2026-11-08 17:24:35 +01:00
pjgmobile 28bd0185f9 #5327: on Linux an expert-arena shortfall the file cache can serve loads through it again - the 0.1.55 warn-and-proceed (free pages + Cached+Buffers+SReclaimable-Shmem-Dirty cover the need: MemAvailable credits only half the cache), said loudly on stdout as well, because a service setup never sees stderr; the #4846 refusal stays for shortfalls it cannot serve (#4051's anonymous RAM), under a cgroup limit, and on Windows, and STRATA_ARENA_FILE_CACHE=0 turns the credit off; tests 2026-11-08 17:24:35 +01:00
Niko1221 ee1b382e9c feed models: qwen4exp sizes are read in place with a RAM budget (budget True) like the built-in Unsloth sizes; the low-RAM mode wrote a second 77 GB copy of the experts on a 64 GB PC; q4geo_synth --tokenizer-from 2026-11-08 15:44:25 +01:00
Niko1221 88ea8e2a02 setup: a feed model of another shape (layers x experts) than the shipped expert profile gets a neutral profile; the engine refused the shipped one 2026-11-08 15:35:27 +01:00
Niko1221 508e6999ab Merge f-own-1a (451f3e8a6): own-stack phase 1A and cleanup 5.4; the ggml/MMQ sources the engines build are vendored in third_party/strata-ggml (frozen at the pin, manifest and check), default engine builds need no llama.cpp download, the TQ overlay is plain files, CMakeLists.txt is split into cmake/backends/*; every engine unchanged (CUDA/HIP gfx1201/gfx1151/SYCL device code identical, SYCL 22/22 checksums, Metal 104/105 objects with only __TIME__ differing, CUDA tokens 26/26); release scripts still pass -DSTRATA_GGML_DIR (owner go 11-08) 2026-11-08 15:28:57 +01:00
Niko1221 451f3e8a60 tests/terminal/test_engine_manifest: read the CUDA architecture default from cmake/backends/cuda.cmake 2026-11-08 15:28:10 +01:00
Niko1221 03882d09db feed models: drafter none leaves the Flash-Next MTP draft layer out (setup fetched it for every qwen4exp model) 2026-11-08 15:23:11 +01:00
Niko1221 b40d65ea4c setup: a feed model without a picture reader no longer stops install with KeyError 'mmproj' (found installing a feed qwen4exp model from a test feed) 2026-11-08 15:18:18 +01:00
Niko1221 f223047daa make_model_release: write the model's entry in the feed's schema (models/<id>.entry.json) with the values it cannot know as [FILL: ...]; build-feed --models refuses an entry that still has one 2026-11-08 14:57:07 +01:00
Niko1221 eddbf228dc Engine version 0.1.59 (CMake project and Metal host) and STRATA_CMAKE_EXTRA in the Linux packaging build scripts (a new model shape: -DSTRATA_GEOMETRY_FILE) 2026-11-08 14:46:43 +01:00
Niko1221 e66450705e tools/test_gfx906_build_files: read the cmake/*.cmake files the CMakeLists.txt blocks moved to 2026-11-08 14:43:03 +01:00
Niko1221 1115121839 CMake: the K-quant MMQ comments say what the engine does (default on NVIDIA 8.0+ and Linux gfx12) 2026-11-08 14:43:03 +01:00
Niko1221 974c0e3467 tools/check: the docs step also runs check_vendored.py (third_party/strata-ggml manifest) 2026-11-08 14:43:03 +01:00
Niko1221 8b16677015 Split CMakeLists.txt by backend: cmake/backends/{cpu,cuda,hip,sycl,metal}.cmake, strata_ggml.cmake
The 3,100-line CMakeLists.txt is cut into macros, each called where its block
used to be (a macro runs in the caller's scope, so variables, targets, sources,
flags, options and their order are unchanged):
- cmake/strata_ggml.cmake: which ggml (vendored tree or the STRATA_GGML_DIR
  override), the forced GGML_* options of the root project, the MMQ instance
  list and the trellis variants' origin;
- cmake/backends/cpu.cmake: strata_kernels_cpu, the native (ggml-cpu) expert
  rows, the CPU tests, the DeepSeek CPU-lane bench;
- cmake/backends/cuda.cmake: the CUDA toolchain, strata_mmq / strata_mmq_w4a4 /
  strata_kq, the spill guard;
- cmake/backends/hip.cmake: the gfx906 compat toolchain and sources, MMQ on HIP;
- cmake/strata_gpu_engine.cmake: the GPU engine both CUDA and HIP build;
- cmake/backends/{sycl,metal}.cmake: the ggml block of sycl/ and metal/.
sycl/ and metal/ resolve the vendored root with the same macro (normalised
path, so the objects' __FILE__ strings equal the root project's).
2026-11-08 14:43:03 +01:00
Niko1221 19418261e9 Give the quant header Strata's name as a forwarder, nothing else moves
third_party/strata-ggml/strata-quant-common.h includes third_party/ggml/ggml-common.h,
with the provenance and MIT notice. The text stays at the old path: the Metal
runtime reads that file's text under that name and the Mac package ships it as
metal/ggml-common.h, so the runtime lookup, the compiled-in path and the
packaging scripts are untouched (the swap goes with the 1B packaging change).
2026-11-08 14:43:03 +01:00
Niko1221 bc83c088de Fold the trellis MMQ overlay into plain files of the vendored ggml-cuda
The configure-time overlay (cmake/strata_ggml_tq_overlay.cmake) wrote the Gyro
trellis types (TQ2_T, TQK6, TQK7, TQP5-7) into patched copies of ggml-cuda's
headers inside the build tree. They are plain files in
third_party/strata-ggml/ggml/src/ggml-cuda-tq now (patched ggml.h, mmq.cuh,
mmq-load-tiles.cuh, mmq-config-*.cuh; the copies quantize.cu, quantize.cuh,
mmq-vec-dot.cuh; tq.cuh, the tile loader and the six instances), byte for byte
the overlay's output and each listed in UPSTREAM.md; check_vendored.py checks
that the patched files only add lines. The pristine ggml-cuda files stay
untouched, so every regular instance compiles exactly as before. The default
build uses the folder like the overlay dir (first on strata_mmq's include path);
the SHA-256 guard of the MMQ_TAB scheduler is not needed for it. A
-DSTRATA_GGML_DIR build still gets the overlay, reading tq.cuh and the tile
loader from the vendored folder. third_party/ggml-tq is gone.
2026-11-08 14:43:03 +01:00
Niko1221 067b4dda5f Vendor the ggml sources the engines build into third_party/strata-ggml
ggml-base, ggml-cpu (x86, Arm) and the ggml-cuda MMQ headers, quantizer and
instances the engines compile, frozen at llama.cpp 3cf03257 with the MIT
licence, UPSTREAM.md (file list with sha256) and tools/check_vendored.py.
The layout is llama.cpp's, so CMake takes ${root}/ggml from the vendored tree
by default (root, sycl, metal, split) and no longer fetches llama.cpp.
-DSTRATA_GGML_DIR=<checkout> stays as an override. Only ggml/CMakeLists.txt and
ggml/src/CMakeLists.txt differ from upstream (trimmed to ggml-base + CPU).
2026-11-08 14:43:03 +01:00
Niko1221 fdb376f398 qwen4_intake: the Metal and SYCL down-expert lists follow main's f-q4-kquant (Metal adds Q3_K/Q4_K/Q5_K/Q6_K/IQ3_S, SYCL Q5_K/Q6_K); test_metal_lists and test_sycl_lists read the sources again 2026-11-08 13:36:58 +01:00
Niko1221 5c2e80ff43 settings register: the line numbers of two STRATA_ reads that the feed-models merge moved (setup.py, strata_cli/launch/screens.py); --check clean 2026-11-08 11:52:17 +01:00
Niko1221 0fdfa40b47 Merge f-q4-feedmodels (d4b72bc90): the signed feed's optional models member (strata_control/feed.py, feed_models.py), setup tables and prescan, tools/strata_mcp.py catalog, packaging/feed_tool.py --models/model-files/verify-feed, the app family menu (Qwen4 prep) 2026-11-08 11:50:30 +01:00
Niko1221 97a50dc24c Merge f-q4-intake (37e91bfdb): tools/qwen4_intake.py, the tokenizer refuses an unknown pre-tokenizer, the server's tool-call parser follows the chat template (serve/toolformat.py), packaging/make_model_release.py dry run (Qwen4 prep) 2026-11-08 11:50:24 +01:00
Niko1221 7f46672a48 Merge f-q4-metal (ed1f58d27): the Mac (Metal) engine follows the compiled geometry (q4_lite/q4_mid/q4_big kernel parity 40/40, gate 2 like CUDA), plus the PLE row-buffer fix in src/program/generate.cpp (sized by the compiled hidden size, not 2560; it overran 24 KB at hidden 4096 and crashed Metal serve); default build unchanged (CUDA SASS 3573/3573 and 160/160 host objects, HIP gfx1201/gfx1151 string offsets only, SYCL 129/129 objects and 22/22 checksums, Metal per-token functions identical, UD-Q4_K_XL 10/10 tokens, CUDA IQ3_S 26/26 tokens) (Qwen4 prep) 2026-11-08 10:13:39 +01:00
Niko1221 db137d917a Merge f-q4-sycl (41717f29c): the Intel Arc (SYCL) engine follows the compiled geometry, so q4_lite/q4_mid/q4_big build; default build unchanged (CUDA SASS 3573/3573, HIP gfx1201 3920/3920, gfx1151 string offsets only, Metal 85/85 objects, SYCL 22/22 checksums); A750 gate 2 q4_lite 0.00021 vs floor 0.00017; one conflict with f-clean-6-0 in sycl/src/prefill/prefill.cpp resolved as tested by Q4-SYMERGE (Qwen4 prep) 2026-11-08 09:42:22 +01:00
Niko1221 0bf2efe331 Merge f-clean-6-0 (646826312): Intel Arc output is repeatable run to run; skinny bf16 GEMMs (outputs <= 64) run on a fixed-order kernel instead of oneMKL, which is not bit-repeatable for them (STRATA_SYCL_BF16_DET=0 restores oneMKL); A750 50/50 runs identical with the gate recipe, prefill 0.995-1.004; owner OK'd it as the default 11-08 (cleanup 6.0) 2026-11-08 09:09:52 +01:00
Niko1221 ed1f58d275 settings register: regenerated after the rebase onto 09eb82791 (STRATA_METAL_MSL_DUMP, research; source line numbers) 2026-11-08 09:05:16 +01:00
Niko1221 dcaf4755c1 ple_direct_rows_per_s: the probe's output buffer is PLE_N_HEADS x PLE_HEAD_DIM floats a token, not 2560 (heap overflow of 24 KB at hidden 4096; on Metal it corrupted Apple's shader-cache objects and the first window of strata --serve at q4_big died in MTLCompilerFSCache::getElementImpl) 2026-11-08 09:04:39 +01:00
Niko1221 a875d702cf Metal window_probe: --session N carves the engine's session state (session_init) before the window comes up 2026-11-08 09:04:39 +01:00
Niko1221 7893d4972e Metal tests: msl_dump keeps --touch, --map and the pipelines after all libraries (restored after the revert) 2026-11-08 09:04:39 +01:00
Niko1221 97a1df1b30 Revert "Metal: -DSTRATA_METAL_PRELUDE_SALT adds one comment line to the kernel prelude (separate compile-cache keys for a build)"
This reverts commit 362a8c5ebe4b0d25419973859f52b575536da474.
2026-11-08 09:04:39 +01:00
Niko1221 c3bdd42dc2 Metal: window_probe runs the decode window without the engine (init, windows, the gate-2 requests)
metal/probe/window_probe.cpp links the engine's window (init, the prompt chain, decode windows, logits) and runs it on its own: a few windows,
or with --requests / --logits-out the gate-2 requests (prompt + forced tokens) with the logits of every last position written out for
g3kl_mac.py --from-dump. It is how the q4_big shape was judged when `strata --serve` crashed in Apple's shader cache before its first window.
2026-11-08 09:04:39 +01:00
Niko1221 496a5382e4 Metal: -DSTRATA_METAL_PRELUDE_SALT adds one comment line to the kernel prelude (separate compile-cache keys for a build) 2026-11-08 09:04:39 +01:00
Niko1221 906aa63c75 Metal: a crash also prints the open descriptors, their limit and the process's memory; msl_dump --wrap / --map hold a model like the engine 2026-11-08 09:04:39 +01:00
Niko1221 021e07be72 Metal tests: msl_dump --pipes also builds a few pipelines of the core library 2026-11-08 09:04:39 +01:00
Niko1221 a78b2d6278 Metal tests: geo_check prints the shape a build is compiled for and asserts a default build's constants are Flash-Next's 2026-11-08 09:04:39 +01:00
Niko1221 a479b17a15 Metal: the selection budget follows idx_top_k; msl_text_check preprocesses with clang; geo_pins counts the pins
qsa.metal QBUDGET and the partials buffer of the 32-cell chunks take the indexer's top_k from the prelude (GEO_BUD) instead of 2048 + 3.
tools/msl_text_check.py runs the C preprocessor (the Metal toolchain component is not on the Macs) and the files are LF.
tools/geo_pins.py counts the literals that are Flash-Next's shape per file at two revisions.
2026-11-08 09:04:39 +01:00
Niko1221 943e67ea98 Metal tests: the parity programs take their shape from the compiled geometry
gr, gdn, gdn_prompt, qsa, qsa_prompt, route, prompt, expert and moe_mm parity (and gemv_parity --nin) read hidden, expert width, hc_lr,
head counts, GDN heads, top-k and expert count from engine/geo.h instead of 2560 / 640 / 320 / 24 / 48 / 10 / 512, and compile their
kernel libraries with geo::prelude(); qsa_parity dispatches the qsa_prep groups the model has (QH + 2 QKV + IH + 1, was a fixed 33).
Built with -DSTRATA_GEOMETRY_FILE=<header> they are the kernel parity of that shape; without it they are what they were.
2026-11-08 09:04:39 +01:00
Niko1221 ab5a09b6ff Metal engine: sizes and loop bounds come from kCompiledGeometry; Window::init checks all 19 fields
window.cpp, prompt_chain.inc and stream.cpp took hidden 2560, width 640, 48 layers, l % 4, 16/48 GDN heads, 10240/6144/12288/16480 channel
counts, 24 query heads, 2051 selection cells, 33 attention chunks and the per-layer state sizes from literals; they now take them from
engine/geo.h (the numbers they replaced for a Flash-Next build). Window::init asks strata::geometry_unsupported (the 19 fields the CUDA guard
compares, plus rms_eps) instead of 6 fields, and also refuses a file whose experts per token differ from the compiled number; the refusal
message is today's. The MTP draft layer and PromptGemm (SSD streaming) host buffers follow the same names; their kernels and DFlash2 are not
part of this change.
2026-11-08 09:04:39 +01:00
Niko1221 e172375b25 Metal kernels: query and kv heads, experts per token, hc norm and up widths come from the compiled geometry through the #define prelude
engine/geo.h turns kCompiledGeometry into the host constants and into geo::prelude(), the #define lines the kernel libraries start with
(GEO_QH, GEO_QKV, GEO_TOPK, GEO_NJ, GEO_GRU_NU); a define is written only where this build's value differs from Flash-Next's, so a
Flash-Next build hands the compiler the prelude it always did. qsa.metal (QH, QKV, and the indexer-key threadgroup that was r == 32),
moe.metal / router.metal / glue.metal / prompt.metal (top-k 10 in route_tok, route_tok_p, moe_combine_sh2) and gr.metal (gr_norm2's
10 elements a thread = n_embd / 256, gr_up's prefetch of 3 units a lane = ceil(hc_lr / 128)) read them with Flash-Next's number as the
default. Preprocessed, every library of a Flash-Next build is token-identical to before (tools/msl_text_check.py, 12 of 12 libraries,
plain, K-quant and F16 preludes); 8 of the 12 are byte-identical.
2026-11-08 09:04:39 +01:00
Niko1221 0ff18ef93d Metal: the engine's kernel libraries in one header, STRATA_METAL_MSL_DUMP, msl_dump and msl_text_check
msl_libs.h lists every library the engine compiles (files, order, prelude); window.cpp, prompt_chain.inc and dflash2.inc build
them from it, and tests/msl_dump.cpp compiles the same libraries without starting an engine. STRATA_METAL_MSL_DUMP=<dir> makes
mtl_rt.mm write the exact text handed to the compiler (NNN.msl + index.txt); tools/msl_text_check.py compares two dumps (raw bytes,
then the preprocessed text, then the AIR). No kernel and no engine behaviour changes.
2026-11-08 09:04:39 +01:00
Niko1221 269f33859b fused_gr: the dynamic-shared-memory opt-in of the single-token down kernel is set once per device, not once per process (a layer split runs it on several cards); doc_pages lists the .md files git knows about, so an ignored build tree's llama.cpp copy is not link-checked 2026-11-08 08:46:50 +01:00
Niko1221 2b36e8a027 Merge f-clean-3 (321794636): docs and history (cleanup step 3, items 3.0-3.10); release manifest and capability table with checks, history/ with pointers, link check, tools/ split, bench index; owner OK'd the wording 11-08 2026-11-08 08:35:12 +01:00
Niko1221 321794636c READMEs: DeepSeek on Mac reads pictures too (dropped 'text only'); the 7 translations now name Strix Halo and the Mac like the English page; MAC.md and MODELS.md say the same in plain words 2026-11-08 08:34:44 +01:00
Niko1221 41717f29c1 qsa_real_shapes() takes the compiled geometry; gdn_parity's prefill cases and native_grouped_parity follow the build's shape
qsa_real_shapes() (24 / 2 / 256 / 64, indexer 4 x 128) is the shape every QSA parity program builds from; it now reads kCompiledGeometry (the same values for the default build), so the
q4_* builds' kv_stream / qsa_prompt_attn / snapshot checks run at their own head counts instead of being refused.  gdn_parity's prefill cases used 48 value heads; native_grouped_parity's
compiled-shape loop no longer asks the K-quant checker for an IQ4_NL down.
2026-11-08 07:00:42 +01:00
Niko1221 09eb827914 Merge f-q4-hipkg2 (da7b1d022): the AMD prompt-expert kernels (F16 K-quant, native-format fused IQ, Q2 blobs) follow the compiled geometry; default build unchanged (HIP gfx1201 182/182 and gfx1151 194/194 touched functions, CUDA SASS 3573/3573 and 160/160 host objects, SYCL 129/129 objects and 22/22 checksums, Metal 85/85 objects); HIP q4_lite gate 2 0.000183 vs floor 0.000173 (Qwen4 prep) 2026-11-08 06:15:05 +01:00
Niko1221 8893ba5eec SYCL dq_dense_parity: the dense GEMV checks also run at n_in 2048 and 3072 (the hidden widths of q4_lite and q4_mid) 2026-11-08 05:41:56 +01:00
Niko1221 b72b451529 SYCL parity programs: gr, gr_multi, cvec, gdn, s2_expert_grouped, native_expert and native_grouped take the compiled shape
gr_parity, gr_multi_parity, cvec_parity used 2560 / 4 / 320, gdn_parity 128 / 16 / 48 and 2560, s2_expert_grouped_parity 2560 x 640 (and a blob-size assert for that shape only): they now read
kCompiledGeometry, so the q4_lite / q4_mid / q4_big builds check their own kernels (the values for the default build are the ones they had).  native_expert_parity reads
Q4KQ_PARITY_EMBD / Q4KQ_PARITY_FF as the CUDA tree's test does; native_grouped_parity adds the build's hidden width to the K-quant down widths and, for a build that is not
Flash-Next's, the UD mix's pairs at the build's own expert shape.
2026-11-08 05:35:50 +01:00
Niko1221 e1e9d6ff95 SYCL prompt path: split_q, gate_attn and kv_append take the query head count from the geometry
The three kernels had 24 query heads (and the launcher's ld16 default and block counts T x 24 x 256) written in. The CUDA prompt path takes the fused rms_rope_rows route and keeps these only for
STRATA_QSA_FUSE_NR=0; this port has no fused route, so at 16 or 32 heads they would have mixed the heads up silently.
2026-11-08 05:32:21 +01:00
Niko1221 30fabc8ac9 SYCL engine: top-k, layer count and the PLE probe follow the geometry; STRATA_LOGITS_DUMP for the gate-2 judge
verify.cpp and remote_experts.cpp refused k != 10 (the verifier would not start at top-8); generate.cpp had two layer-index checks against 48 and a 2560-float buffer for the
PLE probe.  verify.cpp also gets src/core/verify.cpp's STRATA_LOGITS_DUMP debug tap (off unless set), which the logits judge reads.
2026-11-08 05:14:34 +01:00
Niko1221 c598662834 SYCL prompt path: prefill.cpp, kernels.dp.cpp and the Xe GDN recurrence take the model shape from the compiled geometry
prefill.cpp: N, HC, LR, K, NE, C, ZV, HV from kCompiledGeometry and the loose literals (1280 / 640 / 2560 expert and hidden widths, 12288 / 512 / 128 attention and
indexer widths, the 24 and 2 head counts) became the named widths src/prefill/prefill.cpp uses (kFF, kFF2, kEmbd, kQW, kQFull, kKvW, kIdx*).
kernels.dp.cpp: N, HC, LR, S, HK, HV, C from the geometry; the router, combine and SwiGLU kernels use KU (top-k) and FFC (expert width).  The Q2_0 blob dequantisers keep
Flash-Next's layout (Q2_0 packs only).
gdn_xe.hpp: the Xe GDN recurrence (default on for Alchemist) had its own copy of 128 / 16 / 48 / 10240 with no assert: it now reads the geometry.
moe_fused / moe_fused_iq: the fused expert kernels are Flash-Next's 2560 x 640 and now decline any other compiled shape instead of mis-running.
For the default geometry every value is the one it replaces.
2026-11-08 05:14:34 +01:00
Niko1221 b9de2cdb8c SYCL PLE post-ops, PLE key, native MoE combine and S2 grouped experts take their widths from the compiled geometry
native_ple_postops (N, H), ple (the native key's geometry assert now says what the kernel needs), native_moe (the combine's rank count KU = n_expert_used, 10 for
Flash-Next; the vec4 fast path is taken when k == KU) and s2_expert_grouped (H, FF) follow core/geometry.hpp, as src/kernels/cuda does since the geometry work.
2026-11-08 05:14:11 +01:00
Niko1221 3b64e076c8 SYCL attention: the decode and the three prompt kernels take the heads per KV head from the compiled geometry
G = n_head / n_head_kv replaces the literal 12 in qsa_decode_attn, qsa_prompt_attn, qsa_prompt_attn_xmx and qsa_prompt_attn_esimd (the static_asserts that stopped
every shape but Flash-Next).  The kernels already index by G (the decode kernel's float4 probability rows and 16-lane score reduce need G % 4 == 0 and G <= 16; the prompt
kernels pad the query rows to 16), so 8 (q4_lite) and 16 (q4_mid, q4_big) compile as they are.  The decode kernel's q8_v3 / pre-sm_75 variants that the CUDA side
generalised do not exist in this port.
2026-11-08 05:14:10 +01:00
Niko1221 5306fee502 SYCL fused_gr: the hyper-connection read takes n_embd, hc and hc_lr from the compiled geometry
N, HC and LR come from core/geometry.hpp (the static_assert that stopped q4_lite / q4_mid / q4_big is gone); the plain read's tile is N (N / 2 for the staged tile), the
literal 2560 / 1280 template arguments and tile choices became N and N / 2, as in src/kernels/cuda/fused_gr.cu.  The single-token down kernel's hidden row (D floats)
no longer fits 48 KB at hidden 3072 and up: fused_gr_read then takes the multi-token read of one token (bitwise the same read, the default path already).
The Q8_0 read fixes of the CUDA side (chunk width from n_embd, up-row steps from hc_lr) have no counterpart here: this file has no Q8_0 read.
sycl/tools/fixups.py: the kernel-name fixup also accepts the tile spelled N or (N / 2), so a re-migration of the CUDA source keeps working.
2026-11-08 05:14:04 +01:00
Niko1221 da7b1d022e Strata Q2 blob fused experts (moe_fused.cu CUDA and gfx11 kernels) and their parity test follow the compiled geometry
Weight-row counts, K stages, code/scale row bytes, the scale staging copies, the H row stride, the down store stride and
the launch grids come from kMoeEmbd / kMoeFF.  The CUDA kernel stages a row's gate/up scales in 16-byte copies, so the hidden
width must be a multiple of 512 (static_assert).
2026-11-08 04:48:52 +01:00
Niko1221 2351f2cccb Strata Q2 blob (prompt path): blob_dequant and strata_q2_kernel take the row lengths, scale counts and offsets from the compiled geometry
Gate/up codes [2 F][E/4 B], down codes [E][F/4 B], gate/up scales [2 F][E/64], down scales [E][F/64] (Flash-Next: 1280 x 640 B,
2560 x 160 B, 1280 x 40, 2560 x 10).  Launch sizes follow.
2026-11-08 04:48:52 +01:00
Niko1221 6e4ef31e47 q4geo_synth: --exact-bf16 / --exact-parts and --router-scale
The pack's --compat-bf16 turns the F32 router, the shared-expert gate, the HC inject and the Q8_0 HC projections into BF16. With --exact-bf16 those tensors are written BF16-exact (F32 values rounded, Q8_0 scale 2^-12), so the conversion is lossless and llama.cpp and the engine read the same weights.
--router-scale multiplies the router weights.
2026-11-08 04:48:52 +01:00
Niko1221 78c27655c8 prefill_fused_iq_test follows the compiled geometry and gets --edge; q4geo_synth gets the AMD fused recipes and --small-ple
The HIP/CUDA fused-expert parity test took 2560 / 640 / top 10 / 512 experts as constants; they are now kCompiledGeometry's,
so the test built with -DSTRATA_GEOMETRY=<name> checks the kernels at that shape.  --edge runs the tail and tile-boundary
token counts (33, 1, 130, 517, 640) for every pair.
tools/q4geo_synth.py: recipes fused-kq (Q4_K/Q5_K gate/up with Q5_1/Q8_0 down), fused-iq (IQ3_S gate/up, IQ4_NL down) and
mix-kq (one file with every K-quant expert path of the AMD prompt kernels) at any expert width, and --small-ple (PLE head
sizes of 16 small primes: a 6 MB table instead of 28 GB, for hosts that cannot write the sparse table; the moduli are read
from the file).
2026-11-08 04:48:52 +01:00
Niko1221 b273c8006e HIP/CUDA native-format prompt experts (moe_fused_iq.cu): K of gate/up and down, the weight-row counts, the grids and the down store stride follow the compiled geometry
GU_ROWS_K, D_ROWS_K and the 1280 / 2560 weight rows of the int8, WMMA and F16 kernels come from kMoeEmbd / kMoeFF /
kMoeGuRows, with a static_assert that a product's rows are whole work items.  Flash-Next's device code (gfx1151)
is unchanged.
2026-11-08 04:48:52 +01:00
Niko1221 3d209733d2 HIP F16 K-quant prompt experts: the work-item counts, activation strides and down stride follow the compiled geometry
moe_fused.hpp gets kMoeEmbd, kMoeFF and kMoeGuRows from kCompiledGeometry (2560, 640, 1280 for Flash-Next).
moe_f16_common.cuh and moe_f16_kq.cu take GU_NFB, D_NFB, the activation and H row strides, the stage counts and the down
store stride from them, with a static_assert on the divisibility the tiles need.  The default geometry's device code
(gfx1151) is unchanged.
2026-11-08 04:48:52 +01:00
Niko1221 4dad59ab37 Merge f-q4-geometry (fc1c4e429): the model shape is fixed at build time (-DSTRATA_GEOMETRY / -DSTRATA_GEOMETRY_FILE); the default Flash-Next build is unchanged (CUDA SASS 3573/3573, HIP gfx1201 3920/3920, gfx1151 path-string offsets only, SYCL 22/22 checksums, Metal 10/10 tokens, CUDA 130/130 tokens); Qwen4 test shapes q4_lite/q4_mid/q4_big pass logits gate 2 on CUDA (Qwen4 prep) 2026-11-08 04:48:39 +01:00
Niko1221 fc1c4e4295 fused_gr: the Q8_0 up read walks hc_lr in as many 128-value steps as it needs
The up-projection rows of the STRATA_HC_Q8 read were loaded in a fixed 3 steps of 128 values, which covers hc_lr up to 384. At hc_lr 512 (q4_big) columns 384-511 of every row were dropped (KL 0.0059 against llama.cpp, 15 of 144 positions over the rule; 0.00028 and 3 positions after the fix, the same as without HC_Q8). The step count is now (hc_lr + 127) / 128: 3 for Flash-Next's 320, unchanged.
2026-11-08 04:36:17 +01:00
Niko1221 d10a8cd7e1 fused_gr: the Q8_0 hyper-connection read takes its chunk width from n_embd
The STRATA_HC_Q8 read (the default on gfx12 and gfx1151 HIP) split each stream into chunks of a fixed 640 columns. At n_embd 2048 (q4_lite), 3072 (q4_mid) and 4096 (q4_big) a stream is not a whole number of chunks: 12 chunks of 640 cover 7680 of q4_lite's 8192 columns, so every hyper-connection read missed 6% of its input (3% error in the mixed activation, then wrong logits: KL 0.027 against llama.cpp, 67 of 144 positions over the rule).
The chunk is now the largest multiple of 128 up to 1024 that divides n_embd: 640 for Flash-Next (unchanged), 1024 for 2048, 3072 and 4096 (at most 16 chunks, the scratch's room). Static asserts for n_embd % chunk and hc_lr % 32.
2026-11-08 04:36:17 +01:00
Niko1221 646826312a SYCL: debug-only hash dumps to find where two runs of the same prompt first differ (all off unless set)
STRATA_PF_HASH_FILE: per row hashes of the first GDN layer's stages in the prompt path. STRATA_LAYER_HASH_FILE: per layer hashes of the payload the host sees for the CPU experts and of their output. STRATA_STATE_HASH_FILE / STRATA_STATE_DUMP: hashes (and a raw dump of the first four layers) of the GDN states and filled int8 KV before a window. They found the oneMKL bf16 GEMM repeatability problem fixed in the previous commit.
2026-11-08 04:10:03 +01:00
Niko1221 0f3ae296bf SYCL: the prompt path's skinny bf16 GEMMs run on a fixed-order kernel, because oneMKL's result changes from launch to launch for them
On the Arc A750 the same prompt gave different tokens on different runs. The first thing that differs is the ssm_alpha / ssm_beta projection (48 outputs) of the first GDN layer: oneMKL's bf16 GEMM returns a different result on every launch for M=48,T=86,K=2560 and M=4,T=86,K=10240 (the hyper-connection inject), also at T=1..8 for M<=64 and at T<128-160 for those M and K. The GDN state then differs in every layer, the first decode window's logits differ by 0.1-0.7, and a near-tie flips a token.

Gemm::bf16 now runs outputs <= 64 on a 16-lane sub-group kernel with a fixed accumulation order (repeatable; 3-36x faster than oneMKL on the shapes tried, standalone; max abs error 1.6e-5 on sums of scale 90). STRATA_SYCL_BF16_DET=0 gives the oneMKL call back, =2 uses the kernel for every output count. Registered as a rollback switch.
2026-11-08 04:09:45 +01:00
Niko1221 cb73b620a2 host: ending a child that never started no longer signals the caller's own process group on macOS
On macOS (Node 24.12) child.kill() on a child whose spawn failed (no pid, the 'error' event not yet delivered) sends the signal to pid 0, which is the
caller's whole process group. folder-dialog.test.mjs's Linux case let the real portalFolder spawn a missing gdbus and end it, so on a Mac the SIGTERM
reached the test runner and every file after it was cancelled ('Promise resolution is still pending'). Linux and Windows do nothing for such a child.

- process-tree.endTree only calls child.kill() when the child has a pid; a new test runs a never-started child through endTree/endTreeThenKill in a
  group of its own and checks the caller survives (fails on the Mac without the fix, passes with it).
- folder-dialog.test.mjs: the Linux cases pass portal: async () => null like external-env.test.mjs does, so no real gdbus is started.
- load-pictures, packaged-control and the native fixture build their temp folders under the resolved temp path (/private/var on a Mac), as chat-files
  already does; the host reports resolved paths.

m1001 (M4 Mini, macOS 27.2, Node 24.12.0): npm test went from killed after 2 s (722 of 1632 run, 172 cancelled) to 1632 tests, 1522 pass, 0 fail, 110 skipped.
The time-skip tests (timeskip-real, time-skip, timeskip-fork, timeskip-compare-server, tune-and-timeskip-routes, process-tree) passed 20 of 20 runs
before the change and 20 of 20 after.
2026-11-08 01:41:16 +01:00
Niko1221 61632296f3 tests/terminal: the wizard size test waits for the layout to settle instead of a fixed 0.3 s
test_the_three_choices_and_the_name_are_on_screen_at_every_size paused 0.3 s after each resize and then checked the screen once; under load the
redraw came later and the check failed. It now polls (up to 30 s) until the same checks hold, then runs them. The checks are unchanged.
30 runs under CPU load on Windows (8 burners, 2 jobs of 15) and 30 on Linux p1006 (24 burners on 12 threads): 0 failures.
2026-11-08 01:18:28 +01:00
Niko1221 1631a9fde8 Merge f-clean-7 (045843071): the app loads screens, languages, Prism and Time-skip on first use; main chunk 3,123,528 -> 755,418 B, start screen 248.5 -> 143.4 ms (p2006 Chrome, median of 10); nothing users see changes (cleanup step 7) 2026-11-08 00:59:48 +01:00
Niko1221 0458430719 app: load the Time-skip panel and its progress card when a chat has a Time-skip run
Both are drawn only in a chat that has a Time-skip run, so their code is its own file, fetched then (and a few seconds after start when idle, so the first card is not waiting on it). They draw nothing until the code is here: no new text.
A chat with a sample Time-skip run: the card, the opened panel and the comparison are pixel-identical to before.
Main JS file 783,660 -> 755,420 bytes.
2026-11-08 00:40:54 +01:00
Niko1221 204a503d05 app: load the code-colouring grammars after the first screen
Prism and its 15 grammars (src/lib/syntax.ts) are their own file. A code block shows its text at once and is coloured when the file is here: the page fetches it a few seconds after start when idle, and a code block that appears sooner fetches it itself.
The language name of a code fence (code-language.ts) stays in the main file. Screens with code blocks are pixel-identical 0.4 s and 6 s after load.
Main JS file 844,062 -> 783,660 bytes.
2026-11-08 00:34:59 +01:00
Niko1221 c9e9f133ef app: load a language's strings when that language is used, not all nine at start
English stays in the main file (the English text is the key of every string). Each of the other eight languages is its own file (src/lib/catalog-<lang>.ts: the same 14 locale files merged in the same order as before).
The saved language is fetched while the rest of the code loads and the first screen is drawn once it is in; picking another language in Settings or on the first screen switches everything together once its file is here.
The 17 tests that read translations right after setTranslationLanguage now load the catalogs first (loadCatalog); no test expectation changed.
Main JS file 2,736,232 -> 844,062 bytes (gzip 875 -> 278 kB). Screens are pixel-identical in all nine languages apart from the preview's live telemetry.
2026-11-08 00:22:38 +01:00
Niko1221 38ab10e03e qsa_decode_attn: the q8_v3 value loop compiles for any heads-per-KV-head G
The Flash-Next branch (G == 12) sat in an if constexpr inside a non-template kernel, so its
12-element initialiser was compiled for every G and a build for another geometry stopped with
'too many initializer values'. It moves into a template helper (fold_cell_v3<G>); the
Flash-Next expressions are unchanged.
2026-11-08 00:08:08 +01:00
Niko1221 03dd20f82b app: load the screens after Chat, and the Settings and set-up dialogs, on first use
Models, Tuning, Serving, Monitor, Tasks, Skills, Settings and the first-run set-up load as their own files the first time they open; Chat stays in the first file.
The two dialogs open once their code is here (same first frame and focus as before); a routed screen keeps the old one on screen until the new one is ready (no new text).
Main JS file 3,123,528 -> 2,736,232 bytes; nothing else changes.
2026-11-08 00:02:20 +01:00
Niko1221 d3f696fe4e Tests: the df2ux launch-plan fixtures say NVIDIA too, not whatever Intel Arc the box has (2 failures on p1006) 2026-11-07 23:56:25 +01:00
Niko1221 3d26434f21 tools/: 27 untested, undocumented experiment scripts move to research/tools (3 folders, a README each saying what and when); two machine-local DFlash2 analysis scripts move to history/tools; documented, tested and packaged tools stay; tools/dflash2 files a training branch changes are left 2026-11-07 23:37:51 +01:00
Niko1221 ff6cd56cb3 root: chat.py and highlight.py move to tools/ (docs updated); the user entry points (setup, START-HERE, UPDATE, strata.py, BENCH.bat and bench.sh, which docs and setup name) stay 2026-11-07 23:37:51 +01:00
Niko1221 242e8f333e bench/results: an index page (one row per result folder, built by tools/bench_results_index.py and checked in tools/check docs); the results stay in place 2026-11-07 23:37:51 +01:00
Niko1221 b3f696ecad link check: tools/check_links.py for the current pages (a docs step in tools/check, a docs.yml workflow, a test), a baseline report for the archived pages, and the one broken anchor it found 2026-11-07 23:37:51 +01:00
Niko1221 8c743a697d history/: the finished UI audit-fix campaign, the dated app design documents, the Mac-first rebuild (docs/work) and its acceptance-matrix generator, each moved with a pointer left at the old path; open requirements copied to apps/host/docs/BACKLOG.md; host module map and contracts kept in apps/host/docs/README.md 2026-11-07 23:37:50 +01:00
Niko1221 de9c0448b2 docs: public speed and quality claims that cited private lab files now say they are internal measurements whose run logs are not published 2026-11-07 23:37:50 +01:00
Niko1221 bcee5f92c4 apps/host: remove the broken sync:kit command (its source folder is not in the repository); the checked-in kit styles and sprite are the source; a test keeps the two components.css copies equal 2026-11-07 23:37:50 +01:00
Niko1221 2baea39521 apps/host README: a developer start that works from a fresh clone (Node 24.12, npm ci, build, preview:ui, test, typecheck; preview, source host and packaged Control) 2026-11-07 23:37:50 +01:00
Niko1221 f19907ba77 capability table: one data file and generator for what each model family runs on per hardware, read against the 0.2.12 packages; AI_SETUP, AI_REFERENCE and MEDIA_KIT take their summaries from it (DeepSeek runs on a Mac) 2026-11-07 23:37:50 +01:00
Niko1221 e40597a066 release manifest: docs/data/release.json, a generator and a check for version lines and installer names (docs said 0.2.5 and 0.2.9; the release is 0.2.12 / 0.1.58) 2026-11-07 23:37:50 +01:00
Niko1221 fd5b3c4559 docs: Intel Arc source build needs oneAPI 2026.1 (2025.3 does not build the SYCL engine) 2026-11-07 23:37:50 +01:00
Niko1221 78f5417195 qsa_decode_attn: the q8_v3 kernel keeps Flash-Next's original probability loop 2026-11-07 23:25:52 +01:00
Niko1221 48f17ff35e fold_cell and gr_up_fast keep Flash-Next's original expressions (device code unchanged for the default shape) 2026-11-07 23:11:46 +01:00
Niko1221 e709a5ff44 native_dense_parity: also n_in 512, 1024, 2048, 3072 2026-11-07 22:59:54 +01:00
Niko1221 4e50c30a59 native_expert_parity: expert widths from Q4KQ_PARITY_EMBD / Q4KQ_PARITY_FF
The synthetic check was fixed at 2560 x 640.  Setting the two variables runs
the same GPU and CPU checks at other widths (512 and 1024 for the K-quant
downs a UD file carries when the width divides by 256).  Default unchanged.
2026-11-07 22:59:23 +01:00
Niko1221 fad95e2611 prefill: shared-expert and interleaved SwiGLU kernels use the geometry's expert width (were 640); STRATA_DBG_NAN also checks the shared expert, its gate and the weights; synth tool --set 2026-11-07 22:36:04 +01:00
Niko1221 741310713c setup golden: STRATA_HC_Q8 is no longer written into the config of Unsloth UD files on CUDA (the engine turns it on from the GGUF); five places, nothing else changes 2026-11-07 22:03:28 +01:00
Niko1221 b4e9ac7c29 fused_gr: the single-token down kernel takes its hidden row from dynamic shared memory when it does not fit 48 KB 2026-11-07 21:57:06 +01:00
Niko1221 c5d3f55dfe prefill: routed-expert count follows the geometry in the combine, route and peer kernels 2026-11-07 21:52:27 +01:00
Niko1221 f637d759b9 fused_gr down tile and verify/remote-expert top-k follow the geometry 2026-11-07 21:34:06 +01:00
Niko1221 6f379c8e7c generate.cpp: string literals of the PLE checks 2026-11-07 21:29:14 +01:00
Niko1221 caebc48411 generate.cpp: PLE constants declared at function scope, string literals fixed 2026-11-07 21:24:17 +01:00
Niko1221 f3e99e58d5 geometry: attention G, fused_gr tiles and fast up for hc_lr 256-512, top-k from the geometry in native_moe, native activation buffers follow n_embd/n_ff, PLE hash constants read from the model file 2026-11-07 21:21:16 +01:00
Niko1221 99807e8da4 Merge f-q4-kquant (d19ab7302): Metal and SYCL take Q5_K/Q6_K (Metal also Q3_K/Q4_K/IQ4_XS/IQ3_S) down experts for UD files at expert widths 512/1024; additive, today's sets unchanged (Qwen4 prep) 2026-11-07 21:21:00 +01:00
Niko1221 ddbfa1d5c8 Merge f-clean-1c (3c32937a2): launcher speed settings become engine defaults (parallel stack, GDN stack, gfx12 chunked scan, HC_Q8 on UD files, Metal fp16 for resident IQ3_S/Q2_0, hipBLASLt table on gfx1151 only); row 8 held (cleanup step 1c) 2026-11-07 21:13:39 +01:00
Niko1221 75822f3e9e generate.cpp: the expert layout is loaded for the compiled geometry's layer count 2026-11-07 20:59:19 +01:00
Niko1221 edee9ea256 q4geo_synth: expert tensors first in the file, as the expert pack needs 2026-11-07 20:54:32 +01:00
Niko1221 20f7f1ad0b q4geo_synth: a vocabulary and one merge the tokenizer step accepts 2026-11-07 20:49:22 +01:00
Niko1221 499d971666 q4geo_synth: the PLE tensors and keys (sparse table); ngram.hpp: the PLE head width follows the hidden size 2026-11-07 20:44:07 +01:00
Niko1221 d19ab73020 Metal: instantiate the K-quant / IQ down kernels only for a model that has them
Measured on the M5 Max (cold start, same parity binaries, kernel sources of
main against the branch): the extra instantiations cost moe_mm.metal's library
about +0.5 s on every start (5.7 -> 6.2 s) and moe.metal's about +0.65 s on a
cold shader cache.  A Flash-Next set should not pay for types it never uses.

The new instantiations (DN_SET IQ4_XS / IQ3_S, DNK_SET, MM_DN2_KQ and the other
down tiles, MM3_DN, MM6_DN1) sit under #ifdef STRATA_KQ_DOWN.  window.cpp
kq_down_prelude defines it when any layer's ffn_down_exps is Q3_K, Q4_K, Q5_K,
Q6_K, IQ3_S or IQ4_XS, at the three compile sites of moe.metal / moe_mm.metal
(decode window, prompt GEMM setup, prompt chain).  The parity tests do the same
from the down type.  For today's sets the kernel text is what it was.
2026-11-07 20:08:00 +01:00
Niko1221 6a71879979 Merge f-clean-2 (c94ef2375): drafting and plan test fixtures no longer read the box's real Intel GPU (9 failures on the Arc box) 2026-11-07 19:31:23 +01:00
Niko1221 3c32937a2b snapshot: re-made goldens after the launcher copies moved to the engine (nine cells lose STRATA_GDN_TC and STRATA_GDN_CONVL2 in every launch cell, and STRATA_HC_Q8 in the UD cells; nothing else changes) 2026-11-07 18:45:38 +01:00
Niko1221 d1121b8931 launcher: remove the copies of the settings the engine owns now (parallel stack, GDN stack, Q8_0 hyper-connection read, Metal fp16 windows, the packaged hipBLASLt table path); a config-level hipblaslt_table true/false reaches the engine as STRATA_HIPBLASLT_TABLE on/off; MMQ_KQ stays (row 8 is held) 2026-11-07 18:45:38 +01:00
Niko1221 88e6634390 Revert "engine: on AMD that is not gfx12, a UD-Q5_K_XL / UD-Q6_K_XL style file (most expert gate/up tensors Q5_K or Q6_K) runs the K-quant MMQ prompt kernels by default (STRATA_MMQ_KQ=1; was setup's unsloth_default_env; owner yes 11-07); STRATA_MMQ_KQ=0 turns it off"
This reverts commit ffb94c578c.
2026-11-07 18:40:47 +01:00
Niko1221 055274c882 Merge main b0fc432ed (step 4, step 2, 1A) into f-clean-1c
# Conflicts:
#	data/settings-register.json
2026-11-07 18:40:24 +01:00
Niko1221 2bc8c6692a Metal: K-quant, IQ4_XS and IQ3_S down experts (decode window and prompt GEMMs)
A UD file at an expert width divisible by 256 carries Q5_K / Q6_K down
experts (Qwen3.6 UD-Q4_K_XL: 38 and 3 layers); the Metal expert paths took
Q5_1 / Q5_0 / Q8_0 / IQ4_NL / Q2_0 downs only, and d_qk listed IQ4_XS with no
kernel.  Additive: new kernel instances and one new kernel template.

- moe.metal: moe_down_k (dn_body_k, XD<F>) for Q3_K / Q4_K / Q5_K / Q6_K -
  their decoders read the codes in place and need prescaled x and per-16 sums;
  the down's h is the raw f32 rows moe_gu wrote, so the kernel forms them from
  the raw row (exact power-of-two scalings, as xprep).  Same host names
  (moe_down_<type>_<L>_<R>_<S>), same arguments.  DN_SET for IQ4_XS, IQ3_S.
- moe_mm.metal: W4<Q3_K>; mm_down3 (MM3_DN) and mm_down6 (MM6_DN1) for the
  six types, mm_down2 tiles for the v2 (M4) path.
- iq_host.cpp d_qk: 21, 11, 12, 13, 14 (n_ff % 256).  prompt_chain.inc dn_ok
  and window.cpp dn2_tile: the new types.
- tests: expert_parity and moe_mm_parity take --ff / --embd and Q3_K.
2026-11-07 18:39:43 +01:00
Niko1221 3a8254d046 native_expert_parity: expert widths from Q4KQ_PARITY_EMBD / Q4KQ_PARITY_FF
The synthetic check was fixed at 2560 x 640.  Setting the two variables runs
the same GPU and CPU checks at other widths (512 and 1024 for the K-quant
downs a UD file carries when the width divides by 256).  Default unchanged.
2026-11-07 18:39:43 +01:00
Niko1221 cd14b976af SYCL: Q5_K (and Q6_K) down experts in native_expert_grouped
STRATA_D_FMTS had no K-quant: a UD file at an expert width divisible by 256
(Qwen3.6's UD-Q4_K_XL, 2048 x 512, Q5_K / Q6_K downs) was refused by
native_expert_supported on Intel Arc, as the CUDA list has not been since it
took Q5_K (Q6_K with STRATA_Q6K_EXPERTS).  The lane kernels are the gate/up's
(Fmt / KqSplit of 13 and 14); no kernel code changed, two list entries.

native_grouped_parity: --kqdown (K-quant gate/up x Q5_K/Q6_K down at hidden
2048/2560 x width 512/1024: split vs per-entry bitwise, GPU vs CPU reference),
also run by default; --hash (output checksums of the grouped call for every
pair of today's sets, to compare a build before and after a kernel change).
2026-11-07 18:39:43 +01:00
Niko1221 c94ef2375a Tests: the plan and adopt fixtures say NVIDIA, not whatever Intel Arc the box has (9 failures on p1006) 2026-11-07 18:32:52 +01:00
Niko1221 5e1ea3ff6e tools/q4geo_synth.py: synthetic qwen4exp GGUFs at any shape in the type mix of an Unsloth UD file, and the geometry header of a shape or a real file; routing trace layer bound from the geometry 2026-11-07 18:16:50 +01:00
Niko1221 4041af8e6c prefill, fused_gr, expert kernels: the shape constants are read from kCompiledGeometry (values unchanged for Flash-Next) 2026-11-07 18:08:12 +01:00
Niko1221 af7759b69e geometry: the compiled shape is a header chosen at configure time (STRATA_GEOMETRY / STRATA_GEOMETRY_FILE), with the constraints every shape must meet in one place 2026-11-07 18:04:24 +01:00
Niko1221 b0fc432ed9 Merge commit 'b8d604d28' into f-clean-1a 2026-11-07 17:25:04 +01:00
Niko1221 1300170273 Snapshot setup: setup's own helpers read WIN / EXE / VEXE of the simulated class, not of the host (the R9700 Linux cells) 2026-11-07 17:25:03 +01:00
Niko1221 b8d604d287 Merge f-clean-2 (e8c3acd2d): checks we can trust - CMake CPU preset and x86 link fix, tools/check, test portability, CI workflows (not pushed), bench timeout fix (cleanup step 2) 2026-11-07 17:22:06 +01:00
Niko1221 e8c3acd2d1 bench_standard: a socket TimeoutError is reported as timeout, like the timer (Windows reported connection_TimeoutError when the socket fired first) 2026-11-07 17:19:43 +01:00
Niko1221 391f7ce8dc tools/check: a module-level skip shows its reason 2026-11-07 16:40:41 +01:00
Niko1221 e2acc3a950 Snapshot setup: the simulated class decides the engine file suffix; source-text test follows the moved setup lines
The setup record took ".exe" from the host the test ran on (found by the Linux run: 12 Linux-class Qwen cells); ProbeLib now answers EXE / VEXE / engine_exe per class,
so only those 12 setup entries change (launch and app entries untouched).  test_dsv41_vision_reserve reads setup.py and strata_control/setup_config.py.
2026-11-07 16:35:26 +01:00
Niko1221 e62f3c1ffd Retire STRATA_DF2_GATE and STRATA_DF2_ROW_MS: the calibrated DFlash2 gate (cleanup 4.5 batch D)
Removes AdaptiveGate, df2_gate_on() and df2_gate_row_ms() (window_rule.hpp), their three
sites in generate.cpp and their block in window_rule_test.cpp. The opt-in gate measured
2.7-4.9% slower; with it off (the default) the window rule is unchanged. The register
marks both retired in 0.2.13.
2026-11-07 16:28:46 +01:00
Niko1221 64fac3177c Retire STRATA_GR_Q32_PL: the pipelined HC up-read arm (cleanup 4.5 batch C)
Removes the PL=true template arm of gr_up_q8_q32_kernel (fused_gr.cu) and the three
launch branches behind STRATA_GR_Q32_PL (HIP gfx12 only; no gain over STRATA_GR_Q32_G=2).
The default instantiation is unchanged. The register marks it retired in 0.2.13.
2026-11-07 16:28:46 +01:00
Niko1221 41b87a4bd2 Retire STRATA_METAL_ROUTE, STRATA_METAL_PARTIAL, STRATA_METAL_WPF_MIB: Metal decode variants (cleanup 4.5 batch A)
Removes the window.cpp branches of the fused router, the partial GDN commit and the
weight-prefetch dispatch, and the wpf kernel (gr.metal). The router_route kernel stays:
metal/tests/token_chain.cpp (its default) and metal/tests/fuse_parity.cpp still use it.
docs/MAC.md loses the row that described ROUTE and PARTIAL. The register marks the
three retired in 0.2.13.
2026-11-07 16:28:46 +01:00
Niko1221 dff2c91145 Retire STRATA_MMVQ_PF and STRATA_MMVQ_SG: SYCL mmvq tuning (cleanup 4.5 batch B)
Removes the aligned weight-load prefetch kernel of the wide Q6_K mmvq and the
SIMD16 sub-group variants of the decode mmvq (sycl/src/kernels/cuda/native_mmvq.dp.cpp).
Both measured no gain in the engine (PF=2/3/4/6 slower everywhere; no SG value beats
SIMD32 beyond noise). Default settings run the same kernels as before. The register
marks both retired in 0.2.13 and the line numbers of the register are rescanned.
2026-11-07 16:28:46 +01:00
Niko1221 cf0161bfb3 Give the two standalone drivers optional build targets (cleanup 4.4)
src/dsv41/sampler_stat_main.cu -> target ds4_sampler_stat (CUDA, inside the
STRATA_BUILD_DSV41 block, EXCLUDE_FROM_ALL): the DeepSeek sampler statistics
harness that the Metal engine and `strata-dsv41 --sampler-test` compile from
the same sampler_stat.inc.
src/split/tools/selftest_main.cpp -> target strata-split-selftest (HIP,
inside the split engine's CMake, EXCLUDE_FROM_ALL): the split engine's kernel
self-test on one card, with no model. Its header cited a build script in a
private test folder; it now names the target.
Both are built by name only: nothing in the default build changes.
2026-11-07 16:28:46 +01:00
Niko1221 5d60cf13f3 settings_register --check: a retired entry (retired_in set) is not an orphan
The test already allowed it; the command line check listed it as 'in the register, not read by the engine'.
2026-11-07 16:28:41 +01:00
Niko1221 506f65781e Remove unused imports reported by Ruff F401 (cleanup 4.3)
60 files, 94 names: strata_control, strata_cli, serve, integrations/harness,
metal/setup_mac.py, packaging/make_local_feed.py, sycl/tools/fixups.py and the
tools/ scripts. Left alone: test files and tests/, bench/results, __init__.py
re-exports, apps/host/audit-fixes (moving in 3.5), tools/dflash2/final_tables.py
(its exec() uses the namespace), tools/nvfp4_verify_gguf.py (torch) and the
platform import in strata_control/doctor.py (tests patch doctor.platform).
2026-11-07 16:28:41 +01:00
Niko1221 7b52ab055c Tests: the not-preallocated check is the full size; tools/dflash2/test_port.py skips without its lab files 2026-11-07 16:12:41 +01:00
Niko1221 0ea3b73145 tools/check leaves CI alone; the Git Bash launch tests set their own CI/STRATA_NO_PROMPT; the optional suite needs gguf as well as torch 2026-11-07 15:05:01 +01:00
Niko1221 af9f44e522 metal: a resident IQ3_S or Q2_0 pack (found from the GGUF file name) runs the fp16 decode windows by default (STRATA_METAL_F16 unset; was mac_fit.engine_env; owner yes 11-07); STRATA_METAL_F16=0 turns it off, =1 turns it on for any pack 2026-11-07 15:00:01 +01:00
Niko1221 ffb94c578c engine: on AMD that is not gfx12, a UD-Q5_K_XL / UD-Q6_K_XL style file (most expert gate/up tensors Q5_K or Q6_K) runs the K-quant MMQ prompt kernels by default (STRATA_MMQ_KQ=1; was setup's unsloth_default_env; owner yes 11-07); STRATA_MMQ_KQ=0 turns it off 2026-11-07 14:55:49 +01:00
Niko1221 567c46873f engine: on CUDA a GGUF that carries the hyper-connection projections as Q8_0 (Unsloth UD-* files) reads them as Q8_0 by default (STRATA_HC_Q8=1; was setup's unsloth_default_env; owner yes 11-07); STRATA_HC_Q8=0 turns it off 2026-11-07 14:55:06 +01:00
Niko1221 0a50eda160 engine: on Linux with every card gfx12 the chunked GDN scan is on from the first chunk (STRATA_GDN_CHUNKED=1 by default; was the launcher's GDN_STACK_GFX12; owner yes 11-07); STRATA_GDN_CHUNKED=0 turns it off 2026-11-07 14:54:00 +01:00
Niko1221 6858c919ca engine: STRATA_GDN_TC=1 and STRATA_GDN_CONVL2=2 are the Linux CUDA engine's own defaults (was the launcher's GDN_STACK_CUDA; owner yes 11-07); =0 turns each off 2026-11-07 14:53:32 +01:00
Niko1221 6bb7e7a2a3 Move the unused reserve proposal out of stage_reserve.hpp (cleanup 4.2)
proposed_window_mib / proposed_reserve_mib and their two constants go to
research/stage-reserve-proposal/ with a README and their own checks; the
production header keeps reserve_mib / room_bytes, and stage_reserve_test keeps
its real-arithmetic checks unchanged.
2026-11-07 14:17:30 +01:00
Niko1221 0029379234 Remove the three permanently excluded SYCL #if 0 blocks (cleanup 4.1)
iq_kernels.dp.cpp: the 997-line AMD-tuned S26/S27 grouped-expert block
(canonical code: src/kernels/cuda/iq_kernels.cu).
native_qsa_score.dp.cpp: the 38-line inline-PTX load_a/load_b/mma block
(canonical: src/kernels/cuda/native_qsa_score.cu; the port scores with the
scalar path).
generate.cpp: the 28-line #542 CUDA-runtime mismatch warning (canonical:
src/program/generate.cpp).

Each leaves a one-line comment. sycl/tools/fixups.py gets entry 14, which
removes the same regions after a re-migration (idempotent, depth-aware), and
the comments that named sycl/migrate.sh now name sycl/tools/migrate.sh.
2026-11-07 14:17:30 +01:00
Niko1221 456b07cbe7 backends.yml: SYCL job on oneAPI 2026.1 from Intel's apt repo (the engine does not compile with 2025.3.2) 2026-11-07 14:12:13 +01:00
Niko1221 343b8eae6c hip prefill: the engine takes the hipBLASLt table beside it by itself on gfx1151 only (the owner's 2026-10-19 rule); another card needs STRATA_HIPBLASLT_TABLE=on; the launcher's off words are read the same way 2026-11-07 14:11:34 +01:00
Niko1221 c9abd38a7f engine: the exact parallel-request stack is the Linux CUDA engine's own default with 2 or more batch slots (was the launcher's PARALLEL_STACK_ENV); each switch keeps =0, a value you set is kept 2026-11-07 14:09:51 +01:00
Niko1221 1015f5515d Merge f-clean-step0 (01f9c62a0): the config snapshot tool and its golden files (cleanup step 0) 2026-11-07 14:06:11 +01:00
Niko1221 cb012aa5df Tests: a user named 'user' in the redaction test; tools/trellis scripts are not collected by pytest; tools/check not-run text 2026-11-07 13:54:23 +01:00
Niko1221 96b45a8415 Tests: setup_unsloth pins the cards' speeds; host tests name the reason they skip
tools/test_setup_unsloth.py: setup.card_speeds asks the CUDA driver of the machine that runs the test, so which fake card was Main depended on the box (4 failures on a box with no NVIDIA driver); the fixture says card 0 is the faster.
apps/host/tests: seven tests that skipped with 'true' now skip with a string that names the OS or the variable they need.
tools/check: a skip with no reason is listed with the test's name.
2026-11-07 13:44:49 +01:00
Niko1221 7ce44a1fd3 tools/check: --shard I/N for machines with short job limits 2026-11-07 13:32:14 +01:00
Niko1221 118e20757c Tests: fixtures stop reading the machine they run on (Windows, Linux, a Mac's /var)
Tests and fixtures only, no production code:
- markdown-crlf: the long text goes to the render child in a file, not argv (Windows cuts a command line at 32 KB).
- component-updates: the packaged-update test writes the program file under the name of the platform it runs on (strata-desktop.exe only on Windows).
- connector-setup: the shared-download test pins the installer's look for an installed cloudflared to an empty folder and waits for the request with a deadline; it hung for ever on a box that has cloudflared in PATH.
- chat-files: folders are made under the canonical temp path (a Mac's /var/folders is a refused system folder, /private/var is not).
- test_tester_readme: the version is read from Cargo.toml, bash is found by full path and given C:/ paths (Windows runs the WSL launcher for a bare 'bash').
- test_docker_entrypoint: the never-overwritten case sets ALLOW_DOWNLOAD=1 (the guard for older model files stops the start otherwise), plus a test of the guard.
- test_setup_download: the whole-file GET that Python before 3.13 sends after the HEAD redirect is not a piece; the not-preallocated bound is the full size.
- Windows: split engine and DeepSeek fit fixtures say Linux and NVIDIA (Runner.intel_chosen, sys.platform, _on_windows) instead of reading this PC; setup tests no longer download the engine; path flavour in mac/pictures; utf-8 for a help text; the sh stand-in demangler is skipped on Windows with a reason; update.lock cleanup; private files under STRATA_HOME; a Fake process with kill().
2026-11-07 13:31:45 +01:00
Niko1221 d8db7ced36 The four owner-approved strings (11-07), word for word
1. strata launch --help: "dflash2 (downloads its drafter once, 0.3 to 1.2 GB depending on the model; the size is shown before the download)"; the docs row: "...and downloads its drafter once (0.34 GB for the models that have a tuned drafter, 0.52 GB on Intel Arc, else 1.2 GB)".
2. The Arc launch line ends "No model files were downloaded." (was "Nothing was downloaded.").
3. After "Drafting: off (no drafter is downloaded yet)", only when a recent miss mark exists and the choice is DFlash2 or Auto on a tuned set: "Run strata drafting fetch to get the DFlash2 drafter now."
   (drafting.fetch_hint, printed by the server's start; the miss mark and its 12 h window move to drafting.py, drafting_fetch keeps its names).
4. A bare strata drafting fetch with several DFlash2-capable models set up: "Several models are set up here. Say which one: strata drafting fetch --model <name>, or --config <file>."
   (exit 2, nothing downloaded; before, it fetched the stock drafter).
2026-11-07 13:30:48 +01:00
Niko1221 a37d0a57ef One decider: the app host's speed-switch table and Drafting families are generated from strata_control; the rules run shared vectors
strata_control/app_policy.py writes apps/host/server/policy-generated.mjs (the four app speed switches with their words, defaults and environment entries, from
speed_switches.py; the Drafting families and the model-name rule, from drafting.py).  native-settings.mjs imports it instead of carrying its own copy
(byte-identical values, checked against the old table).  tests/terminal/test_app_policy.py fails when the committed file or the vectors are out of date; the host test
runs speedSwitchApplies / resolveDrafter (host and screen) over policy-vectors.json, which the Python writes from its own answers (240 switch cases, 12 names, 7 families).
GPU roles already run shared vectors (tests/data/gpu_roles_vectors.json); the request-slot default stays the app's own (recorded in tests/snapshot/README.md).
No default changes.
2026-11-07 13:30:44 +01:00
Niko1221 93a1e18bce The app's plain Load follows Auto everywhere: it fetches whatever Control's Auto plan says is needed (standard DFlash2 drafter included)
fetchAutoDrafter and the plain-Load step gate on the plan's `needed` (the Auto decision's drafter is not on this computer) instead of `tuned_needed`, so the
stock-drafter cases (Intel Arc, UD-Q4_K_XL on NVIDIA 72 GB / 12 GB and the Spark) get the drafter the Terminal's Auto uses.  The decision stays Control's.

Snapshot: the app_plain goldens are re-made (18 cells changed against the stored main goldens: the 12 tuned cells the df2ux commit fixed and the 6 stock-drafter cells
this one fixes) and test_entry_points_agree.py pins app_plain == launch and app_models == launch + `drafting: auto` in all 32 cells apart from the allow-list in
tests/snapshot/allowlist.py.  With DFlash2 the NVIDIA 72 GB plain Load writes no `parallel` and no --batch-mtp (request slots: recorded in the README).
2026-11-07 13:30:38 +01:00
Niko1221 b6769b7f9a GitHub Actions: fast checks, config snapshot and backend compile-only workflows
check.yml: tools/check steps as one job each on Linux and Windows (a macOS job that reports and does not block until its first green run), path filters that include the shared contracts and packaging/.
snapshot.yml: the config snapshot on Linux and Windows.
backends.yml: CUDA 12.9 and 13.0, HIP (ROCm 7.2) and SYCL (oneAPI 2025.3) compile-only in the vendors' containers, Metal strata and strata_ds4 on a macOS runner; llama.cpp at the commit pinned in setup.py, ccache caches. The existing workflows are unchanged.
2026-11-07 13:25:01 +01:00
Niko1221 b6f585164c tools/check: one command for the fast suites, with an explicit not-run for a missing tool
python tools/check runs the CPU CMake preset (configure, build, CTest), the Python suites (tests/, serve/, tools/test_*.py, and the torch-only tools folders when torch is installed), the host's typecheck, test and build, and the config snapshot. Each step has its own timeout, runs with temp files, build tree and logs in a scratch folder, and the table lists passed / failed / skipped / not run with the reason for every skip and every not-run step. Exit 1 on a failure or a timeout; --strict also fails on a step that could not run.

requirements-dev.txt pins pytest and textual apart from the runtime requirements. apps/host/scripts/test.mjs gives node --test a per-test timeout (STRATA_TEST_TIMEOUT_MS, default 10 minutes) and passes extra arguments (a reporter) through.
2026-11-07 13:24:50 +01:00
Niko1221 8feca419ff setup seam: config_for / dsv41_config_for in strata_control, and the setup entry point in the config snapshot
The lines of setup.py main() between the model arguments and write_setup_config() (Qwen / Unsloth) and the same stretch of install_dsv41() (DeepSeek)
move, unchanged, to strata_control/setup_config.py.  setup.py builds the inputs and calls them; the helpers and constants still live in setup.py and are
reached through `lib`, so a caller can answer the machine probes.  The snapshot drives them for NVIDIA, Spark, Strix Halo and R9700 (Intel Arc and Mac
setup are other scripts and are recorded as not driven).  Existing launch / app goldens are untouched; only the setup entries are new.
2026-11-07 13:09:53 +01:00
Niko1221 e396ce821f CMake: CPU check preset, tests on by what they need, x86 test programs link without native experts
- CMakePresets.json: cpu-check (no GPU, no network), cpu-check-native, cuda-compile and hip-compile (the release flags, no tests).
- STRATA_BUILD_TESTS defaults on for x86 when the test sources are there (it looked for tests/CMakeLists.txt, which does not exist); the release scripts say OFF themselves.
- With STRATA_NATIVE_EXPERTS=OFF on x86 the kernels library now carries q2_avx1.cpp, which expert_layout.cpp calls: 7 programs on Linux and 12 on MSVC failed to link with undefined *_avx1. No change with it ON.
- expert_parity and pool_test need pack/full/experts.bin (a model file): a stated skip when it is not there, the same test when it is.
- A configure line says which kinds of test were registered; the stale phase-1 header and the 'Linux + CUDA only' split note are corrected.
2026-11-07 13:09:13 +01:00
Niko1221 6ea300dde5 Settings register: every STRATA_* setting the engine reads, with a scanner, a test and a draft settings page
tools/settings_register.py scans src, include, metal/src and sycl/src for the 1,009 STRATA_* names read through getenv and the env helpers
and writes data/settings-register.json: backends, read sites, parse kind, code default where it can be read off the call, the engine's
own arch default tables, what the Terminal, the app and the server set, doc mentions, first and last commit and the first release. The
status, owner, note, retire_after and retired_in fields are hand-kept and survive regeneration.

tools/test_settings_register.py fails when the engine reads a name that is not in the register, when the register lists a name no code
reads (unless it is retired with a version), and when an entry has no valid status or a retire-candidate without a file:line citation.

docs/SETTINGS.md is generated from the register (opt-in and rollback entries only) and is a draft: new visible wording, for review.
No product code is changed.
2026-11-07 13:09:12 +01:00
Niko1221 daf035a8c1 Merge f-clean-step0 (config snapshot test) 2026-11-07 12:51:09 +01:00
Niko1221 be32c7f3fb Merge branch 'f-clean-step0' into f-clean-1a 2026-11-07 12:50:52 +01:00
Niko1221 01f9c62a0a tests/snapshot: golden files of main 146df3c46 (identical when made on Windows and Linux) 2026-11-07 12:37:56 +01:00
Niko1221 affa7d96e7 tests/snapshot: the simulated machine decides the Linux-only stack switches; state folder name normalized 2026-11-07 12:31:42 +01:00
Niko1221 4a5a6ece70 tests/snapshot: drop provisional goldens 2026-11-07 11:09:30 +01:00
Niko1221 c10b746970 tests/snapshot: config snapshot tool (driver, matrix, app driver, goldens pending) 2026-11-07 11:08:56 +01:00
Niko1221 146df3c468 setup.py run(): the app's installer also runs the bundled Mac preparation tools in metal/tools (metal_repack.py, stream_experts.py); a fresh Mac Terminal or app install of UD-Q4_K_XL or UD-IQ4_XS-v31 stopped with "only runs its bundled preparation tools" since af1ffd913. Test: they run, and -m, another interpreter and other folders are still refused 2026-11-07 01:29:36 +01:00
Niko1221 9b02c62ab5 DFlash2 tuned drafter: plan and size follow the drafter Auto really fetches; every app load path fetches it
- drafting plan --model (+ tuned, drafter_gb, tuned_needed, file); mtp or nothing needed says so; launch --plan sees a model not installed yet and a stock pack from 0.2.11
- drafting fetch --throttle (12 h rule, silent when the feed is not there); a bare fetch uses the one installed model's run config
- app: plain load, restart of stopped models and tuning run fetch the tuned drafter when Auto resolves to it; install plan passes the model; Drafting hint number from the plan
- launch plan text shows 0.34 / 0.52 GB, not 0.3 / 0.5
2026-11-07 01:09:42 +01:00
Niko1221 9780a8c27d Merge main (54828c7ef): four sponsors in Loyal supporters, all 8 READMEs 2026-11-06 21:53:35 +01:00
Niko1221 311926416a Merge f-0212-tools (ea765a571): tool-call fixes in serve/ and the native gateway, with their tests and docs 2026-11-06 21:53:34 +01:00
Niko1221 0fe4ae5090 qsa_select tp_select: no __match_any_sync below sm_70 (a plain add per thread there; integer atomics, the same sums): the CUDA 12 release engine (sm_60..89) builds again 2026-11-06 21:39:07 +01:00
Niko1221 ea765a5717 App gateway: an unknown model name on /v1/messages uses the one loaded model (Claude Desktop sends claude-5-5-sonnet); chat and Responses stay exact, and with several models loaded the 400 stays (#5312) 2026-11-06 19:55:58 +01:00
Niko1221 c49f198c77 Test: the Anthropic route serves the loaded model whatever name the request carries (#5312, claude-5-5-sonnet) 2026-11-06 19:51:51 +01:00
Niko1221 b1541ab245 Parser: the prefixed-name and misspelt-opener checks look only at the end of the buffer (the new text and 256 characters before it), so a long call is not rescanned with every token (#5409, #5276) 2026-11-06 19:48:21 +01:00
Niko1221 8a42a9457d moe_iq4_w4a8: the device side compiles for sm_60..75 targets (a trap there, as moe_fused_iq.cu does; arch_ok() already declines those cards, so no behaviour changes): the CUDA 12 and CUDA 13 release engines build again 2026-11-06 19:42:58 +01:00
Niko1221 05506f9532 Tool names: a namespaced MCP tool written by its last segment (get_asset_info for mcp__assets__get_asset_info) is that tool when exactly one declared tool ends that way (#5281) 2026-11-06 19:40:40 +01:00
Gabriel A. Devenyi 95381770f8 Anthropic tool results follow tool_use_id: results sent in the order the tools finished are put in call order before the template pairs them by position (PR #4832) 2026-11-06 19:36:59 +01:00
Gabriel A. Devenyi d7e555bdbb tool_choice "required" with several tools: write only "<tool_call>\n", because from a written "<function=" the model went on with names no tool has and the reply came back as text (PR #4828) 2026-11-06 19:36:43 +01:00
Niko1221 75acfca321 Config undeclared_tool_calls (env STRATA_UNDECLARED_TOOL_CALLS): "call" returns a well-formed call of a tool the request did not declare as a tool call; the default "text" is as before (#4622) 2026-11-06 19:36:04 +01:00
Niko1221 63fd759227 Tool calls: a misspelt opener (<toolcall>, <tool-call>) followed by <function=NAME> of a declared tool opens the call, where a <tool_call> would; prose and code stay text (#5276) 2026-11-06 19:34:47 +01:00
Niko1221 54828c7efb README: Loyal supporters adds themactep, macintog, bisonbet and vizv (public GitHub sponsors) in all 8 languages 2026-11-06 19:33:46 +01:00
Niko1221 68ab15ddc1 Tool names: <function=tools NAME> (a space for the dot) is the declared tool NAME, and a bare <function=tools> is the one declared tool that takes the call's parameters (with tool_call_recovery); anything else stays text (#5409) 2026-11-06 19:32:59 +01:00
Niko1221 7be42963e6 Version 0.2.12 / engine 0.1.58: Terminal and app 0.2.12, Control and every engine package 0.1.58 (the 0.2.11 bump list, plus the v0.1.58 line in the setup.py version history) 2026-11-06 19:21:19 +01:00
Niko1221 d4b72bc901 feed models: the owner's words and warn_allow policy, a family-menu component with a test, a launch model-screen test 2026-11-06 19:21:00 +01:00
Niko1221 37e91bfdbb strata_tokenizer: the approved wording for an unknown pre-tokenizer 2026-11-06 19:19:52 +01:00
Niko1221 8221fb8c1c feed models: read the model host off setup's url builder so strata_control names none (test_the_runtime_has_no_model_host_in_it) 2026-11-06 17:59:13 +01:00
Niko1221 5b1728358b test_download_integrity: a server that keeps serving the wrong file ends with the file kept as .bad (the dlsha behaviour: the new mismatch line, the file not deleted) 2026-11-06 17:43:19 +01:00
Niko1221 e17394fe1c serve/test_tool_format: the real Qwen3 chat template selects the JSON parser 2026-11-06 17:31:44 +01:00
Niko1221 9bbbb66061 packaging/make_model_release.py: plan a model release as a dry run
From the intake report it prints, in the order of the 0.2.11 release, what
would be built, signed and uploaded, with sizes from the previous upload
folder: case A is the feed (a models entry, re-signed at the next sequence)
and the app; case B adds the Qwen engine packages and Controls at a new
version; case C is refused. Steps that need the release key print a
placeholder; the script has no key, build, upload or publish code, and
--execute is refused. --write also writes the plan, the model's DRAFT feed
entry and the release notes, model card and checklist filled from the report
with measured numbers left as [FILL: ...].
2026-11-06 17:31:10 +01:00
Niko1221 a123ba696c feed models: the Mac setup paths, a row for a model the engine does not run (Terminal, app), UNAVAILABLE_POLICY switch 2026-11-06 17:30:11 +01:00
Niko1221 e7d4844637 serve: pick the tool-call parser from the chat template
serve/toolformat.py names the tool-call form a template documents (xml, json,
dsml, none, other). parser_for_template() gives JSON templates (Qwen3 / Hermes)
JsonOutputParser, which reads the JSON call as the template's own form whether
or not tool_call_recovery is on; every other template keeps OutputParser, so
the models that run today get the class they always had. Service takes it from
its template, set_family still picks DSML for DeepSeek.

The server stops at start, with one line and exit status 2, on a tokenizer pack
whose pre-tokenizer it has no rule for. serve/tool_parser_golden.json holds the
events of both parsers for fixed outputs, fed whole and in pieces.
2026-11-06 17:25:06 +01:00
Niko1221 7084a49b7b tools/qwen4_intake.py: read GGUF headers and say what a new model needs
One command on a folder, GGUF files or a Hugging Face repo (range reads of the
headers only). Per set of shards it reports the architecture and every geometry
key against qwen4exp's compiled shape and qwen35moe's limits, the tensor layout
against the family_layout.hpp tables (missing, reshaped, unknown), each quant
type by role against the CUDA / HIP / SYCL / Metal kernel lists, the tokenizer
and chat template (tool-call form, thinking tags), MTP, picture reader, split
and sizes, and a case A / B / C verdict with a DRAFT catalog entry and setup
config. serve/toolformat.py holds the tool-call form detector.

The tables are ports of the C++ and kernel sources; the tests read those files
and fail when a table drifts. Fixtures are real headers recorded from Hugging
Face (Flash-Next sets, Qwen3.6, DeepSeek, picture readers).
2026-11-06 17:22:07 +01:00
Niko1221 1e88e88c5f strata_tokenizer: refuse a pre-tokenizer type it has no rule for
An unknown tokenizer.ggml.pre used to fall back to the qwen35 splitting rule
without a word, which would change every prompt for a model with its own rule.
pre_tokenizer_kind() names the rule (qwen35, deepseek3) and Tokenizer raises
UnknownPreTokenizer (a ValueError) for anything else. The types accepted before
(qwen35, empty/None, deepseek*) build and split as they did.
2026-11-06 17:21:51 +01:00
Niko1221 c152be538c setup: read the feed's model list for an unknown --model only when it is not a built-in alias or a DeepSeek size 2026-11-06 17:21:16 +01:00
Niko1221 aae7721b6d feed: an optional models member (a model list in the signed feed): strict parsing, the cache of the last verified list, feed models in setup's tables, build-feed --models, verify-feed 2026-11-06 17:18:42 +01:00
Niko1221 706efc5b0f prefill_fused_iq_test: skip the Q5_0 down pairs where the native kernels do not cover them (CUDA has none; the gfx12 / gfx1151 builds run them); --only=NAME still insists 2026-11-06 16:29:26 +01:00
Niko1221 0a0e4463ea file tier: the re-check after the RAM copy counts only the page cache the group can really have (on by default; STRATA_FILE_TIER_HONEST=0 turns it off)
I/O path only: the same bytes are read, only HOW the experts outside the RAM copy are read (unbuffered or through the file cache) is chosen differently.

file_tier_unbuffered took its `avail` from cgroup_available_bytes, which credits the group's whole `file` counter as reclaimable. On a RAM-budget
config that credit includes pages the engine holds itself: P2003 (RTX 3060, 46 GB, 36 GiB group), UD-IQ4_XS-v31, 22 GiB RAM copy: the re-check read 18.7 GiB
available when the group had 11 GiB left (limit 36, current 24.8). With held = 0.52 >= 0.5 the timed drive probe was skipped and the file tier switched to the file
cache (direct reads 7.9 GB/s vs 2.5 GB/s cached): prompts 2.5-3x slower (pp2k 85 vs 273 tok/s, pp8k 195 vs 580), decode 10-14% slower. The one start that read
12.0 GiB kept the fast mode, which is why the first start of a session was sometimes fast.

Now the smaller of that figure and the conversation cache's sampler (only the clean inactive file cache is credited, the group's own limit, no operator
cap) decides. The --memory-limit-mib cap is not applied: it bounds the engine's own allocations, not the page cache (counting it sent IQ3_S unbuffered and cut its decode
2-3x). A flip now prints once: "strata: expert reads now go through the file cache (X GiB of RAM free for them, Y GiB limit)"; the technical line is behind STRATA_TRACE.
STRATA_UNBUFFERED_LOAD still forces either mode.

Measured on P2003 (build wip11, n >= 3 starts per arm, interleaved fresh engines, I/O-quiet before each start, memory watch on, floor 14336 MiB):
 v31 unforced: main flipped in 5 of 6 starts, this change 0 of 9 (6 + 3): pp2k 85-94 -> 263-273 tok/s, pp8k 187-198 -> 580-590, code_py decode +14 to +20%;
 v31 DFlash2 vs MTP in the kept state (3 rounds): DFlash2 +2 to +14% per kind (json_extract -3.5%);
 IQ3_S: the file cache stays (9 of 9 starts), decode level with main (code_py 17.0 vs 15.6 tok/s, json_extract 14.2 vs 13.4, page cache evicted before every start);
 low RAM (6-7 GiB ballast, budget 16 GiB): no regression, MemAvailable minimum 14911 MiB;
 memory minimum with the unbuffered state 15.6-15.8 GB (floor 14.3 GB), no arm stopped.
Not P1.8: main and the P1.8 build flip alike.

(cherry picked from commit be3831e9d944fb44ac36d176f4764e30df0f9f6b)
2026-11-06 15:51:21 +01:00
Niko1221 e77bd43bc9 prefill: a Q5_0 down (UD-IQ4_XS-v31: 33 of 48 layers) on the fused int8 kernel on gfx12, opt-in STRATA_PF_FUSED_Q50=1
native_w11_kernel takes a Q5_0 down block (d, qh, qs[16]; 22 bytes): the 5-bit codes are rebuilt with the qh bits and kept as the signed int8 q - 16 (no minimum
term), the scale is d.  d_covered_on() says yes for T_Q5_0 only on gfx12 with the switch; the layer otherwise leaves the fused path for the gather + FP16
dequantize + hipBLASLt route as before.  hip_prefill_fused_iq gets the pairs IQ3_XXS / Q5_0 and IQ4_XS / Q5_0 (reference-checked: the build has no Q5_0 MMQ).

R9700 (p620, v31, DFlash2, same executable with and without the switch, 3 rounds x 2 reps): prompt 2K +44%, 8K +40%, 32K +13%; decode unchanged.
First-token KL against the FP16 route (self-variation 0): 2K mean 0.016, max 0.068, top-1 24/24.  Off by default in this commit.

(cherry picked from commit 97c3033f566d27ea1d741d7b893ac13e0f4e8526)
2026-11-06 15:48:47 +01:00
Niko1221 37b5cf0db2 gfx12: the start-up line for the length-gated chunked GDN scan, and STRATA_GFX12_DEFAULTS=0 turns the gate off
"strata: RDNA4 (gfx12): prompt chunks of 30,000+ tokens use the chunked GDN scan (STRATA_GDN_CHUNKED=0 turns it off)" is printed once at start with the
other gfx12 default lines (owner's wording, 11-06): only on gfx12, only while STRATA_GDN_CHUNKED is unset and STRATA_GFX12_DEFAULTS is not 0.
The gate follows the gfx12 defaults' one off switch, so the line is true whenever it is printed.

(cherry picked from commit b2d1680a864195383697559edb9b6cf8180b9a19)
2026-11-06 15:47:44 +01:00
Niko1221 c0e7a6f4f8 gfx12: the chunked GDN scan is the default for prompt chunks of 30,000 tokens or more
STRATA_GDN_CHUNKED unset on a gfx12 card (RDNA4): the FP16 matrix-core chunked recurrence runs for prompt chunks of 30,000 tokens or more
(--prefill auto takes chunks up to 32,768 there, so a 32K prompt is one chunk of about 31,996 rows).  Shorter chunks keep the kernels they
ran, so every prompt up to 20K is bit for bit what it was.  STRATA_GDN_CHUNKED=0 turns it off, =1 is the old opt-in (every chunk of 64
tokens or more), STRATA_GDN_CHUNKED_MIN=N moves the line.  Owner OK 11-06.

R9700 (p620, IQ3_S, DFlash2, interleaved pairs against the same tree without the change, 5 rounds x 2 reps): prompt 32K +6.1% (5/5),
64K +6.7% (5/5).  First-token KL against the unchanged build (self-variation 0.00000): all 24 prompts of 2K, 8K, 20K and the 8K agent turn
identical; 32K 0.00005, 32K+ 0.00004, 64K 0.00086; top-1 equal on all 30 prompts.  No log line yet (wording is with the coordinator).

(cherry picked from commit 7b579367aaa56978232cb60ef405987a199d736f)
2026-11-06 15:47:44 +01:00
Niko1221 645b9177e3 setup: wording for the kept .bad file (NEEDS OWNER APPROVAL): the mismatch line says the file was kept, and says when the server sends the very same bytes; one line when the kept file is removed for room
(cherry picked from commit ae3c3bad63c23b2b51c72f232832d2db195ef72c)
2026-11-06 15:47:44 +01:00
Niko1221 071e4dc51e setup: a downloaded file with the wrong SHA-256 is kept as <name>.bad instead of deleted; the next run hashes it again and takes it back if it matches, a good new download or too little room removes it (visible text unchanged in this commit)
(cherry picked from commit e547295924d5cb560febafca6d1cbadeed33d2b9)
2026-11-06 15:47:44 +01:00
bulhenvaster bca7931f3d peer tier: STRATA_ADAPT_OVERLAP=1 with --peer-device (the peer's copies land while the next window runs)
In peer mode the adaptive overlap was refused, so every decode window first waited for the previous round's copies. With the overlap the peer
tier issues its copies in layer order, signals each layer's landing the way the primary does (a stream-ordered D2H copy of the layer number into
a pinned int, or host functions), its residency table takes the swaps at the window's start (flip_pending), and a layer waits for a tier's copies
only when that tier swapped in it (AdaptGate: peer_landed, peer_has, per-tier last). The wait before a window clears the peer's marks with the
primary's. Without the variable nothing changes.

Ported from #2720 (bulhenvaster), rebased by hand onto the current gate (the watchdog label stays). Not ported: STRATA_PEER_LAUNCH_IN_POOL
(the author measured no gain on his build), the new decode timing line and the documentation paragraph.

(cherry picked from commit e78b64a7348d685dfd11799fdb395661f2120436)
2026-11-06 15:47:43 +01:00
bulhenvaster 2b9f05093d prefill: STRATA_PF_PEER_CPU=1 (opt-in) runs the CPU share beside a peer GPU
With --peer-device the prompt path never gave the CPU pool a share (cpu_maybe required !m.pp), so a short read at long context streamed every
expert neither card holds over the primary's link while the CPU sat idle. With the switch the share runs beside the peer: it takes only experts
neither card computes, its rows go between the primary's and the peer's block (the peer's stays the tail its combine reads from back_at), and the
staged chunks go up to cpu_share_max()'s 3072 tokens. Without the switch nothing changes.

Ported from #2721 (bulhenvaster), rebased by hand onto the current prompt path (the default-armed share's limit and the routed-stream gate).

(cherry picked from commit c8b6c43c1f2653636d15fe14abf53112357570aa)
2026-11-06 15:47:43 +01:00
sergqwer 24c6c2f682 Admit the PCIe share's fetched misses to the GPU cache (opt-in STRATA_ADAPT_FETCH=1|2)
Decode reads missed expert blobs from host RAM three ways; the PCIe share's
blobs cross into VRAM anyway and were dropped after the window. With the
switch set, the share takes each layer's most-routed misses and a fetched
expert routed enough more than the layer's least-routed resident expert that
the window does not route is copied straight into that expert's slot
(fetch_blobs gets an optional dst2 target per blob). =2 leaves the tier's own
swaps out of a layer where an admission ran. STRATA_ADAPT_FETCH_VERIFY=1 reads
every admitted slot back. Off, with a log line saying why, where it cannot run.
Unset changes nothing.

Ported from PR 2964 (sergqwer) onto f-0216-core; the strata generate verify
loop was merged by hand (main's speculative-q lines kept) and the DETAILS.md
note became a row of the opt-in switch table.

l620 (RTX PRO 5000, IQ3_S, 3,896-slot cache, --pcie-frac 0.25): decode =2 +10.2%,
=1 +5.0% (4 rounds); token forcing over 14,167 tokens: mismatch 2.73% vs 2.53% /
2.53% for two unset arms (+0.19 pt, 90% CI -0.02..+0.42), PASS.

(cherry picked from commit b2bfaef906acee4f86bdf9f052aee6a4d295fb4e)
2026-11-06 15:47:43 +01:00
bitaccesscomau 386f9959f1 Exact two-pass QSA prompt selection (opt-in STRATA_SELECT_TWO_PASS=1)
Prompt-path block scores in single-pass TF32 with a per-block error bound,
3xTF32 rescoring only near each query's top-k cut-off, so qsa_block_topk
returns the same cells in the same order, ties included. Off unless the
variable is set; says once where it cannot run (HIP, Metal, SYCL, layer-major
path). Adds qsa_two_pass_test, qsa_two_pass_status_test and the GPU parity
test qsa_two_pass_parity.

Ported from PR 4119 (bitaccesscomau); the log line of the per-prompt counters
is shortened without dropping a field.

l620 (sm_120, IQ3_S), greedy ids identical off vs on in every pair:
prompt 128K +1.65% (n=12), 256K +4.10% (n=8), 32K -0.14% (noise).

(cherry picked from commit bbf7c55f1351d913233250fe7869642f7c2b98a1)
2026-11-06 15:47:43 +01:00
Niko1221 f281e3fe26 force_check.py: absolute path for the profile copy
(cherry picked from commit 1fb1e134809f6015c72802f19a99d70a1b1636db)
2026-11-06 15:47:43 +01:00
Niko1221 f46e88d610 force_check.py: cfg profile_src gives each engine start a fresh copy of the expert profile
(cherry picked from commit e47164c8e9d4d0ae0826d10177cc4d74369ec0bf)
2026-11-06 15:47:43 +01:00
Niko1221 d486fbe7d6 serve: STRATA_FOLLOW / STRATA_FOLLOW_LOGPOS token forcing through the real decode/verify windows (test and measurement only, off by default); force_check.py score --path decode
(cherry picked from commit af68878f0cc4c5aa4618da8f96b10f9262230721)
2026-11-06 15:47:43 +01:00
Niko1221 65909b2d0f verify: STRATA_VERIFY_PROFILE and STRATA_ROUTE_STATS reports cover windows up to 16 rows (measurement only; default behaviour unchanged); tools/force_check.py: the token-forcing check (scorer driver + the paper's two rules)
(cherry picked from commit b3b8c1d5a088c998947895a3a4007138b085da66)
2026-11-06 15:47:43 +01:00
Niko1221 cae562fa01 verify: STRATA_FORCE_FAULT=<m>:<bias> test knob for the token-forcing scorer (adds a bias to every m-th token's logit in the STRATA_LOGPOS rows; off by default, silent)
(cherry picked from commit dcb0483f06810f397cfdce9c1db976d8e67e2a24)
2026-11-06 15:47:43 +01:00
Niko1221 63f063852f prefill: UD-IQ4_XS-v31's Q5_0 down layers (33 of 48, with IQ3_XXS or IQ4_XS gate/up) take the fused F16 matrix-core kernel on gfx1151 under Windows instead of leaving the fused path for gather + FP16 dequantize + hipBLASLt. The Q5_0 block decode (qh bits spread, int8 codes q - 16, scale d) is the RDNA4 patch's; native_f16_kernel and the FP16 down rows are the K-quant experts' route. On by default there; STRATA_PF_FUSED_Q50=0 or STRATA_GFX1151_DEFAULTS=0 turns it off (the layers keep the earlier route), =1 turns it on for any gfx1151 build; it needs the F16 prompt set (STRATA_PF_F16). Other GPUs are untouched. Parity test pairs IQ3_XXS / Q5_0 and IQ4_XS / Q5_0 against the FP32 dequant reference, with 256/64, 33/16, 1/16, 130/32 and 517/32 token/expert cases: rel RMS 2.7e-3 against the int8-rounded model, 1.4-1.5e-2 against FP32 (the IQ pairs' usual level)
(cherry picked from commit 12f9547785781f77da41d6d08c2f541077de2df0)
2026-11-06 15:47:42 +01:00
Niko1221 cc3f9142e3 Auto on the 12 GB / streaming CUDA class: IQ3_S starts the MTP, UD-IQ4_XS-v31 starts DFlash2 with the tuned pack at the class's own 0.8
drafting.CLASS_DEFAULTS: one removable entry per set, read from the signals the confidence already reads (a CUDA card whose engine slots are under the last-but-one CONF_STEPS step).
IQ3_S: on P2003 the file cache makes the MTP faster than DFlash2 by 12-44% (RTX2 t4b). UD-IQ4_XS-v31: DFlash2 beats the MTP by about 6.5% (t8, 3 starts; DF-VAL forced-fast tuned 0.8 x1.037).
The v31 entry DEPENDS ON RTX2's FILE-TIER FIX (unbuffered reads) BEING IN THE SAME RELEASE; without it remove that entry.
Anything chosen by name wins. Resident CUDA, Strix, gfx1201, Arc and Metal entries and the conf tables are unchanged. The sm_86 support line reads the set: UD-IQ4_XS-v31 gets the existing
'faster on some kinds of text and slower on others' line, every other model keeps 'slower'.

(cherry picked from commit 42412d42ff835d6a66a78fbf6d119911908eda1b)
2026-11-06 15:47:42 +01:00
Niko1221 f138653938 DFlash2 on Strix Halo: the tuned pack for UD-IQ4_XS-v31 too, at Strix's current confidence
One more SET_DEFAULTS line for the measured cell (DF-VAL, setup's config, budget 38, 3 rounds: tuned 0.35 against stock 0.35 x1.046, 3 of 3 rounds, every kind 3 of 3).

(cherry picked from commit c2b801157e71a503da859b66baff12fff28cd299)
2026-11-06 15:47:42 +01:00
Niko1221 ce6bbd7d54 DFlash2 on Intel Arc: the tuned bf16-selector pack for SYCL + IQ3_XXS at Arc's own confidence 0.6
The SYCL 0.2.11 engine reads only bf16 selector codebooks, so Arc gets its own tuned file (qwen3.8-flash-next-dflash2.strata1.q4_0-bf16sel.pack, 522,458,112 bytes, own SHA-256 pin) through
SetDefault.pack. Only IQ3_XXS, the set measured on the A750 (tuned 0.6 x1.054, 3 of 3 rounds); other Arc sets keep the stock pack. SYCL never gets the selq4 file, CUDA / HIP / Metal never the
bf16sel one. NOTICE names the second file and no longer overclaims about the text.

(cherry picked from commit 6111c583b70e3c88a3d54039e13d76b19f15742d)
2026-11-06 15:47:42 +01:00
Niko1221 aba4ddaca7 Auto on HIP gfx1201 starts the MTP until the DFlash2 prompt stall is fixed (one entry: drafting.AUTO_MTP)
11 of 50 engines on 0.2.11 never got past the prompt on this card class. Only Auto changes: DFlash2 chosen by name, a saved choice and the person's own options still win; gfx1151, other AMD, CUDA, Metal and SYCL and the confidence tables are unchanged. The support line under the Drafting dropdown says it in plain words (app catalog entry in all nine languages).

(cherry picked from commit 8b4398743aa67368d72277310bbe8093bbe4923c)
2026-11-06 15:47:42 +01:00
Niko1221 31928fbe34 DFlash2: Strata's tuned drafter pack on CUDA (every expert resident) and Strix Halo, at their current confidence
SET_DEFAULTS entries carry a card condition (tuned_applies: the CONF_STEPS first-step slots or the Spark's shared memory for CUDA, gfx1151 for HIP); no entry names a conf, so
conf_for / CONF_STEPS / BACKEND_CONF run as before and only the pack changes. Enabled only for the cells measured at the shipped conf (CUDA-resident IQ3_S and UD-IQ4_XS-v31, Strix IQ3_S, UD-Q4_K_XL, UD-IQ4_XS).
12 GB and 24 GB cards, several cards, gfx1201, other AMD and SYCL never fetch or use it. Fetch keeps tuned, then stock, then MTP; silent offline; feed backends are asked per start's backend.

(cherry picked from commit f096dcd750b5928ace6f9eca1e2936b9e35578d4)
2026-11-06 15:47:42 +01:00
Niko1221 10831eceb2 Metal build: stubs for the f-0216-cpu 512-bit entry points (q2_rows_512_ok, iq512f_supported, iq512_fused_gu_rows, iq512_reference_gu_rows, q50_512_down_rows) 2026-11-06 09:57:08 +01:00
Niko1221 7604725595 arm64 portable build: stubs for cpu_vnni_ok, q2_rows_512_ok and iq512_reference_gu_rows (the Spark link failed on them) 2026-11-06 09:51:02 +01:00
Niko1221 2a8217c0ce generate: the CPU expert kernel line reads 'CPU expert kernels: <X>' (owner's short wording) 2026-11-06 09:44:37 +01:00
Niko1221 a7cc8faa5d split: one constant for the expert window floor, and the IQ3_S pool test
EXPERT_WINDOW_SLOTS (2 * MAXT * TOPK) is the slots a size pool must hold for one window's picks to have a place; ExpertCache's check, the residency
planner's floor and the arena's keep all read it. expert_residency_test now also covers the GSQ-RCO IQ3_S set's real pools (7, two of them a single
layer), a sweep of RAM targets against a copy of the old global ranking as the negative control, a refusal beyond what the floors allow, and that
the card's sliced 16-byte-padded strides partition the layers like the whole-expert bytes do. Class E (placement only).
2026-11-06 09:36:37 +01:00
MIDHAT NASHAR 20885f7af0 split: retain a window in every expert pool
Mixed-quant models can fail to load in the split engine after cold-expert offload: the global residency ranking can leave one size pool below the
2 * MAXT * TOPK slots Up::expert_cache() needs even while other pools can still give up experts. Group experts by both the gate/up and the
down byte strides, keep the cold-first ranking but skip eviction from a pool that has reached the window floor, count only the extra evictions
that really happen, and say so before the model is uploaded when the request cannot be placed.

Ported from #5158 (midhatn) unchanged; the tests and the engine check follow in the next commit.
2026-11-06 09:36:37 +01:00
Niko1221andhavok2-htwo abf2953be0 native_dense: STRATA_HC_Q8 copies only the stage's own Q8_0 hyper-connection rows on a trimmed layer split (P1.2)
With --trim-stage-weights every later stage's HC_Q8 loop over the GGUF copied the rows of all 48 layers and read its own. The loop now skips a
tensor outside the stage's layer range, the filter the upload loop above it and the bf16-pack path below it already use. The Q8_0 rows copied
and the bytes not copied are recorded in the run manifest (STRATA_MANIFEST). STRATA_HC_Q8_ALL_STAGES=1 copies every layer as before.
No value changes: a stage never reads another stage's layers; without a trim the range is unset and nothing is skipped. Class E.

Co-authored-by: havok2-htwo <[email protected]>
2026-11-06 09:36:37 +01:00
Niko1221 78c5a834e3 Merge f-0216-dfship into f-0216-all 2026-11-06 09:36:34 +01:00
Niko1221 51548e5539 Merge f-0216-strix2 into f-0216-all 2026-11-06 09:36:34 +01:00
Niko1221 7597e383cf Merge f-0216-rtx into f-0216-all 2026-11-06 09:36:30 +01:00
Niko1221 f2eedfe9f2 Merge f-0216-cpu into f-0216-all 2026-11-06 09:36:30 +01:00
Niko1221 bd0e878f42 Merge f-0216-core into f-0216-all 2026-11-06 09:36:29 +01:00
Niko1221 4084979c95 prefill: STRATA_PF_ALLOC_TRACE=1 prints the free-memory figure around Prefill::init's allocations against what the code asked for (stager ring, routing tables, PLE rows, GEMM scratch, the chunk's buffers, library warm-up). Off by default, no behaviour change 2026-11-06 08:56:14 +01:00
Niko1221 530a29ea2d serve: on an integrated GPU with a page-locked expert complement (Strix Halo), the free-VRAM startup line and the request-start warning no longer advise a larger --vram-reserve-mib (it cannot raise that figure: the driver counts pinned host memory and the prompt path's buffers about twice, so the cache giving up experts to the complement only lowers it). New startup line: "N MiB of graphics memory reported free. On this PC the graphics chip shares the system memory, so this figure reads low, and a larger --vram-reserve-mib won't raise it. If decode drops to a few tok/s, lower --max-context." New request-start note, printed once: "Only N MiB of graphics memory reported free at the start of this request. On this PC the figure reads low because the graphics chip shares the system memory. If decode drops to a few tok/s, lower --max-context." Every other GPU keeps the earlier text. Wording only: greedy ids identical to engI on agent8k, agent, pp4k and code_py (UD-Q4_K_XL, setup config) 2026-11-06 08:56:13 +01:00
Niko1221 3e8d3b2940 Mac: DFlash2 is the default drafter for UD-Q4_K_XL and UD-IQ4_XS-v31, with Strata's tuned drafter at --draft-conf 0.7
SET_DEFAULTS is the one per-(backend, set) table (Metal only; IQ3_S stays on the MTP). Setup, strata launch and strata drafting fetch fetch the tuned pack through the signed feed
(pinned size and SHA-256, resumable, silent offline, the standard drafter and then the MTP as fallbacks). Apache-2.0 licence and the modified-by-Strata notice ship in
data/dflash2-tuned and are copied beside the file. The Metal 'not tested' line is replaced for these sets.
2026-11-06 07:52:31 +01:00
Niko1221 039cecc5a5 feed: a drafters member for DFlash2 drafter files Strata hosts (pack-drafter, build-feed --drafters, make_local_feed --drafter) 2026-11-06 07:52:31 +01:00
Niko1221 d735d66a4d docs: STRATA_IQ4_W4A8 on sm_120 and its exit counters; STRATA_FILE_TIER_IDLE and the idle policy of the file tier 2026-11-06 01:49:09 +01:00
cctan 7bf5ec4ebf prefill: opt-in int8 kernel for the IQ4_XS / IQ4_NL expert products (STRATA_IQ4_W4A8), MMQ's bits
Ported from #4871 (cctan): src/prefill/moe_iq4_w4a8.cu, tried first by mmq::Context::run; unset, nothing changes.
Changes on top of the PR: the "would MMQ split this launch along K" test is now mmq::launch_splits_k, which reads the
device's real MMQ configs (tile width, stream_k flag, tile efficiency) instead of an Ampere table, so the kernel declines
exactly the launches whose sums MMQ adds in another order, on any architecture; sm_120 joins sm_80..sm_88 (bit-for-bit
against MMQ on an RTX PRO 5000 and an RTX 3060); the kernel counts what it ran and why it declined and prints that at exit;
prefill_iq4_w4a8_test takes --tokens N (tails), --wide (scale extremes) and --bench (MMQ against the kernel per product set).

Class E: 0 differing output words in every case (tokens 97 / 2049 / 4096 / 8191 / 15360 / 32768, uniform and log-normal routing,
group buffers and pointer table). Kernel time against MMQ on the down products (IQ4_NL): +9..+70% on sm_86, +13..+50% on sm_120.
2026-11-06 01:49:09 +01:00
Niko1221 178d1c74b5 merge f-0215-mac-pp (host-row pipeline, qsa_merge_h, ragged expert tiles) into f-0215-mac-int 2026-11-06 01:25:09 +01:00
Niko1221 eecd7f61d7 merge f-0215-mac-out (step profile, opt-in screens, Metal DFlash2 conf 0.5, calibrated gate) into f-0215-mac-int 2026-11-06 01:25:09 +01:00
Niko1221 86c3367a89 native_expert.cpp: the fused-rows type list terminator is '\0' (a NUL byte had slipped into the character constant; MSVC rejects it) 2026-11-06 01:05:40 +01:00
Niko1221 04b73d061d kq_window_bench: --dump-trace writes the synthetic windows in the route-dump text form 2026-11-06 01:01:14 +01:00
Niko1221 1daec2a5b8 check_isa_guard: iq512_reference_gu_rows and iq512x_rows are listed (main already reports them as unlisted; both are reached only behind cpu_avx512_ok / cpu_vnni_ok) 2026-11-06 00:51:10 +01:00
Niko1221 b94ab5f4c6 kq_window_bench: the CPU pool's part of a decode window replayed without a GPU (per-layer format pairs from the pack, expert and row counts calibrated on the engine's logged means, cold DRAM) 2026-11-06 00:50:40 +01:00
Niko1221 445cdf4540 Run manifest: say what the unset STRATA_ROUTE_TAIL_SKIP means (the engine default applies, tails are skipped only where the configuration allows) 2026-11-06 00:47:28 +01:00
Niko1221 8463d15c84 STRATA_ROUTE_DUMP: terminator fix 2026-11-06 00:42:08 +01:00
Niko1221 1c768f9d12 STRATA_ROUTE_DUMP: newline fix 2026-11-06 00:41:18 +01:00
Niko1221 b0b0348c2b kernels: opt-in fused gate/up rows in ggml order for 2+ tokens (STRATA_IQ512_FUSED_MAX; N, after PR 5243's row_gu_dot); STRATA_ROUTE_DUMP routing trace for offline placement replays 2026-11-06 00:39:11 +01:00
Niko1221 7e3fae57ca PR 5120 tier below VBMI is opt-in (STRATA_VNNI_TIER=1): its 512-bit rows round differently from the AVX-2 rows such a CPU runs today (SDE Cascade Lake: all multi-token gate/up lines and the Q2_0 rows differ), so by default every CPU keeps its output 2026-11-06 00:22:37 +01:00
Niko1221 32944ce1ee cpu_dispatch_dump: the Q2_0 rows (q2_rows_any) next to ggml's, so the substitute unpack can be compared with the multishift 2026-11-06 00:22:30 +01:00
Michel Pollet f000d4a455 cpu/experts: the 512-bit VNNI rows on a CPU without AVX-512-VBMI (PR 5120)
cpu_vnni_ok() = AVX-512 F + DQ + BW + VL + VNNI + OS state, cpu_vbmi_ok() = F + VL + VBMI; the Q2_0 row dot and the q8 quantizer take the 512-bit form
on VNNI without VBMI (the one VBMI op, vpmultishiftqb, has a bit-for-bit substitute unpack), and the i-quant / Q5_0 rows of iq_avx512.cpp, whose object holds
no VBMI instruction, gate on cpu_vnni_ok() instead of cpu_avx512_ok().  Adjustments over the PR: DQ is asked for (the file is compiled with it), the i-quant rows
gate on VNNI (the rows use vpdpwssd/vpdpbusd), and STRATA_NO_VBMI=1 runs the substitute unpack on a CPU that has VBMI (a test knob).
2026-11-06 00:22:30 +01:00
Niko1221 6368dc68ab cpu: drop the IQ4_NL 512-bit down rows (5-11% slower than iq4nl256_down_rows at 2-4 tokens on Zen 4); Q5_0 down keeps its 512-bit rows; vpdpwssd in the i-quant rows' integer sums (same integers, one op less) 2026-11-06 00:22:29 +01:00
Niko1221andbsorensen110 55dc17697c cpu: IQ4_XS gate/up and Q5_0 / IQ4_NL down rows in 512-bit lanes on AVX-512 CPUs, bit-identical to the code they replace
IQ4_XS gate/up: ggml-cpu's dot order (the integer lanes of its AVX2 dot, f16 scale times y.d, eight float lanes, its hsum) for every token count;
before, an AVX-512 CPU ran ggml's one-token dot per token.  Q5_0 down: ggml's dot order, weights decoded once per block pair for all tokens.
IQ4_NL down: iq4nl256_down_rows' order, 512-bit.  Each is checked once against the code it replaces; STRATA_NO_IQ4XS512=1 / STRATA_NO_QD512=1 switch
them off.  The IQ4_XS nibble-plane decode follows the layout of PR 5243.

Co-authored-by: bsorensen110 <[email protected]>
2026-11-06 00:22:29 +01:00
Niko1221 241b2f203c cpu_dispatch_dump: a hash per (pair, token count, row range) of the pool's native expert calls, to compare a change switched off and on 2026-11-06 00:22:29 +01:00
Niko1221 044105d8de kq_cpu_bench: --part gu|down times one projection alone, --hot K walks K distinct experts (cache-resident case) next to the DRAM walk 2026-11-06 00:22:29 +01:00
Phlimosx 994029b02f generate: the file tier's Linux defaults and the routing-aware prefetch stay off while every expert is in the GPU caches
Ported from #4972 (Phlimosx), extended: the residency is checked again at every request's start, so a
smaller cache (VRAM resize) or an eviction turns io prefetch, its decode gate and the look-ahead back on,
and a regrown cache turns them off again. Only what the defaults switched on is touched: an explicit
STRATA_IO_PREFETCH=1 keeps io prefetch and the look-ahead, STRATA_LOOKAHEAD=1 keeps the look-ahead,
STRATA_FILE_TIER_IDLE=0 turns the policy off. set_io_prefetch clears the gate flag with the prefetch.

Class E: the answers are identical (45 of 45 decode answers on l620, --mmap-experts, 24576 of 24576 resident).
2026-11-06 00:22:11 +01:00
Niko1221 89f4fa18ee Run manifest (lite): STRATA_MANIFEST=<file> writes the selected fast paths, fallback reasons, tensor formats, engine choice and budgets as JSON (P0.1)
A diagnostic for testers and for the performance work, off by default. With STRATA_MANIFEST unset every call returns at once
and nothing is printed or written. With it set, the engine writes <file> (whole file, replaced by rename) when it is ready and
again after a request if something new was recorded; the launcher (strata_control/split_engine.py) writes the all-cards split
decision to <file>.launch.json. Sorted keys, no timestamps, process ids or addresses, so two matched runs give the same text.

Content:
- engine: version, engine kind (single GPU or layer split), stages, KV format, drafter, spec window, pipeline windows, PLE I/O.
- budgets: context, KV resident cells, expert slots and cache MiB (all tiers and primary), VRAM reserve, prefill chunk cap and
  stages, pool workers, memory limit, resident budget, batch slots, conversation cache.
- paths: one entry per decision with selected/declined and the reason in plain words: prompt experts per format class (MMQ,
  FP16 WMMA tiles, native fused kernels, FP16 GEMM, with the gate/up and down formats and the layer count), MMQ direct and group
  sort, HC_Q8, the output head (VRAM or pinned host, repacked or not), route tail skip, DMA batch mode.
- tensors: dense and head formats as held (count, bytes), the packed and HC_Q8 copies made at load.
- launch: split-engine mode, engine chosen, reasons, card architecture and count, routed-expert types, and the AMD auto rule
  condition by condition (architecture, card count, expert types), so four gfx1201 with the IQ3_S set is recognised and two or
  three cards are not promoted by the four-card rule.

The only text it prints is "strata: STRATA_MANIFEST: cannot write <file>" on stderr, once, when the variable is set and the file
cannot be written.

Tests: tests/core/run_manifest_test.cpp (off is free and writes nothing, matched runs in any recording order give the same text,
replace-on-rerecord, escaping, dirty tracking, unwritable target), tests/split/test_split_engine.py::LaunchManifest.
2026-11-06 00:21:53 +01:00
Niko1221andAlanJager e2d3159996 Build the flag-order test on HIP too: hip_flag_order_test is the same source through the compat header (P1.1)
Co-authored-by: AlanJager <[email protected]>
2026-11-06 00:20:28 +01:00
Niko1221andmartifaj 1ed923f59e PLE reader: issue() folds a small batch in a stack buffer instead of a hash table and a job vector (P1.6)
Class E: the queued jobs (page offsets, read lengths, Use order inside a job) and the dedup_rows count are the same as
before; the bytes delivered are the same.

PleReader::issue() built a std::unordered_map by page and a std::vector<Job> on the heap on every call, then sorted the
jobs by offset. A decode step asks for 1..16 rows (the prefetch for PLE_N_HEADS = 16), so those allocations and the
hashing were most of the work. A batch of up to 64 rows (PR #4910) now collects its misses into a fixed stack array,
sorts them by (page offset, request order), and merges equal pages in one pass straight into the queue. Nothing is shared
between calls, so concurrent issuers (they hold the reader's mutex when threaded) have no scratch to race on.

Batches above 64 rows (a prompt chunk: thousands of rows) keep the previous hash path. The PR also sorted every miss in
that case with a reusable vector; measured, that is slower there (see below), so it was not taken. A batch of more than
2^32 rows is refused like a failed reader, as the 32-bit order/ticket counters cannot hold it (the old code could not
queue it either).

Measured with tests/ngram/ple_issue_bench.cpp (this thread's heap allocations and thread CPU time inside issue(), threaded
reader, 3000 calls per cell, 2 rounds that agree within 3%; Ryzen 5 5600X, g++ -O2, synthetic 300k-row table, rows are a
4000-row window / uniform / skewed):
  rows   allocations/issue before -> after    CPU us/issue before -> after
     1        5.1 -> 2.1                          1.69 -> 1.54
     4       13.4 -> 5.4                          2.00 -> 1.82
    16       40.3 -> 17.4                         3.44 -> 2.53
    64      125.2 -> 51.8                         8.07 -> 5.01
  above 64: unchanged (the PR's sort-everything variant took 159 vs 66 us at 3072 rows and 2.9 vs 0.55 ms at 49152).
The decode impact is not measured here (see the round's results file).

Tests: ple_reader_test --selftest now also checks, for batch sizes 0, 1, 2, 15, 16, 63, 64, 65, 66, 127..129, 300, 1000,
4096 (random, clustered, one row n times, descending, out-of-range mixed in, page-straddling rows twice), that the bytes
are right, requests grew by the rows asked for and dedup_rows by (rows read - distinct pages). With the existing
selftest and the multi-client stress test (rows across pages, injected delays and read errors, late writers, failed
reader): pass plain, under ASan+UBSan, and the stress test under TSan.

Co-authored-by: martifaj <[email protected]>
2026-11-06 00:12:28 +01:00
Niko1221andcloud4t0r aeaca205eb Mixed-HIP pipelined prompt read: stage 0 is served to its end before the drafter's blocking prefill on its card (P1.9, part 2)
Correctness (class E): the same windows are served in the same order, only a little earlier; the tokens do not change.

With --pipeline-windows 2 on a two-stage layer split the drafter sits on the first stage's card (mtp_first).
read_windows_pl called mtp.prefill(...) right after a stage-1 window finished, while stage 0 still had a window in
flight on that same card. MtpDrafter::prefill blocks the host until its stream is idle (on HIP it synchronizes once
per group whatever its `sync` argument says: prefill_per_group_sync), a stage-0 window only advances when this same
thread serves its doorbells, so the host sat in hipStreamSynchronize and the window it waited for could never
finish: the first request of a fresh engine read its prompt, produced 0 tokens, and the watchdog ended the engine
60 s later (gfx1201 + gfx1030, issue #5147). Neither 20 s guard in Verifier::service / done can fire, because the
loop never runs again.

serve_stage0_to_end() serves stage 0's windows to their end before that prefill, when the drafter shares stage 0's
card. The HIP per-group synchronization (which exists because the unsynchronized E-4 queue hangs on gfx1201 /
ROCm 7.2.4) is left as it is: the caller is fixed, not the mitigation. Pipeline windows 0 and 1 take no new path
(mtp_first is false there). The overlap lost around each drafter prefill has not been measured.

Not testable on the boxes of this round: it needs a mixed pair of HIP cards (gfx1201 + gfx1030) and a two-stage split;
p620 has one R9700. Compiles on CUDA (z440, sm_86).

Co-authored-by: cloud4t0r <[email protected]>
2026-11-05 23:57:51 +01:00
Niko1221andquantforge-br f225844d19 Prompt MMQ row tables get the stream-k fixup's over-read margin, sized from the backend's tile bound (P1.9, part 1)
Correctness (class E, not a speedup): the product values do not change; the rows past T*K are never routed to.

llama.cpp's mul_mat_q_stream_k_fixup loads ids_dst[col_low + jt*J + j] for a whole J-column tile before it compares
the tile with the expert's rows (the main kernel skips an empty tile first; the fixup only skips its writes), and its
tiles run to ceil(max_rows / J) * J for every expert of the launch. A table of exactly T*K entries is read past its
end by up to roundup(max_rows, J), and the main kernel reads up to J - 1 past its last row. Strata's `ids_identity`
was an exact-sized allocation: when it ended a 2 MiB page the over-read faulted on the next (Xid 31 FAULT_PDE,
"prefill copy_i32: an illegal memory access" on a long prompt on a CUDA layer split; issue #1468 and others).

PR #5108 added T + 256 rows by hand. Here the margin is derived: J is bounded by ggml's mul_mat_q_switch_J
(J = 8 .. 128 in steps of 8), the tile loops of moe_mmq.cu now use that one constant (kMmqTileColsMax /
kMmqTileColsStep), and the table is T*K + roundup(T, J) + J rows (an expert takes at most one row per token), with
checked 64-bit arithmetic: a size that does not fit fails the carve instead of wrapping to a small table. The same
size is used by carve, bytes_needed_impl and the three places that write the identity (helper rebind, normal prompt,
layer-major), so the estimate and the carve stay identical. The peer prefill's row table, an exact cudaMalloc used by
the same MMQ launches, gets the same margin.

Tests: tests/prefill/mmq_ids_margin_test.cpp enumerates every index the fixup and the main kernel can read for 2000
random layouts (late experts, empty tiles, every J), checks the exact-sized table of before is overrun by the late
expert layout, and the overflow refusals. Builds on CUDA sm_86 (z440).

Co-authored-by: quantforge-br <[email protected]>
2026-11-05 23:46:57 +01:00
40849df0a8 Transfer completion: flag B stays behind queued copies, and the adaptive-copy range covers the whole round (P1.1)
Correctness (class E): kept even without a speedup.

Flag B (PR #5096): the graph waits for "flag B >= layer + 1" and flag B is one counter that only rises. A non-empty
plan raises it from a host function on the copy stream, after its uploads. An empty plan (n <= 0) raised it on the
spot, even when the copy stream still held an earlier layer's, or the other token group's, uploads: the counter
jumped past a layer whose bytes had not landed. Verifier::raise_flag_b_ordered() now raises it through the copy
stream whenever copies were queued in this window, and directly only when none were. The same ordering is used at
the four places that publish an empty plan when the pool did not (run, run_slot_rows, batch_serve, service), which
had the same hazard. A failed host-function launch used to be ignored (flag B never rose and the window hung); it
now stops the engine like a failed upload does, after releasing the GPU waits.

Adaptive-copy bookkeeping (PR #4844): a round planned on the pipelined loop whose loop drained before step 3 is
marked resident on the serial rule, which used the lo/hi/bi of an earlier round, so only that range of swaps was
marked and the others were lost (the slot held the new expert, the table still named the old one). The pipelined plan
now names the whole round. The range arithmetic moved into AdaptiveCopyRange (adaptive_range.hpp) so a host test can
walk the rounds.

Tests: tests/core/flag_order_test.cu (delayed host-function copy standing in for a slow DMA, a consumer kernel that
reads staging once flag B allows it, a negative control that shows the old direct raise releases it early, flag only
rises, nothing queued, graph replay twice, every device; passes on both A4000s of z440), tests/program/
adaptive_range_test.cpp (serial batches for 0, 1, many swaps and several caps, the stale-range loss, empty round).

Co-authored-by: AlanJager <[email protected]>
Co-authored-by: uvlad23 <[email protected]>
2026-11-05 23:39:06 +01:00
a5b820a415 Request accounting and prompt timing marks: yielded prompt legs summed once, admission time without a double count, unknown stays unknown (P0.4)
Reporting only (class E): no engine speedup, no change to what the engine computes.

serve/server.py
- A prompt read that gives way (BYIELD) and resumes keeps the request's external prefix boundary, and the prompt
  time and tokens of every leg are summed exactly once (prefill_leg_stats; PR #5318).
- The admission's DONE times one token and BDONE counts that token but times only the slot: the slot time now
  includes the admission time, and the output token is not counted twice. A figure the engine never reported
  (a close at the admission token, in the slot, a carry back to the solo path) stays unknown (None) instead of
  turning into 0 or into the API's output count (PR #5307).
- The two patches overlap in the request finalization; ended_on_yield is taken before the unknown-figures step so
  the identity check still sees the yield's own record.

PfTimer::mark(): a mark in the phase already running is not recorded (PR #4006). The totals are the same, a
fraction of the event records; under WDDM every record is a submission of its own and the launch gaps they made
were charged to "dequant". The rule is a pure function in prefill/timer_marks.hpp with a host test.

Tests: serve/test_yield_accounting.py (new: two yields, solo and admission yields, cancel after one and two
yields), serve/test_parallel.py BatchLegAccounting (EOS and length finishes, zero slot time, close at the
admission token and in the slot, slot to solo carry), serve/test_request_accounting.py (unknown stays unknown,
explicit zero), tests/prefill/timer_marks_test.cpp (same totals with 18 of 23 records skipped).

Co-authored-by: YusenZhang0601 <[email protected]>
Co-authored-by: shoucandanghehe <[email protected]>
Co-authored-by: Vop3p <[email protected]>
2026-11-05 23:31:14 +01:00
Niko1221 b97433df05 spec: STRATA_DF2_GATE=1 (opt-in): DFlash2's verify window from a per-request calibrated survival (position x confidence bucket, prior C^1.3) against the cost of one more row, instead of a fixed --draft-conf; window_rule_test covers it 2026-11-05 23:18:51 +01:00
Niko1221 64cc20a8e2 Merge f-0215-fit (setup wording) into f-0215-int 2026-11-05 22:47:02 +01:00
Niko1221 1d342f0836 setup: on a unified-memory GPU whose RAM budget leaves no room for a numeric expert cache, say where the cache is sized from instead of warning that it may take the OS's room: "The expert cache is sized from the graphics memory (<N> GB carve-out + shared), not from the RAM." N is the carve-out setup detected (no number when none was). The old warning and its --resident-budget-gib tip are gone; the test checks the new line with 2 GB and 48 GB carve-outs 2026-11-05 22:44:44 +01:00
Niko1221 0e1d440713 Merge f-0215-pk into f-0215-int 2026-11-05 22:36:02 +01:00
Niko1221 19136808f9 Merge f-0215-fit into f-0215-int 2026-11-05 22:35:59 +01:00
Niko1221 2c0ad33f71 Metal prompt: ragged expert tiles on by default for the types that have kernels (STRATA_PROMPT_V6=0 off); drop the unused Mac PLE threading and the tall-tile probes 2026-11-05 21:59:37 +01:00
Niko1221 3cc5714f37 Strix Halo (Windows gfx1151): STRATA_SMALL_UNR=3 and STRATA_GR_Q8_PFS=1 (load-ordering variants of the decode kernels, same arithmetic) join the default table. UD-Q4_K_XL, DFlash2 serve, 4 rounds x 2 passes x 4 prompts interleaved: window time -0.25% (25 of 32 pairs faster, same window and token count in every pair); first-token logits and 220-token outputs identical on 3 prompts for UD-Q4_K_XL and UD-IQ4_XS. Linux gfx1151 and STRATA_DEFAULTS=026 keep their tables; arch_defaults_test updated and passing 2026-11-05 21:19:24 +01:00
Niko1221 687a7d79d7 Metal prompt: host rows beside the GPU (embedding + PLE rows of the next chunk on a thread, parallel PLE gather on Macs), qsa_merge_h (merge writes the fp16 operand), ragged expert tiles (mm_gu6/mm_down6/mm_scan6, STRATA_PROMPT_V6), STRATA_PROMPT_KPROF per-dispatch profile, matmul2d probes 2026-11-05 20:36:54 +01:00
Niko1221 6e31946cbe Strix Halo (gfx1151, Windows): the F16 prompt expert kernels write the down rows as FP16 and a combine kernel reads them (STRATA_PF_DM16, on by default there, =0 turns it off; from the Strix dm16 work patch, extended to the F16 K-quant and IQ kernels). 8K prompt, same engine, interleaved pairs: IQ3_S +2.2% (4 of 4), UD-IQ4_XS +2.6% (3 of 3), UD-Q4_K_XL +1.2% (3 of 4); first-token KL 1e-4 at 8K, 0.02-0.04 on agent transcripts 2026-11-05 19:59:50 +01:00
Niko1221 66b346c29f drafting: Metal gets its own DFlash2 confidence (0.5, BACKEND_CONF) instead of the CUDA slot steps, which wrote 0.8 for the Mac's --expert-cache 64; M5 Max UD-Q4_K_XL +14.3% over MTP spec 2 on six kinds 2026-11-05 19:54:31 +01:00
Niko1221 830732a33d metal: step profile scales every pass by the cool whole-window time; opt-in screens STRATA_METAL_HCLD=1 (HC loads in flight: -10.9%, off), STRATA_METAL_WPF_MIB (weight prefetch beside gr_up), STRATA_METAL_MOE_GU / MOE_DN (expert tile override) 2026-11-05 19:54:31 +01:00
Niko1221 e431a4a8e2 Strix Halo (Windows gfx1151): the V2K grouped decode experts for Q4_K / Q5_K gate/up and Q5_1 / Q8_0 down are on by default (STRATA_EXPERT_V2K=0 turns them off). UD-Q4_K_XL, DFlash2 serve, 16 request pairs: window time -4.0% (16 of 16 faster, mean x1.040), same window and token count in every pair; first-token logits and 220-token outputs identical on 3 prompts (the kernels are bitwise the old ones) 2026-11-05 19:27:35 +01:00
Niko1221 4dbaf703fd Strix Halo (gfx1151, Windows): the K-quant prompt experts of UD-Q4_K_XL (Q4_K / Q5_K gate/up, Q5_1 / Q8_0 down) take the fused F16 matrix-core kernels by default (STRATA_PF_FUSED_KQ=0 or STRATA_GFX1151_DEFAULTS=0 keeps the earlier route). Setup config, n=1: 8K prompt 885 -> 1,387 tok/s, 4K 611 -> 1,316; first-token KL against the earlier route 1e-5 .. 6e-2 on prompts of 1K+ tokens (information), same greedy ids 2026-11-05 18:54:42 +01:00
Niko1221 c4fa1d9e1e metal: STRATA_METAL_STEPPROF=T, the in-situ step profile by part (prefix runs at every barrier group, weight bytes and GB/s per part, distinct experts per layer); run_graph can run the first N dispatches 2026-11-05 18:41:45 +01:00
Niko1221 9f7d977d22 README: a Loyal supporters section under the Sponsor and coffee buttons in all 8 languages, with the public GitHub sponsors as linked profile pictures (first: ryan4yin) and a one-line template for the next one 2026-11-05 14:54:54 +01:00
Niko1221 5d37280db1 Docker: build.sh copies metal/tools/metal_repack.py and stream_experts.py into the build context (the Terminal bundle carries them since the Mac Unsloth sets; the v0.2.11 image build stopped at the missing metal_repack.py); the context test checks every INSTALLER_METAL_TOOLS name 2026-11-05 14:02:27 +01:00
Niko1221 dc9dfcfd29 GitHub Sponsors: FUNDING.yml names github: Niko1221 (the repository's Sponsor button), and every README gets a Sponsor badge beside the coffee badge, a Sponsor button in Support Strata and a support line that names both 2026-11-05 14:02:27 +01:00
Niko1221 c3b3f50355 Merge f-0214-int d7f5c6dd1 (Strix first-launch plan fix: expert cache auto on UMA, Python only) into f-0211-rc 2026-11-05 12:54:06 +01:00
Niko1221 d7f5c6dd11 Merge f-0214-strixplan 0c0b7b875 (Strix plan fix) into f-0214-int 2026-11-05 12:53:44 +01:00
Niko1221 0c0b7b8751 setup: uma_holds_experts is never true on a Mac (the signed Mac plans stay exactly as they were); test 2026-11-05 12:29:14 +01:00
Niko1221 86ffdbeb17 setup: on a unified-memory GPU a v3.1 Unsloth set whose experts fit the shared pool gets expert cache auto and nothing pinned (UD-IQ2_XS's first launch failed with the fixed cache and the pinned part on a 64 GB Strix Halo); the other sets keep their plans; tests 2026-11-05 12:25:53 +01:00
Niko1221 aac067021c Merge f-0214-int e2b53784 (METAL test-only change: metal/tests/moe_mm_parity.cpp) into f-0211-rc 2026-11-05 11:25:35 +01:00
Niko1221 e2b5378403 Merge f-0214-metal ba5bef2d6 (parity test: IQ4_XS gate/up runs v2) into f-0214-int 2026-11-05 09:42:34 +01:00
Niko1221 ba5bef2d61 moe_mm_parity: IQ4_XS gate/up runs the v2 path (v1 is not an engine path and has no IQ4_XS kernel) 2026-11-05 09:41:13 +01:00
Niko1221 3e0210663b Merge f-0214-int 437668151 (round 0214: the new Unsloth Qwen quants, the Q5_0 dot fix, the Mac app branch) into f-0211-rc 2026-11-05 09:39:50 +01:00
Niko1221 437668151d Merge f-0214-macapp 408a5ca37 (Mac app offers Unsloth sets) into f-0214-int 2026-11-05 09:37:11 +01:00
Niko1221 0bdcef4682 Merge f-0214-engamd aa678f10d (Q5_0 expert-dot fix) into f-0214-int 2026-11-05 09:35:26 +01:00
Niko1221 408a5ca37b tests: the Mac model list includes Unsloth's sets 2026-11-05 09:33:32 +01:00
Niko1221 10df7ace1d tests: the Mac app's running-engine hint 2026-11-05 09:25:00 +01:00
Niko1221 d6eaab500f Mac app: the running-engine hint says to unload the model in Strata (approved wording); test 2026-11-05 09:24:49 +01:00
Niko1221 aa678f10d0 Q5_0 expert dot: the -16 offset takes the sum of the quantized activations (CUDA/HIP and SYCL), like Q5_1 and Q4_0
vec_dot_q5_0_q8_1 used the q8_1 block's ds.y (the sum of the original activations). With the SwiGLU outputs (not zero-mean) the whole-expert error with Q5_0 down was 2.1e-2 .. 3.1e-2 against 1.2e-2 for every other down type. Now 1.2e-2, and the GPU agrees with the CPU path. The Q5_0 parity limit is tightened to 1.8e-2.
2026-11-05 09:07:47 +01:00
Niko1221 af1ffd913a Mac app: Unsloth's sets are offered and installed as the Terminal's setup_mac does (metal_repack to one GGUF, --ple-gguf the table's shard, disk counts the repacked file); the repack tools ship in the installer bundle; tests 2026-11-05 09:05:01 +01:00
Niko1221 edb7c4819b native_grouped_parity: pin the CUDA layout (STRATA_EXP_MODE=0) on gfx1151, where mode 8 and the strided call sum in a different order 2026-11-05 08:54:01 +01:00
Niko1221 74a4441354 Merge f-0214-engamd into f-0214-int 2026-11-05 08:53:25 +01:00
Niko1221 bee9168114 Merge f-0214-engnv into f-0214-int 2026-11-05 08:53:25 +01:00
Niko1221 f699fc0a7d Merge f-0214-metal into f-0214-int 2026-11-05 08:53:24 +01:00
Niko1221 96d48dff45 Merge f-0214-catalog into f-0214-int 2026-11-05 08:53:24 +01:00
Niko1221 99cbdd5592 tests: the Terminal model list needs a taller screen for the seven new rows; adopt's unsupported-family example is nvfp4 2026-11-05 08:45:15 +01:00
Niko1221 7faeb8c264 Parity tests: v1-vs-new grouped diagnosis (differing words listed), v1 against the float reference in native_expert_parity, Q5_0 gets 32-value scale blocks in the grouped test's random weights 2026-11-05 08:41:41 +01:00
Niko1221 39ea3e5216 launch: Unsloth's sets are written for an Intel Arc card (adopt.FAMILIES), without --ple-gguf; test 2026-11-05 08:30:25 +01:00
Niko1221 60ab1e52f3 catalog: the MCP tool description says Unsloth's 1- to 8-bit files 2026-11-05 08:11:01 +01:00
Niko1221 bfbe7fab2b MMQ parity screen: skip the trellis types when the ggml has no host traits for them (it read past the type table on Windows) 2026-11-05 08:06:52 +01:00
Niko1221 85e7c83d96 catalog: the Unsloth family credit line reads 1- to 8-bit 2026-11-05 08:05:38 +01:00
Niko1221 199fbc7a45 catalog: approved wording (about lines, SUPPORTED_GGUFS, UD-IQ1_S reason, Unsloth family line); METAL_Q5_0_DOWN on (f-0214-metal cbef1a9ff) so the Mac lists all seven sets 2026-11-05 08:04:15 +01:00
Niko1221 9ed3c77899 catalog: Unsloth v3.1 Qwen3.8-Flash-Next sets (UD-IQ1_M, UD-Q2_0, UD-IQ2_XS, UD-Q2_K_XL, UD-IQ3_XXS, UD-Q3_K_XL, UD-IQ4_XS-v31) at d9620119 with a per-model revision; UD-Q4_K_XL's four-shard layout accepted from local files; manual-load refusal lifted (UD-IQ1_S stays refused); mac_fit switch for the Q5_0 down sets; header-only fixtures and tests (about lines pending wording) 2026-11-05 07:23:41 +01:00
Niko1221 c1d5e4d5e5 Tests for the new Unsloth Qwen3.8-Flash-Next expert and dense type combinations (HIP, CUDA, SYCL)
native_expert_parity: --bench decode micro-number, +0/-0 is no fp16 difference.
Registers every gate/up x down pair of the new sets (CUDA and HIP, SYCL), a dense type test
(native_dense_types_parity), Q5_0 down in native_grouped_parity, new-set types in the MMQ screen,
and a --synthetic mode in the SYCL native_expert_parity.
2026-11-05 07:22:34 +01:00
Niko1221 14a4636b83 native_dense_parity: Q4_0/Q5_0 decode tolerance 4e-2 (offset term uses the unrounded activation sum, as llama.cpp CUDA) 2026-11-05 07:19:16 +01:00
Niko1221 5d56ff8c06 Parity tests for the Unsloth v3.1 Qwen3.8-Flash-Next quant mixes (CUDA)
- native_expert_parity: ctest entries for every gate/up x down pair the new sets use
  (IQ1_M, IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS, IQ4_XS gate/up; Q2_0, IQ4_NL, Q5_0, Q8_0 down)
  at hidden 2560 / FF 640.
- native_dense_parity: dense Q2_0..Q6_K, IQ4_NL, IQ4_XS decode (MMVQ, 1-8 columns) and the
  prompt dequantizers (f32, f16, bf16) against ggml.
- prefill_mmq_v31_test: the new expert formats through MMQ (Q5_0 only in a
  STRATA_ORCA_Q4KS_MMQ build), plus a --bench of Q5_0 MMQ against dequantize + cuBLAS.
2026-11-05 07:18:29 +01:00
Niko1221 cbef1a9ff6 Metal: Q5_0 down experts, decode (moe.metal, moeu) and prompt (moe_mm v1/v2/v3), host tiles, honest gu_qk/d_qk; parity tests take Q5_0 2026-11-05 07:14:30 +01:00
Niko1221 2b8179f3e6 Merge f-0213-int ce4197ce8 (AppImage ELF check: an ELF test instead of 'every ET_DYN is a static PIE', #4337) into f-0211-rc 2026-11-05 05:29:02 +01:00
Niko1221 ce4197ce81 appimage_elf: a shared library is not a static PIE; the check tells them apart without name rules (#4337 checker)
The checker treated every ET_DYN without PT_INTERP as a static executable, so the RUNPATH $ORIGIN that Pillow, numpy and sharp libraries carry was
reported as 'static PIE with a damaged dynamic section' (35 files in 0.2.11, 18 Pillow libraries in the published 0.2.10 Control; false positives).
A static PIE is now an ET_DYN with no interpreter, a non-zero entry point and DF_1_PIE in DT_FLAGS_1 and no DT_SONAME; a library (no entry point, or a
SONAME and no PIE flag) is never checked. A candidate whose dynamic section cannot be read is reported as damaged, because the overwrite that crashes
rg also erases the flags, so the test for 'executable' uses the entry point, which the damage does not touch. Tests (tools/test_appimage_elf.py, 16, run
on p1006): shared libraries with and without a SONAME, entry point or RUNPATH pass; a static PIE passes; a static PIE with a 0x5A .dynamic or an added
RUNPATH fails; a real gcc shared library with RUNPATH $ORIGIN passes; a real gcc static PIE passes and its overwritten copy fails. Build tool only.
2026-11-05 05:24:48 +01:00
Niko1221 3fda37bd8c Merge f-0213-int 3c4afa8d7 (Models page: no Connect engine / Download & load flash after a start) into f-0211-rc 2026-11-04 23:37:34 +01:00
Niko1221 3c4afa8d78 QAGUI #2 (source): /api/native says checking until Control has answered; a failed start-time read is retried in 3 s, not held 45 s; page shows Checking, not Connect engine 2026-11-04 23:26:47 +01:00
Niko1221 975eaab2bb Version 0.2.11 / engine 0.1.57: Terminal and app 0.2.11, Control and every engine package 0.1.57 (the 0.2.10 bump list: strata_cli, apps/host, apps/desktop, Cargo, CMake, Metal, the build scripts, the version tests, AGENTS.md, the bug-report template) 2026-11-04 23:23:33 +01:00
Niko1221 0fa4288d30 markdown: keep the list line as it was written (a source guard in ui-do.test.mjs reads it) 2026-11-04 22:34:01 +01:00
Niko1221 90c77357fb markdown: a file with Windows line ends no longer runs the page out of memory (follow-up to 68bead108)
The sidebar now shows a file's current content, so a CRLF file reaches the Markdown renderer. Every line then ends in a carriage return, which '.'
does not match: a list line passed the list-start test but not the item pattern, the list loop took no line, and the outer loop never advanced - an
endless loop pushing nodes (150 MB to 4.4 GB in 20 s on a 60 byte file; QAGUI final pass, the agent's Add-Content writes CRLF). 0.2.10 never showed
the changed text so it never met it; the bug is older and any CRLF Markdown list opened in the preview would hit it.
Line ends (CRLF, CR, LF) are read as one in the renderer and in the preview slicer, and the list loop always moves on (a line the item pattern rejects is
shown as text). Test: tests/markdown-crlf.test.mjs renders in a child process with a 200 MB cap and a 20 s limit; on 68bead108 all 4 cases abort out of
memory, with the fix all pass. Edge 156, seeded live turns (stream, finish with the card, file rewritten with CRLF, cards opened 6 times): heap stays
near 19 MB with the fix. No visible text.
2026-11-04 22:10:41 +01:00
Niko1221 1814da7c44 Merge f-0213-overlay (aba888972) into f-0213-int 2026-11-04 21:49:55 +01:00
Niko1221 64b48c0d00 Merge f-0213-qafix into f-0213-int 2026-11-04 21:49:19 +01:00
Niko1221 5d363f11ea Merge f-0213-qatui into f-0213-int 2026-11-04 21:49:19 +01:00
Niko1221 aba888972a select: the list is placed by the shared overlay function, as tall as it needs and no taller than the room above or below its button
The Select popup (every dropdown in Settings, Models and the tune dialogs) was capped at 320 px whatever the window and opened upward through a
transform, so in a short window or at a large Interface size it scrolled although the window had room, or started above the top of the window
(Edge 156 matrix: Select popups 4 of 72 and 3 of 72 cases right on 548fdf061, 69 of 72 and 61 of 72 after; the rest are harness not-open rows).
It now uses lib/overlay-place.ts like the menus, measures its real height once it is on the screen, and writes maxHeight. The Mode menu and the
sidebar More menu already passed on 548fdf061 (77 of 77, 84 of 84; 43 of 77 and 49 of 84 on v0.2.10). No visible text.
2026-11-04 21:33:24 +01:00
Niko1221 a87c399c91 F12: French s9038 uses the typographic apostrophe (french-catalog test) 2026-11-04 20:35:53 +01:00
Niko1221 e10be676b1 F13: the ? key list wraps at the keys on narrow terminals (80x24 and 60x16) so every key shows 2026-11-04 20:31:12 +01:00
Niko1221 0269e2c42a F13: the monitor's ? key list stays up on every view instead of vanishing at the next poll 2026-11-04 20:26:01 +01:00
Niko1221 6f5cf9f6b7 nvidia-smi is asked once per field and 30 s: one launch --plan on Windows with an engine installed started it 19 times 2026-11-04 20:24:56 +01:00
Niko1221 be2d275361 QAGUI #2: Models page waits for Control's installed list before offering Download & load 2026-11-04 20:24:31 +01:00
Niko1221 c7272c70db QAGUI #1: start-up reads have a time limit and bounded retries; optional reads no longer hold back live 2026-11-04 20:23:27 +01:00
Niko1221 8768e95f12 F12: port-denied text names the right cause on Linux and macOS (QATUI F12)
Windows keeps the Hyper-V/firewall text. serve listen_problem and the host
ports.mjs pick by platform; new locale key s9038 in 9 languages.
2026-11-04 20:21:11 +01:00
Niko1221 548fdf061e Merge f-0213-life (3e009d2b7, process-tree test hardening) into f-0213-int 2026-11-04 19:15:09 +01:00
Niko1221 3e009d2b75 tests: process-tree waits on the real conditions (a process that is running, not a zombie; the child's own ready line; a marker file) with long limits instead of fixed delays 2026-11-04 18:59:35 +01:00
Niko1221 e82c706981 Merge f-0213-preview (68bead108) into f-0213-int 2026-11-04 18:46:17 +01:00
Niko1221 68bead1082 preview: the sidebar shows the file's current content after it changed on disk (follow-up to #5180)
The preview kept a file's first content: openPreview dropped the freshly read item when its id was already a tab, the panel's loading effect had no
content version, and the effect that re-adds a message's artifacts put the stored first copy back. All three change together, through pure rules in
lib/preview-merge.ts: opening a changed file replaces its tab in place; a message's artifact only adds a missing tab or replaces an older, different
copy (each copy carries the time it was read or made) and never overwrites a newer one; both return the same array when nothing changed, so no update
and no loop follows. The panel reloads when (id, content version) changes: a hash of the text, or the host's mtime and size for pictures and PDFs
(/api/files/preview now sends mtime). The reading view is reset by the file, not by its content. Tests: stale case, 2000 alternations without a
change, one load per real change. Real Edge 156 with the real panel: the old rules keep Version One, the new ones show Version Two through 300
sync/render alternations with 0 Markdown renders. No visible text.
2026-11-04 18:45:06 +01:00
Niko1221 85269ca155 Merge f-0213-guidata (final 1d5096beb) into f-0213-int 2026-11-04 18:43:27 +01:00
Niko1221 1d5096beba Privacy note: conversations stay on this computer, in Strata's data folder (owner wording; all nine languages) 2026-11-04 18:33:30 +01:00
Niko1221 e647e132d5 locale-gaps: the notice catalog (sav-en) is a known catalog (#5192) 2026-11-04 17:59:26 +01:00
Niko1221 8ad605789b agent turn: a reloaded page takes a running turn over again; chats resume after they are read from the host
A reload does not stop an agent turn, but the page that came back showed "Network error. Try sending the message again."
with no Stop button, and a new message was refused with "This chat is still working". The host now answers GET /api/chat/active
(the chats with a turn running) and POST /api/chat/attach: a running turn sends one catch-up (the start, the text and thinking
so far as a resync frame, every tool card as it is now with the approvals waiting for a yes, the open questions) and then its
live frames; a turn that ended while the page was away is shown whole once, named by the request id of the cut-off reply.
At start the page asks for the running turns, takes each over in the reply it already has (running, Stop button, the
failure it wrote is cleared), and asks once about cut-off replies of the other recent chats; nothing to take over leaves the
reply as it was. Existing states and texts only.

The things that used to run at bootOnce from the chats in memory (follow time-skip runs, end loops the last window left running,
mark tool cards of vanished turns) now run once the chats have been read from the host (resumeSessions), because the chats are
no longer in the page at that moment. The save retry waits a little longer each time (up to a minute) and never keeps a closing
page alive. Also: tests for a second window's changes (applyChatChange) in the parallel-chats test.
2026-11-04 17:48:03 +01:00
Niko1221 792b626980 Merge f-0213-secure (fix 0855796e6) into f-0213-int
Conflicts in strata_cli/bench_facts.py (the redactor), three hunks: kept every side. The patterns stay QATUI's (the key and the names are matched as whole tokens, a hyphen next to the key does not hide it, the names are not touched inside a label the function wrote), each entry carries SECURE's third field (is a login or host name) for redact_document, and the host names come from socket.gethostname() and sysinfo.node() (LIFE), both marked as names.
2026-11-04 17:40:48 +01:00
Niko1221 cd66956ca6 Merge f-0213-tuicfg (fix 71395ec9c) into f-0213-int 2026-11-04 17:38:00 +01:00
Niko1221 16904848bc setup: the low-RAM mode's copy of the experts is checked for room where it is written (the data folder's packs folder), and the refusal names that folder in the approved words instead of the model folder; test 2026-11-04 17:35:55 +01:00
Niko1221 0855796e68 bench: results.json redacts the machine's login and host name only in the text people and servers wrote, not in the report's own words (#4484)
On a PC whose login is an ordinary word ("user", "test", "admin") the whole-document pass turned status reasons and field names into <user>, and the report failed its own validation. Paths, addresses and the key are still replaced everywhere; the name rules apply to hardware, model and configuration text, error messages, engine options and request checks.
2026-11-04 17:35:01 +01:00
Niko1221 71395ec9c7 setup: the pinned-shard mark helper needs no 'hf' key (the NVFP4 families have none) (#4088)
main() and mac_control_setup() read fam['hf'] to build the pin lookup URL; the NVFP4 families download a file list and have no such key, so every NVFP4 install raised KeyError.
The helper takes None and then keeps the old mark. Found by the INT3 merge run (tools/test_setup_nvfp4.py, tools/test_setup_dl_fix.py).
2026-11-04 17:33:39 +01:00
Niko1221 9cb1dc6454 App Host: the start-up probe for the share port listens on the loopback address, not on 0.0.0.0 (it made Windows Defender Firewall ask 'allow Node.js on these networks?' on the first start of every new install path); test records every listen() of a started host 2026-11-04 17:33:27 +01:00
Niko1221 b247f4ecda Merge f-0213-qatui (final d33d45ade) into f-0213-int 2026-11-04 17:33:14 +01:00
Niko1221 d33d45ade2 update --check on a source checkout prints the tree's own version for Control instead of '?' (display only; decide_control is unchanged) 2026-11-04 17:20:23 +01:00
Niko1221 cabb271145 launch: 'A new API key was made' is said only once the run config holding the key has been written (never in the plan or before a refusal) 2026-11-04 17:19:20 +01:00
Niko1221 094bb3f0a3 chats are files in the profile's data folder, not browser storage: the 5 MB limit is gone (#5192)
The app host saves each chat as its own file (data/chats/<id>.json, an index, a version and a revision in
every file), written to a temporary file, flushed and renamed over the chat, so a crash or a full disk in
the middle keeps the older version whole. The folder and files are owner-only. The page talks to the host
through /api/chats (the page must name itself with x-strata-client; other sites are refused), sends only the
chats that changed, never cuts anything, and keeps a chat the host cannot write whole in memory, retries it,
and says so with the notice and the "Copy unsaved chats" button in all nine languages. A save from an older
copy (a second window) keeps the version it displaces in chats/conflicts. The browser's two older layouts
(the 0.2.10 list and the per-chat entries) are handed to the host once at start and left untouched as a
fallback for one release; a last-moment copy covers a page that is closed before the host confirmed a save.
Other windows are told by an event and take over what changed.
2026-11-04 17:15:42 +01:00
Niko1221 0481347ec0 bench redaction: a key next to a hyphen is replaced (it leaked in results.json), and redacting twice gives what once gives (no <<user>>) 2026-11-04 16:04:18 +01:00
Niko1221 b75e8a975d launch: a profile or run config of the launch that cannot be read stops it before anything is written, so the API key is not replaced (F1, found on l620); owner-approved wording 2026-11-04 16:04:18 +01:00
Niko1221 2204582932 settings file check: args_written, user_args and gpus_by, which setup itself writes, are known keys (a freshly set-up model showed 'args_written is not a setting Strata reads' in the settings panel); test reads SETUP_KEYS from setup.py 2026-11-04 16:00:01 +01:00
Niko1221 22a3d70688 Merge f-0213-guidata (4c4596746) into f-0213-int 2026-11-04 15:59:53 +01:00
Niko1221 d8b6ce356c Windows: the display-adapter CIM query is asked once per process and 2 minutes instead of 18 times in one launch --plan (a stuck WMI cost 18 x 15 s, #4975) 2026-11-04 14:45:52 +01:00
Niko1221 d73ce8608e bench run --profile: SIGTERM ends the run like Ctrl+C, so the server and engine it started are stopped; an interrupt during the model load stops them too (found on l620 and m1001) 2026-11-04 14:45:52 +01:00
Niko1221 127d829018 Merge f-0213-life (final 74f390538) into f-0213-int 2026-11-04 13:43:15 +01:00
Niko1221 4c45967460 chat titles are cut at a character through shortTitle, not in the middle of an emoji (#4533) 2026-11-04 13:34:07 +01:00
Niko1221 74f390538c serve: the error of a request that ended without an answer says the next request restarts the engine only where the engine was really ended (#3961) 2026-11-04 13:34:06 +01:00
Niko1221 50c2db6905 merge f-0213-guidata-shell (#5180 #5079 #5071 #4653 #4533 #4337) 2026-11-04 13:33:50 +01:00
Niko1221 a8767488ce serve: a request that ended without its end event ends the engine only if it is still the process that request ran on and nobody has the turn, so the next request is never killed with it (#3961) 2026-11-04 13:18:36 +01:00
Niko1221 f4d8688e8c merge f-0213-guidata-avail (#4397 #4535 #4221 #4878 #3750 tests) 2026-11-04 12:27:00 +01:00
Niko1221 4d482e627c Merge f-0213-own (final daf4ac087) into f-0213-int
Conflict in serve/server.py main(): LIFECYCLE wraps _main in the shared activity lease (#4463), OWN wraps it in the decoupled console (#4479 / #2481). Kept both: the lease is taken first, and _main runs inside decoupled_console() unless STRATA_BLOCKING_CONSOLE=1; the lease is closed in the same finally.
2026-11-04 12:26:56 +01:00
Niko1221 66331f9bc0 Merge f-0213-life (final 576d06553) into f-0213-int
Conflict in strata_cli/bench_facts.py (the redaction helper): kept both sides' intent. The host name comes from sysinfo.node() (LIFE) and the key is redacted with usable_key(key), word=True, exact=True (SECURE).
2026-11-04 12:24:01 +01:00
Niko1221 daf4ac087a serve --batch: the drain and quarantine limits count silence, never progress (#4451 follow-up)
DRAIN_TIMEOUT_S (300 s) and QUARANTINE_S (600 s) apply only after a request has left and the server has sent STOP / BSTOP:
_drain_control (the finally of generate_batched), _quarantine_control and the BDONE wait of _release_slot_when_done. They
never cover a generation a client is still reading. But they were counted from the start of the drain, so an engine that
was told to stop and kept producing lines (an older engine runs on to max_new; a long prompt read ends at its next chunk)
could be ended after 900 s, with every other request in it. Now every line the engine writes starts the limit again: only
an engine that says nothing at all for 300 s + 600 s after the stop is ended.

Tests (fake engine, limits scaled to 0.3 s + 0.4 s): an engine that keeps talking for ~1.8 s after the stop is not ended and
the request in another slot is untouched (fails with the previous commit's absolute limits); a silent one is ended; the
drain-timeout leak test now really closes the stream mid-read (the old version was ended by a BYIELD before the drain);
long replies, far over the limits, complete. Also: a client that is idle while the engine thinks is not cut by the 60 s
write limit (0.3 s between tokens, 0.1 s limit, the whole reply arrives).
2026-11-04 12:18:12 +01:00
Niko1221 576d06553c launch: _host_platform imports sysinfo (the earlier edit left the name undefined, #4975) 2026-11-04 12:10:31 +01:00
Niko1221 2bfd151578 tests: the adopted-load test lets Control's first answer be 'starting' and the next 'failed' (#4293) 2026-11-04 11:59:36 +01:00
Niko1221 049ae1c678 Merge f-0213-tuicfg (final c6dda74bf) into f-0213-int 2026-11-04 11:45:45 +01:00
Niko1221 39851463bb serve --batch: vram() and session_file() take the control lines like a request (#4451, same class)
On a batch engine the control lines carry one exchange at a time and every request takes `ctl` before it sends or reads
there. VRAM and SAVE / RESTORE read the same lines for their answer without it (POST /v1/vram took it itself, the image
encoder's cache resize, the load-time reserve and the session handler did not): run beside a request they took its
T / PP / DONE lines, and the request lost its reply (or session_file ended the engine as out of step).

StrataEngine.vram / session_file now run under _exclusive_control(): ctl on a batch engine (a thread that already holds it,
as Service.vram does, is marked and does not wait for itself), nothing to take for one request at a time. Waiting longer
than CONTROL_WAIT_S raises the existing "a request is still running; try again when it has finished". No output changes.

Test: the fake engine answers VRAM / SAVE / RESTORE; a request streams while the call is made. On the base the request
ends with 212 of 421 tokens; with the fix it is complete and the call is answered.
2026-11-04 11:43:02 +01:00
Niko1221 3c95402c4f tools: stream_ownership_probe.py - interleaved streaming clients with canaries and random disconnects against a running server (#4451, #2892)
Each client puts its own random canary in its prompt and asks the model to repeat it; some leave before the first byte,
during the prompt read or after a few chunks. Every text a client receives (content and reasoning) is searched for the
canaries issued in the run: a canary that is not the client's own is a cross-delivery (exit 1). Used for the real-engine
A/B of the round 0213 ownership fixes.
2026-11-04 11:35:16 +01:00
Niko1221 b776e1cd02 serve: console output goes through a bounded queue, so a blocked console cannot hold a request (#4479, #2481)
print() in the server blocks when the console stops taking output (a Windows console in selection mode, a paused
terminal, a launcher that stopped reading the pipe), in whatever thread called it - including those that own the
engine's turn, a cancel or an admission. While serve main() runs, stdout and stderr (when they are the process's own)
are replaced by serve/nonblocking_out.NonBlockingOut: write() queues and returns, one daemon thread writes to the real
stream, the oldest text is dropped (and counted) when the queue is over 1 MiB / 4000 chunks. Restored and drained at the
end of main(). Protocol output (engine pipe, HTTP) is not touched. STRATA_BLOCKING_CONSOLE=1 turns it off.
No visible text is added; a drop is counted, not announced.

Tests: a plain print to an unread pipe blocks (control); the wrapper never blocks, keeps the newest lines in order, and
drains. Approach credit: the proposal in #4479 (one process-owned output consumer).
2026-11-04 11:26:31 +01:00
Niko1221 c08fd2ecc8 serve: a streaming client that stops reading no longer holds the engine's turn (#4271, part 1)
The thread that writes the SSE stream to a client also holds the engine's turn (the one-request path). A client that
stops reading without closing (a hung process, a dropped network) filled the socket buffers and the write blocked until
the OS gave the connection up: the engine idle, every other request waiting. A write that makes no progress for
STREAM_WRITE_TIMEOUT_S (60 s; STRATA_STREAM_WRITE_TIMEOUT_S, 0 = off) now ends as a disconnect: the engine is
stopped and the turn given back. No text changes.

Test: a client that never reads a 400k-token stream; on the base the turn is still held after 20 s, with the fix it is
given back at once. Part 2 of #4271 (arrival order of the one-request path: the turn is a plain Lock) is not done here.
2026-11-04 11:26:30 +01:00
Niko1221 97da46cc1c Merge f-0213-secure (f374b5029) into f-0213-int 2026-11-04 11:21:31 +01:00
Niko1221 f1fd753de8 serve: a request waiting for the control lines does not miss a wake-up (#4738)
_take_control read its turn, gave up the lock and then waited: a notification sent in between was lost and the waiter
slept up to 0.5 s although it could go. It now reads the turn (and the short-first arbitration) again under the lock
before waiting, and a waiter that leaves (cancelled, closed) notifies the others.

Approach credit: PR #4738. Tests are new (a hooked Condition forces the schedule; on the base the waiter waits).
2026-11-04 11:16:42 +01:00
Niko1221 b3c6338689 serve: a refused batch admission is an error row, not a completed request; engine output with a non-UTF-8 byte is read (#5044)
- GpuBusy before any segment and with zero tokens: the history row says "error" with no prompt tokens and takes no part
  in the totals, latencies or last timings (#3040); a continuation after native work keeps the old accounting;
- the engine's and the image encoder's stdout are decoded with errors="replace": on Windows a byte such as 0x8e ended
  the reader thread (UnicodeDecodeError) and the server lost the engine's output. Protocol lines stay ASCII.

Not taken from PR #5044: the q36 engine OOM messages (engine C++, and the wording goes to the coordinator first).
Approach credit: PR #5044 (Python parts). Tests: test_parallel (refused admission accounting), test_serve_robustness
(a 0x8e byte before READY and in a reply).
2026-11-04 11:16:42 +01:00
Niko1221 0d922698f2 serve: a failed engine start no longer crashes when another thread closed the process (#4792)
StrataEngine.__init__ cleaned up a start that ended before READY through self.proc; restart()'s close() on another
thread had set it to None (AttributeError: NoneType has no attribute stdin, a recoverable engine abort became a 400).
The cleanup now uses the process object of THIS start, captured once right after it was spawned.

Approach credit: PR #4792 (same idea; the test is new: the closing thread is forced inside proc.wait()).
2026-11-04 11:16:42 +01:00
Niko1221 3f6960f1fc serve: ConvCacheLog.poll runs under a lock (#3443)
Polls come from several request and monitor threads. A second poll moved `pos` between the first one's size check and
its read: lines were read twice (parks counted double) and a read length could go negative (ValueError). Found by
HWSTALL while triaging #3443.

serve/test_serve_robustness.py: the overlap is forced with a hooked getsize; on the base every parked line is counted
twice (8 != 4).
2026-11-04 11:16:41 +01:00
Niko1221 65cff54c0d serve --batch: every frame and the end of an exchange belong to one request on one engine process (#4451)
A --batch request read its slot's lines from `self.slot_q[slot]`, the control lines from `self.lines`, and sent its
BSTOP / BYIELD / STOP with the slot number, all looked up when used. After the engine was restarted the same names meant
the NEW process: a request of the old one read (and could stop) the new one's requests. And a request that left before
the engine had ended its exchange (a drain that timed out after 300 s) gave the slot and the control lines back, so the
engine's late T / PP / DONE / BADM / BT frames reached the next request as its own.

- the request notes the queues of the engine process it was admitted on and reads only those; a send meant for that
  process (BSTOP, BYIELD, STOP) is not sent to a later one (EngineDied instead); slot state is only given back on the
  same process;
- an exchange that is not drained in DRAIN_TIMEOUT_S is quarantined: the control lines and the admission's slot stay with
  it, in the background, until the engine gives DONE / BADM (then a continued admission is BSTOPped and freed at its
  BDONE); if the engine never does within QUARANTINE_S it is ended, as a silent engine is (#481). A slot's BDONE wait that
  times out ends the engine too, instead of freeing a slot whose lines may still come;
- the pump drops BT / BDONE frames for a slot no request has claimed (counted in stale_frames);
- _ctl_mode, _ctl_result, _yielded, _last_done, _ctl_erred are per request thread on a batch engine;
- serve/test_stream_ownership.py (BatchOwnership): interleaved clients with canaries, a cancel in the middle of the prompt
  read followed by reuse, a drain that times out (DEAFSTOP), the engine ending during a drain and restarting. On the base
  the restart case fails: requests of the old process stay attached to the new process's slots.

The other cases of the register (mid-prefill cancel, drain timeout, slot not handed on) pass on the base with the fake
engine; they are kept as regression tests. The real-engine check follows.
2026-11-04 11:16:19 +01:00
Niko1221 66385404ff replicas: a request owns its replica's pipe until its native exchange is over; DONE figures are the caller's (#2892)
ReplicaEngine.generate released the per-replica run lock in its finally without closing the child generator. The
child's own STOP and drain-to-DONE ran later, when the wrapper frame was collected, after the next request had sent
its GEN: the two read each other's tokens and DONE (an empty reply, or another request's text), and a cleanup error
was lost as an ignored generator exception. The unbatched engine's shared `last` was also read by Service after the
lock was free, so a reply could carry the next request's figures.

- close the child before the lock is given back; a close that fails ends the replica (heal() starts it again) and the
  error still reaches Service (EngineSilent handling is unchanged);
- copy the new DONE figures into a per-caller object while the replica is owned; with no new DONE the caller's earlier
  object stays, so an old result is never recorded as new;
- serve/test_stream_ownership.py: canary replies from a fake engine, a hooked run lock that forces the legal schedule
  (the successor takes the replica right after release), 30 forced hand-offs and 60 unforced back-to-back requests.
  On the base: empty reply for the successor, the predecessor's canary in its text, 30 of 30 forced hand-offs crossed,
  figures of the other request.

Approach credit: PR #2886 (scheme A: explicit child close, caller-local DONE snapshot). Written in this tree.
2026-11-04 11:15:36 +01:00
Niko1221 632b611df9 packaging: check the static PIEs of an AppImage (the bundled ripgrep) and gate the repack on them (#4337)
linuxdeploy rewrites the RPATH of every ELF in the AppDir; for a static PIE (the bundled rg) that leaves .dynamic overwritten and the program does not
start (shown on a real gcc static PIE in tools/test_appimage_elf.py). repack-appimage.sh already put the staged bytes back, but --check covered only
engine-bundle and node, nothing ran the rg, and nothing stopped a pack with a damaged one. appimage_elf.py scans for a damaged .dynamic (garbage tags,
RUNPATH on a static PIE, filled relocation table, filler runs) and compares to the staged files; --check now covers control, harness, node and engine-bundle,
scans, and runs the bundled rg; the pack path runs the same check before it squashes. Whether the released AppImages were repacked is outside this repo.
2026-11-04 11:11:56 +01:00
Niko1221 f8f97d9f36 titles: a chat title is cut at a character, not in the middle of an emoji (#4533, helper only)
shortTitle() cuts at a grapheme (Intl.Segmenter) or a code point, so a title never ends in half an emoji, a lone surrogate or a ZWJ. lib/store.ts still
cuts with prompt.slice(0, 47) in three places (not edited here: the GUIDATA owner's file); they should call shortTitle. Tests cover ordinary emoji,
ZWJ families, flags, skin tones, keycaps, combining marks, Arabic, Hebrew, CJK, and a JSON save and restore.
2026-11-04 11:11:56 +01:00
Niko1221 5af35d050e overlays: menus and popovers are placed in screen pixels and written in the page's pixels, so the sidebar More menu opens at any Interface size (#4653)
Interface size scales the page with CSS zoom on the root. In WebView2 (Edge 156) getBoundingClientRect and innerWidth are screen pixels, but left/top/
width/max-height written on an element are zoomed pixels, so a menu placed from its anchor landed (scale - 1) too far right and down: at 125% on a
1080p window the More menu was entirely off the screen (14 of 18 size and scale cases broken, measured in Edge 156). lib/overlay-place.ts places in
screen pixels (below, else above, else the roomier side shortened, kept inside the window) and divides by a scale measured from the page at the end.
Used by FloatingMenu (More, chat actions, preview file actions), Popover, the Select popup and the two tooltips. No layout or text change at 100%.
2026-11-04 11:11:55 +01:00
Niko1221 c6dda74bf2 tune: when candidates were skipped for memory the result says so instead of 'already the fastest' (#5122)
'N settings could not be tried: not enough free memory' is the result message (and the Terminal's tuning line) only when some candidates were skipped for memory
in a result that otherwise keeps the current settings; the structured outcome is unchanged and values.not_tried carries the count. Owner decision 11-04.
2026-11-04 11:06:16 +01:00
Niko1221 64abc62393 linux window: keep the COLRv1 colour emoji font away from WebKitGTK, which aborts drawing it (#4533, idea of #4485)
The window process (only it) is pointed at a fontconfig file that includes the system configuration, rejects *COLRv1* font files inside
<selectfont> and prefers the outline Noto Emoji. Written once under the data folder, only when a COLRv1 font is installed; FONTCONFIG_FILE already
set, STRATA_FONT_CONFIG=off or =<file> leave it alone. fontguard.rs is plain std with unit tests that run on every platform. The three variables
join the crash report. Also: the desktoplog crash-file test asserted the literal 0.2.0 and failed on any other version; it now follows the
crate version. Linux-only wiring (run_window_process) is not compiled on this PC; no WebKitGTK here to show the crash or its absence.
2026-11-04 11:02:10 +01:00
Niko1221 25fe8bca8e monitor: the Windows drive-temperature reader asks NVMe drives only, starts only for the Monitor screen's own ask and not in the first minute, leaves a marker across a crash and gives up a stuck first pass (#4661) 2026-11-04 11:00:21 +01:00
Niko1221 d0f86f8359 tuning: a memory wait that found nothing freed is not repeated for every later candidate (#4895) 2026-11-04 11:00:21 +01:00
Niko1221 c30aa64754 serve and update: a server started by running serve.py holds the activity lease so strata update sees it, the update defaults to the checkout's engine store, the server's own picture encoder is released when its engine died, and a stream that ends without its end event is an error that restarts the engine (#4463 #4352 #3961) 2026-11-04 11:00:21 +01:00
Niko1221 8431dde5d4 bench run --profile: the engine tree it starts is put in a job object and ended through it, so a refused taskkill no longer leaves it running (#4260) 2026-11-04 11:00:20 +01:00
Niko1221 efaa100b37 host: Stop and a timed-out command end the whole process group on Linux and macOS, as taskkill /T does on Windows (#4529 #4570) 2026-11-04 11:00:20 +01:00
Niko1221 0b5096edd4 app: a start Control stopped waiting for is kept and watched while Control says it is loading, and a ready instance whose engine Control says is gone is marked stopped (#5177 #5059 #4293 #4973) 2026-11-04 11:00:20 +01:00
Niko1221 b526f98104 tests: 256K context + Q4 KV on a 12 GiB / 16 GB card keeps a 1536 MiB reserve and a positive expert cache; the reporter's 3256 file and log (#3750) 2026-11-04 10:56:29 +01:00
Niko1221 fce58233fb App Host: a quiet chat stream carries a beat frame every 15 s so the desktop web view does not drop it; the timer ends with the response; a response that already began ends in the outer catch (#4878) 2026-11-04 10:56:28 +01:00
Niko1221 acbaa4391b connect: the output cap grows with the window, and a rewrite keeps a cap the person set (#5006; #4528 checked, no change)
limit.output, CLAUDE_CODE_MAX_OUTPUT_TOKENS and maxTokens are max(4096, context/8): 16384 at 128K, so OpenCode's compaction summary has room (the tested 32K server keeps
4096). The input limit and compaction point stay as tested. Rewriting a config keeps an output cap other than the 4096 older versions wrote. #4528: the documented
provider block is still accepted by OpenCode's schema (deprecated in favour of 'providers'); no change. Idea as in PR #4230, written here.
2026-11-04 10:56:16 +01:00
Niko1221 5899c5bbfd ui-flows: the saved-chat check reads the per-chat entries (#5192), fixes a syntax slip 2026-11-04 10:55:07 +01:00
Niko1221 380212689a launch: --set mmap-experts takes a word for strata-dsv41 and stays a bare switch for Qwen (#5027)
The engine's flag schema picks which options are valueless: on DeepSeek --mmap-experts on|off|auto keeps its word through derive_config, the one argument
precedence (arg_layers) and a second launch. Approach as in PR #5028, written here.
2026-11-04 10:54:12 +01:00
Niko1221 c3109534d0 setup: a shard with a pinned SHA-256 is hashed before it is marked finished (#4088)
The pre-pass that gave a hand-copied full-length shard the 'whole' mark also marked a shard left by a run that stopped between the rename and the hash check,
so download() skipped it and the pinned hash was never looked at. A file the pin table names is left unmarked now, and download() hashes and repairs it;
--gguf-dir files and files without a pin keep the mark. Both setup flows call one helper. Approach as in PR #4318.
2026-11-04 10:54:12 +01:00
Niko1221 3434ed0e3b composer: the Enter that commits an IME conversion no longer sends, in the event order of each engine (#5071)
WKWebView sends compositionend before the committing keydown, with isComposing false and keyCode 229; WebView2 sends the keydown first. The
composer asks a small guard (lib/ime-enter.ts): a key that says it is composing or has keyCode 229 belongs to the IME, and the first Enter in the
few milliseconds after a compositionend is the commit; the flag is used up by the next key and expires, so the next Enter sends exactly once.
The guard runs before the command menu too. Replay tests for WebView2, WKWebView, WebKitGTK, Latin input, Shift+Enter, a cancelled conversion
and shortcuts. No visible text.
2026-11-04 10:53:24 +01:00
Niko1221 474fe147de desktop: a renderer that dies is recovered, not left on the browser error page (#5079)
Windows: the shell subscribes to WebView2 ProcessFailed; macOS: to the web content process termination. The page's own renderer dying is
written to the run log (kind, reason, exit code, the address it showed) and the page is loaded again after a short wait: the same screen at
/?recovery=1 first, home at /?recovery=2 second, and nothing after two recoveries in two minutes (the window is left as it is). The decisions
are in src/recovery.rs (unit tested). The page reads ?recovery=n before it renders (lib/recovery.ts), removes it from the address, and starts
with the sidebar previews closed until the person opens one or another chat. The chats are untouched. No visible text.
2026-11-04 10:53:20 +01:00
Niko1221 fe4a03e2f5 tuning: the worker ignores STRATA_API_KEY, restart candidates are judged against the base's own free memory, an engine abort shows its own error line and is an OOM (#5056, #5122, #5078)
#5056 the worker is started without STRATA_API_KEY, so it answers to the key the tuner made for it. #5122 a candidate that adds no memory needs what the base run
started with (min of the old peak-RSS rule and base free memory plus the candidate's delta). #5078 a 5xx from the worker adds the engine log's last error lines;
an allocation failure is an OOM, other aborts fail that candidate alone.
2026-11-04 10:44:36 +01:00
Niko1221 a09ab1c781 runtime owner: answers status while it plans and launches, gives the first token its own budget, leaves when its server is gone, follows a process it cannot read only after repeated looks, and is recoverable before its first launch (#5177 #5059 #4293 #4574 #4893 #4250 #4973 #4824) 2026-11-04 10:40:01 +01:00
Niko1221 bae1e1d4d3 serve: a branching follow-up goes to the slot of its chat (#2518)
pick_slot also recognises an earlier message boundary of a slot's held stream (the last question replaced), so the request no longer overwrites another chat's idle slot. A boundary every held conversation shares (one client's system prompt) names no owner; busy slots are never picked. Tests: branch affinity, busy owner, shared root, interleaved branches (retained prefix 96000 -> 115200 of 115200 tokens).
2026-11-04 10:38:48 +01:00
Niko1221 ac944fc1f8 chats sharing a folder: a turn reports only the files its own tools wrote (#5008)
Two chats in one workspace folder: the end-of-turn sweep of chat A listed the files chat B had just written as
chat A's deliverables, and the other way round. A registry shared by the turns of the host (FolderClaims) now
records which chat's file tool wrote which file; the sweep skips files another chat claimed, and for turns that
overlapped in the same folder it also leaves out files nobody claimed (a script's output cannot be told apart),
rather than give them to the wrong chat. A turn that ran alone behaves as before. Chat records, tool cards and
approvals were already keyed by chat id and session id; a store-level test with two interleaved live chats and
the same call id in both pins that down. The per-folder bucket, the shared model cache and the approval-policy
notice described in the issue are Harness and engine behaviour, not changed here.
2026-11-04 10:38:45 +01:00
Niko1221 faf3db296e Web page: /?q= fills in the question and focuses the box; nothing is sent until the person presses Send (#4221) 2026-11-04 10:38:41 +01:00
Niko1221 16ff27af85 App Host: an empty or malformed Host header (or a request target that is not a path) gets a 400 and the host keeps running (#4397); the shared listener compares the API key in constant time over SHA-256 digests, as do the other two header secrets (#4535) 2026-11-04 10:38:41 +01:00
Niko1221 5bf9626fed SYCL server: the engine_args wrapper takes the base signature (exe, announce) and forwards it (#5086)
Every SYCL start died with TypeError: unexpected keyword 'announce' because install_two_card_spec wrapped engine_args(cfg) while serve/server.py calls it with exe
and, for the memory budget check, announce=False. The quiet call prints no line. Tests run the real base function under the wrapper.
2026-11-04 10:38:22 +01:00
Niko1221 f374b5029d quant_formats --hf: the header takes the file's size from the tree listing, so gguf_reader's bounds checks apply (#5075)
Approach from PR #5075. --hf failed on every repository with AttributeError: 'Header' object has no attribute '_size'; a cut or huge-length header is now a ValueError that names the file.
2026-11-04 10:38:09 +01:00
Niko1221 3bbf0f399e tool parser: streamed arguments keep a file that lists the closing tags (#5040, #4701)
Approach from PR #5040: _close_verdict holds while </tool_call> is still arriving, and the streaming scan of a body whose end is known runs as final. #4701 (final call) and #4702 (longer code fence) are already fixed on the base; the corpus gets the four 4701 specimens (PR #4762 and #5040 fixtures) and runs them whole, in pieces and cut at every byte, with streamed arguments on and off.
2026-11-04 10:37:34 +01:00
Niko1221 06eb02b1da engine choice: an integrated GPU no longer outvotes a discrete card; unlisted Arc models and card numbers above 9 are still seen (#4971, #5111)
_drm_vendor_backend skips a make whose every card reports a small own memory (the 512 MiB carve-out of an APU) while another make has a card;
intel_arc_cards reads each entry on its own, takes card10 and up, and counts an unlisted Battlemage/Alchemist id as an Arc of the family's smallest memory.
The launcher's merge of the Arc beside other makes was already in place. Backend half as in PR #5184, written here.
2026-11-04 10:37:15 +01:00
Niko1221 c7596ce873 preview: bound the Markdown preview so an unchanged file costs nothing while tokens stream (#5180)
The sidebar parsed and rebuilt the whole file on every render of the chat. It now renders through PreviewMarkdown: memoised by the text, cut into slices at block boundaries (each memoised, keyed by place and content), two slices at first and two more per waiting step (one timer, cancelled by a newer text), a block above 40000 characters and a file above 200000 shown as plain text. No visible text, no layout change.
2026-11-04 10:36:19 +01:00
Niko1221 ad142b468c launch: one precedence for engine options; an explicit --set draft-conf and calibrated values survive (#5178, #5132, #4985)
Explicit launch values, then persisted overrides (user_args and hand edits to the launch config), then calibrated values (recorded by
engine calibrate --apply-result with the model and context they were measured for), then Auto. derive_config builds the argv once through
strata_control/arg_layers.py; explicit values go to user_args so the DFlash2 policy keeps them, and are not remembered when left out next time.
2026-11-04 10:36:06 +01:00
Niko1221 23dda3099f launch --stop: a failed stop exits nonzero and no longer ends with 'Nothing is running' (#4398)
Failed stops are counted apart from stopped ones; the empty-list line is for zero targets only. No wording changes: the existing lines are reused.
Approach as in PR #4568.
2026-11-04 10:36:05 +01:00
Niko1221 2d5319cef1 launch: a damaged profile no longer lends its neighbour's config or mints a new API key (#4395, #4396)
running_launches binds prof before the try so a failed load uses this row's defaults; kept_key skips list rows that carry only an error
and keeps looking for a valid key. Approach as in PR #4562 and #4563; tests use real temporary profiles in both orders.
2026-11-04 10:36:05 +01:00
Niko1221 ff36fa9282 images: the prompt reaches sd-cli in an owner-only file (--prompt-file), not as a process argument (#4202)
Approach from PR #4204. sd-cli at the pinned stable-diffusion.cpp commit reads --prompt-file as raw bytes (checked in examples/common/common.cpp at 2988060). The job folder is the 0700 mkdtemp one; the file is 0600, created exclusively and removed with the job's other files. The stand-in sd-cli reads the file; a canary test checks the recorded argument list.
2026-11-04 10:36:00 +01:00
Niko1221 4be6a003aa bench: results.json gets the same redaction as the other report files; a credential of "none" is no key; the key is replaced as a whole word in its own case (#4484, #4483)
Approach from PRs #4500 (one redact_object call before validation) and #4599 (none is no key, whole word, own case). tools/strata_bench.py gets the same none rule and key matching.
The 8-character floor of #4599 is not taken: a short key is still removed where it stands alone.
2026-11-04 10:34:11 +01:00
Niko1221 8181522f15 Pillow 13.0.0 on Python 3.11+ (GHSA-78g4-xqwv-q3g8, OpenJPEG heap overflow) and a decoder format list (#4220)
requirements.txt and the four hash-locked terminal locks (windows, linux x86_64, linux aarch64, macOS arm64) move to 13.0.0; Python 3.10 stays on
12.3.0. Image.open() in the vision path, the EXIF probe and the DeepSeek picture reader now only opens JPEG, PNG, GIF, BMP, WebP, TIFF and AVIF, so a
JPEG 2000 file never reaches OpenJPEG on any Pillow version. Approach from PR #4220 (version split by Python range).
2026-11-04 10:34:11 +01:00
Niko1221 a837fb450b chats: save every chat whole, one entry per chat, and report a full storage instead of trimming (#5192)
The saved chat list cut long fields, dropped the oldest messages of a long chat and left old chats out of the
list when it passed 3.5M characters, and every restart trimmed the chats again (12 messages of 140,000
characters came back as 6). Now each chat has its own storage entry holding the chat exactly as it is, the index
only keeps the order, and a chat the browser refuses keeps its last good copy, stays whole in memory, is tried
again at every save and is reported (state.sessionSave, unsavedSessionsJson). The old single-entry list is moved
over chat by chat and a chat leaves it only after its own entry was written. Approach from PR #5192 (never remove
chats) kept; the field clipping it still did is gone.
2026-11-04 10:31:29 +01:00
Niko1221 888c5eca84 tests: the GB10 doctor row pins sys.platform 2026-11-04 10:26:52 +01:00
Niko1221 eb4f41fb9a Control: the OS and architecture are read without WMI on Windows, so a stuck winmgmt cannot hang models list or a load (#4975, #5185 as reference) 2026-11-04 10:26:40 +01:00
Niko1221 a3a10b90af owner decisions: plan cards keep the screens' count and add "(--gpu N)"; refusal says "free"; app tells the plan which engine it will unload
- gpu_roles.describe(card_numbers=True) prints "GPU <n+1> (--gpu <n>)"; the two test_launch.py assertions are back on the old digits plus the hint. The Terminal has no translation catalog for this line.
- split_engine: the refusal sentence ends "has 7 GiB free)" when the free memory was read (the size, as before, when it was not).
- the desktop app asked `drafting plan` / `drafting fetch` before it unloads the running model, so its own engine counted as busy memory on a model switch and the plan could pick DFlash2 and fetch the 1.2 GB drafter. New flag --engines-unloading (hidden from --help), sent by native-control.mjs for a load that unloads the running instances (not for a model added beside them); same effect as Runner.card_probe. Logic only, no text.
- tests: python (drafting plan with and without the flag), node split-app.test.mjs (flag on the argv, a 2-card model switch, a model added beside).
2026-11-04 09:32:34 +01:00
Niko1221 0e76645a73 tests: the plan's cards line in test_launch.py counts from card 0 (the numbers --gpu takes) 2026-11-04 07:26:12 +01:00
Niko1221 ac1dfef883 launcher: draft layer also found in --data-dir, split engine counts free VRAM, plan card numbers are the --gpu numbers
- discovery.prepared_beside(gguf, data_dirs): after the models-folder walk it also looks in --data-dir and the remembered data folder for mtp/rt, the pack and the picture reader (setup writes them there); plan, download_plan and the own-files preview pass them. A model folder outside the data folder no longer asks for the 5.2 GB layer a second time.
- split_engine: Card.free_mib from the same nvidia-smi call (memory.free; AMD: the render node's vram_used), fit = min(size, free) - 700 MiB; busy cards get the layer split with DFlash2 and no draft-layer part. Runner.card_probe leaves free memory out while a server of this launch (same profile or port) is about to be stopped. Free cards decide as before.
- plan: the cards line prints the numbers --gpu takes and leaves out cards CUDA_VISIBLE_DEVICES (AMD: ROCR/HIP_VISIBLE_DEVICES) hides; the split probe respects the same variables when no card is named. Digits only.
- tests/terminal/test_fix3_0212.py (41 tests); launch_support.Home clears the visibility variables.
2026-11-04 07:11:31 +01:00
Niko1221 1fef27e836 setup: no "--prefill auto:32768" tip where a bare auto already takes 32768 (one NVIDIA card of compute capability 8.0+, no STRATA_NV_CHUNK=0 / STRATA_DEFAULTS=026, no layer split); docs: DETAILS.md describes the NVIDIA ceiling
Logic only, no text changed: bench_tips takes the GPU and leaves the tip out for those configs; older cards, AMD, a layer split and no GPU information keep it. The explicit auto:32768 warning is unchanged. test_setup_bench_tips covers it (10 passed on l620).
2026-11-03 20:37:45 +01:00
Niko1221 aa12025515 NVIDIA: a bare --prefill auto takes a 32768-token chunk ceiling on cards of compute capability 8.0 and up (owner 11-03, after the full suite passes)
The gfx12 rule in generate.cpp now serves both: nv_chunk.hpp (STRATA_NV_CHUNK=0 or STRATA_DEFAULTS=026 keep 8192, =16 keeps 16384, =1 skips the RAM guard), the fit scan still picks a smaller chunk when the cache cannot lend the buffers, the #2090 RAM guard steps 32768 -> 16384 -> 8192, and a layer split takes 8192 back (STRATA_SPLIT_CHUNK16K=1 keeps the ceiling). An explicit --prefill N, auto:N or STRATA_PREFILL_AUTO_MAX is never touched. Volta / Turing / Pascal keep 8192.
A6000 UD-Q4_K_XL, 13.1K of 24.6K experts in VRAM, MMQ_KQ on: prompt 20K +43.4%, 32K +22.5%, 64K +21.3%, 8K 0.0%; cards that hold every expert: flat.
nv_defaults_test covers the ceiling, the switches, the card rule and the step-down.
2026-11-03 20:37:28 +01:00
Niko1221 dbdd81a675 Merge f-0212-strix (final 8b98b6f1c) into f-0212-int 2026-11-03 19:14:15 +01:00
Niko1221 8b98b6f1ca Strix Halo: an oversized prompt chunk is refused at the start instead of killing the engine after the prefill
An integrated GPU cannot report how much room is left (the pool reads 0 MiB free on a start that works). With a chunk above the 16384 default that the person or the environment names (STRATA_PREFILL_AUTO_MAX=32768, --prefill auto:32768) and a UD-Q4_K_XL cache that fills the pool, the driver could not create a stream (PAL failed to submit CMD) and the first launch after the prompt buffers died with HIP 719. Once the prompt path's own buffers are in place the engine now tries one stream, fill and wait; if that fails it prints one line naming the chunk, the cache and the 16384 alternative and ends with exit code 2, before any prompt is read (HIP builds, integrated GPU, own buffers, chunk above 16384 only; the 16384 default, CUDA and Metal are untouched; STRATA_PF_OWN_PROBE_OFF=1 skips it). The failed context cannot be reused, so the engine cannot step down in the same process.

The #1630 warning about a named chunk of 8192 or more no longer prints on gfx1151 (it said a bare --prefill auto stops at 6144, stale since 0.2.8); it is printed once the device is known. STRATA_TRACE marks around the prompt path and the buffer size of the chunk.

setup: the bench tip for --prefill auto:32768 is not said on an APU (apu_hides_chunk_tip). The tuner's prefill_32k arm needs a discrete GPU. The app's saferStart does not retry a start that ended with the chunk refusal (the same chunk would fail again, and a memory setting is not the cause). Tests for all three.
2026-11-03 19:11:00 +01:00
Niko1221 a1c7f05059 serve: an explicit --draft dflash2 with --batch N turns the batch slots' DFlash2 drafts on for one card (STRATA_BATCH_DF2=0 still turns them off; --draft auto and MTP unchanged) 2026-11-03 17:54:13 +01:00
Niko1221 081edf2d9b Merge f-0212-df2choice (final 181b668e5) into f-0212-int 2026-11-03 17:54:12 +01:00
Niko1221 181b668e5a app: a named DFlash2 with a number of requests passes Expert mode's batch-mtp wish on as in 0.2.10, so the Metal fallback to the MTP keeps its --batch-mtp
The server drops --batch-mtp when DFlash2 runs and keeps it when the MTP runs; a test pins the Metal fallback arguments equal to the MTP's.
2026-11-03 17:52:57 +01:00
Niko1221 ae5e73de20 drafting: a DFlash2 the person chose by name keeps the number of requests they chose (DFlash2 with N requests); Auto and the MTP unchanged
- drafting.blocked_by(named=True): only --pipeline-windows still blocks; Metal keeps the old rule (no per-request drafters there)
- apply/launcher/plan pass the choice; the all-cards split engine runs --draft dflash2 --draft-model PACK only for DFlash2 by name AND --split-engine on
- the split engine's own DFlash2 start line counts as "DFlash2 on"; a split run config with --draft dflash2 is left as it is
- app Drafting hint and Terminal --drafting help say it (owner-approved wording, English only)
- tests: terminal drafting/single/split-parallel, split engine, serve parallel, node parallel-default
2026-11-03 16:28:14 +01:00
Niko1221 938b76eb3e gfx12 prompt staging ring (a1e6ef324): only on a gfx12 card. The branch was keyed on the matrix-core attention being active, which the Strix Halo (gfx1151) also has on by default on Windows, so it took the 256 / 192 slot ring that was measured on an R9700 only. gfx1151 keeps the 96-slot ring of 0.2.10; gfx12 is unchanged. 2026-11-03 16:13:46 +01:00
Niko1221 75bbf42912 docs: the split engine's opt-in DFlash2 draft source (engine flag only; Auto keeps the MTP draft layer) 2026-11-03 14:55:40 +01:00
Niko1221 c65b9f8be8 Merge branch 'f-0212-int' into f-0212-splitdf2 2026-11-03 13:57:10 +01:00
Niko1221 0748e92701 split: DFlash2 rounds follow the draft layer's length rule, lanes with no planned drafts bank their taps (FNX_DF2_DEFER, FNX_DF2_REFRESH), the batch planner prices a drafter round per lane that drafts (RowOffer::first_ms), FNX_DF2_TRACE, FNX_SPECULATE=0 reference 2026-11-03 13:57:09 +01:00
Niko1221 f6cea71a78 Merge f-0212-tailskip (final) into f-0212-int 2026-11-03 12:56:48 +01:00
Niko1221 2d8ead05b8 Merge f-0212-parallel (final) into f-0212-int 2026-11-03 12:56:48 +01:00
Niko1221 212d15ba88 Merge f-0212-nvsingle (final 52ac4e733) into f-0212-int 2026-11-03 12:22:49 +01:00
Niko1221 52ac4e7337 NVIDIA: STRATA_MMQ_KQ=1 is on by default for compute capability 8.0 and up (owner yes 11-03): K-quant experts (UD-Q4/Q5/Q6_K_XL) read the prompt on the MMQ kernels
kNvKqDefaultOn is true; STRATA_MMQ_KQ=0, STRATA_NV_KQ_DEFAULT=0 and STRATA_DEFAULTS=026 turn it off, a STRATA_MMQ_KQ the user sets is kept, Volta / Turing / Pascal keep the FP16 prompt path. A pack without K-quant experts never reads the variable.
Start check (RTX PRO 6000, UD-Q4_K_XL, no STRATA variable set): the start prints the K-quant MMQ line and loads libstrata_kq; 4K prompt GPU timeline 1,047 ms (dequant 7.3%) against 1,500 ms (dequant 25.1%) with STRATA_MMQ_KQ=0, prompt 2,854 vs 2,233 tok/s (one request each).
2026-11-03 12:20:34 +01:00
Niko1221 c850b979b5 split: DFlash2 as the draft source of the all-cards split engine (--draft dflash2 --draft-model PACK, FNX_DRAFT=dflash2). One drafter a lane on card 0 through df2_bridge (src/core/dflash2.cpp with bind() compiled out); the window graphs tap layers 5/15/25/35/45 on card 0 (df2_tap_gr), the prompt path taps each card's own rows into a host ring per lane; Session and BatchDecoder take the drafts after the commit. Greedy 6.4K and 27K answers identical to no drafter and to the draft layer (UD-Q4, 2 x A6000) 2026-11-03 12:16:09 +01:00
Niko1221 95cb87d427 Merge f-0212-multispill (final 31a735153) into f-0212-int 2026-11-03 12:07:25 +01:00
Niko1221 c2de8af1c4 setup: the route-tail skip stays on for Unsloth files on Linux gfx12 (R9700, RX 9000)
Owner 11-03 ("3 Yes"). Since 0.1.44 setup wrote STRATA_ROUTE_TAIL_SKIP=0 into the run config of every Unsloth file whose experts are all in RAM. On Linux gfx1200 / gfx1201 it no longer
does: the engine's own default (rank 7, applied only while the variable is unset, so =0 still turns it off) runs. UD-Q4_K_XL on one R9700: decode +6.2% (2 rounds x 8 classes, interleaved) and a
median +15.6% over the 61 suite tasks; the suite scores 56/61 against 55, 57 and 56 for the default arms; start line "STRATA_ROUTE_TAIL_SKIP=7 is ON (default)". Teacher-forced decode-window
KL against no skip 0.0773 (noise floor 0.00000, top-1 91.1%; agent segment 0.20) on a static, low-hit-rate cache, an upper bound for real use. Windows and other cards keep the 0.
unsloth_default_env takes the card's arch (default "": the old behaviour); test_tail_skip_stays_on_for_linux_gfx12 pins the rule.
2026-11-03 11:57:52 +01:00
Niko1221 dfc675848f Merge f-0212-amdsplit (final 9149835c6) into f-0212-int 2026-11-03 11:55:52 +01:00
Niko1221 31a7351534 Layer split: price DFlash2's head, logits and staging into the last stage's room 2026-11-03 11:51:52 +01:00
Niko1221 ee0b367fb7 Layer split walk: early trim for every card, stage late-allocation reserve, fewer-cards step, min card 3 GiB; docs 2026-11-03 11:48:08 +01:00
Niko1221 c2335eebba Merge f-0211-nvdual (final f95411b84) into f-0211-int 2026-11-03 11:41:43 +01:00
Niko1221 9149835c6a Auto starts the split engine on 4 x R9700 for the IQ3_S set (and the other i-quant / Q2_0 expert types). Owner's yes 11-03. With the shared expert fused into the routed launches the split beats the layer split on IQ3_S: 1 user 186.8 against 104.2 tok/s, 8 users 364.6 against 320.7 through launch; 61-task suite 57/61 against 54/61; first-token KL vs the layer split 0.041. 2 and 3 cards unchanged; --split-engine off keeps the layer split 2026-11-03 11:39:16 +01:00
Niko1221 62853e78de FP16 weight cache on by default when the engine runs batch slots on Linux CUDA (3 GiB kept free); gemm_w16_parity test
gemm_dequant_side_parity failed with the cache because its synthetic scratch (10.5 M elements) is smaller than four of its weights, so uncached they take the sliced path and cached one full-N GEMM. The engine's scratch is 32 M elements and every dense projection fits it. gemm_w16_parity runs the engine's scratch, shapes and types, cache off against on, bitwise. STRATA_PF_W16_CACHE_MIB=0 turns it off.
2026-11-03 11:29:44 +01:00
Niko1221 f95411b843 split: the int8 prompt attention is on for the IQ3_S-type files and off for the others (UD-Q4_K_XL: 57/61 with it off, 55/61 with it on, agent-A8 and A10), like GDN_TC; the variable overrides 2026-11-03 11:22:25 +01:00
Niko1221 e2f7c47447 NVIDIA: STRATA_MMQ_KQ=1 as a candidate default for K-quant experts (UD-Q4/Q5/Q6_K_XL prompt on the MMQ kernels), behind STRATA_NV_KQ_DEFAULT=1 until the owner says yes
UD-Q4_K_XL's Q4_K gate/up and Q5_1 down experts read the prompt through the FP16 dequant path plus cuBLAS on CUDA (STRATA_PREFILL_TIMING, 8K prompt on an A6000: dequant 21%, gemm gate/up 24%, gemm down 13% of the GPU timeline).
With STRATA_MMQ_KQ=1 (libstrata_kq): RTX PRO 6000 (sm_120) prompt 4K +37.7%, 8K +25.8%, 20K +34.3%, 32K +32.6% (n 4 per cell, 2 rounds, all won); A6000 (sm_86) 4K +1.7%, 8K +9.0%, 20K +8.1%, 32K +11.2%. First-token KL against the FP16 path over 24 prompts of 2K: 0.0243 (PRO 6000) and 0.0351 (A6000), top-1 23/24 and 22/24.
nv_kq_default_env / apply_nv_kq_default: compute capability 8.0 and up (lowest card decides), off switches STRATA_MMQ_KQ=0, STRATA_NV_KQ_DEFAULT=0, STRATA_DEFAULTS=026; kNvKqDefaultOn stays false, so nothing changes by default.
2026-11-03 10:18:17 +01:00
Niko1221 e318910206 FP16 weight cache: STRATA_PF_W16_CACHE_MIB=auto sizes it by the card (3 GiB kept free); tester switch, the default stays off 2026-11-03 10:16:40 +01:00
Niko1221 fc5dac23c5 Layer split spill walk: stage-weight trim for short cards, drafter drop and head on host per card, KV window and fewer-cards steps; launcher retry without a drafter and the engine's own reason as the last line 2026-11-03 10:02:18 +01:00
Niko1221 3671e77acd split: the shared expert rides in the routed gate|up launch for every expert type, not only Q4_K (FNX_SH_FUSE=0 turns it off). The IQ and the other layers ran it as three launches on a second stream with an event fork and join, 48 layers a window; k_moe_gu_sd carries its swiglu and down as extra rows of blocks like k_moe_gu_q4k does, with the same lanes a row and summation order. Greedy answers (short, 6.5K, 26K) identical for IQ3_S and UD-Q4; IQ3_S 4 x R9700 1 user 115 -> 203 tok/s, 8 users 211 -> 351; UD-Q4 179 -> 186 and 367 -> 385 (n=1 pair) 2026-11-03 09:57:55 +01:00
Niko1221 22f4dbe0e1 split: the NVIDIA prompt path takes 8192 rows x 4 chunks only if the free VRAM after the weights holds it beside the K/V pool (UD-Q4_K_XL on 2 x 48 GB did not start); else 8192 x 2, 4096 x 4 or 4096 x 2 2026-11-03 09:46:00 +01:00
Niko1221 55a2bcd474 Merge f-0211-nvdual into f-0211-int
Conflict in src/split/src/prefill.cu (pf_ring and the ring wiring): kept both sides. The HIP all-to-all small-chunk path stays, guarded by !own_out (the own-rows output is the NVIDIA hyper-connection split's), and the ring runs with nvdual's own_out argument; in the next-card wiring the HIP a2a box/flag copies and the hyper-connection split's input pointers are both kept.
2026-11-03 08:46:45 +01:00
Niko1221 07e1508893 Merge f-0211-hybrid into f-0211-int 2026-11-03 08:46:30 +01:00
Niko1221 2613b4de5a Merge f-0211-r9700one into f-0211-int 2026-11-03 08:46:29 +01:00
Niko1221 66cfc4bcdb Merge f-0211-r9700split into f-0211-int 2026-11-03 08:46:29 +01:00
Niko1221 d2dc05557e split: gdn_tc_set_default and its flag live in namespace fnx (link) 2026-11-03 08:41:39 +01:00
Niko1221 0ffbf096f7 split: g_gdn_tc_default is defined where the scan reads it (kernels part 4), before its use 2026-11-03 08:37:41 +01:00
Niko1221 fcd1493c1a split: gdn_tc_set_default is called from the Engine constructor (engine.cu includes kernels.hpp), so the HIP build compiles; GDN_TC stays NVIDIA-only 2026-11-03 08:34:32 +01:00
Niko1221 c2d44e93ac gfx12: K-quant experts read the prompt on MMQ by default; bare --prefill auto takes 32768-token chunks
Owner 11-03 ("do all 5"), two prompt defaults for Linux gfx12 (R9700), both changing rounding slightly and both with their off switch.

STRATA_MMQ_KQ=1 joins the gfx12 value-changing defaults (gfx12_extra.hpp, applied by apply_arch_defaults, so a raw engine gets it too; Linux only like the
other approved gfx12 prompt defaults; STRATA_MMQ_KQ=0, STRATA_GFX12_DEFAULTS=0 or STRATA_DEFAULTS=026 turn it off, a value the user sets wins). The experts of
Unsloth's UD-Q4/Q5/Q6_K_XL files (Q4_K, Q5_K, Q5_1, Q6_K) then run their prompt products on llama.cpp's MMQ kernels instead of being dequantized to FP16 and
multiplied by hipBLASLt (UD-Q4_K_XL ran 25K dequant launches and ~50K small Tensile GEMMs per 8K prompt). R9700, same job, interleaved: prompt 4K +10.3%,
8K +14.3%, 32K +16.8%; first-token KL against the FP16 path 0.031 over 24 prompts of ~2K (agent turns 0.065), top-1 23/24. A pack without K-quant experts never
reads the variable. The fused variant (STRATA_PF_FUSED_KQ) stays a flag.

The gfx12 ceiling of a bare --prefill auto goes from 16384 to 32768 (gfx12_chunk.hpp, pure functions with tests). A 20K prompt is one chunk instead of
16384 + a 3.6K tail, and every chunk of 1,024 tokens or more streams all the experts the card does not hold. R9700, IQ3_S, 3 rounds interleaved: 20K +12.1%,
64K +2.6%, 32K +1.3%, 8K +0.9%; UD-Q4_K_XL 20K +26%; first-token KL against 16384 at most 5e-5. The lent slots are taken only for what a prompt needs and are
refilled after it; the RAM guard (#2090) now steps 32768 -> 16384 -> 8192 until the extra loan fits. STRATA_GFX12_CHUNK=16 keeps 16384, =0 keeps 8192.

gfx12_extra_test covers both (gfx only, Linux, off switches, user value wins, the ceiling and its step-down); docs/DETAILS.md lists them.
2026-11-03 08:32:12 +01:00
Niko1221 d00d937b89 split: Pf::down_lt is declared outside the NVIDIA-only cublasLt block, so the hyper-connection checks in prefill.cu compile on HIP (HIP never sets it) 2026-11-03 08:30:58 +01:00
Niko1221 213f1a3905 Engine: the auto CPU/PCIe miss share follows the CPU pool by default on a discrete card: ceiling 0.95, factor k falling from 1.85 at 30 GB/s of expert bytes to 0.8 at 12, and a CUDA layer split refines its stage shares (STRATA_PCIE_AUTO_SPLIT=0, STRATA_PCIE_K_RULE=0 and STRATA_PCIE_SHARE_MAX=0.60 restore 0.2.10; integrated GPUs, HIP layer splits and STRATA_DEFAULTS=026 keep it). Pure rule in pcie_share_rule.hpp with a test. 2026-11-03 08:27:33 +01:00
Niko1221 6983b09bba split (owner 11-03, do all 5): FNX_PF_ROWS 8192, FNX_PF_HCSPLIT mode 2 and FNX_PFX_ATTN_I8 on by default on NVIDIA; FNX_PFX_GDN_TC off for the IQ3_S-type files (decided in the engine from the file's routed-expert types), on for the others; passes limited to 8192 rows while other requests decode; tests 2026-11-03 08:26:30 +01:00
Niko1221 38c08443e0 split: a short last chunk is a pass of its own only after at least 3 whole chunks (with fewer the pass is unsplit anyway and the chunks hide each other's sums) 2026-11-03 06:33:56 +01:00
Niko1221 37e4fcf9ac split: the hyper-connection split needs at least 3 chunks in a pass (2 or 1 chunk: slower than the plain read) 2026-11-03 06:17:40 +01:00
Niko1221 cde3399b31 split: with the hyper-connection split a short last chunk is a pass of its own (prompts under 16K were not split at all: the tail kept the pass unsplit) 2026-11-03 05:37:45 +01:00
Niko1221 a1e6ef3246 gfx12 prompt staging ring: 256 / 192 slots below 12K-token chunks instead of the fixed 96
The Linux gfx12 matrix-core prompt path hard-set the streamed-expert ring to 96 slots (measured before the fused IQ3_S kernels). A layer's streamed
experts want a deeper ring at small and middle chunks. R9700 (gfx1201), IQ3_S, 3 rounds x 2 reps interleaved, prompt tok/s against ring 96: ring 256
4K +12.5%, 8K +2.4%, 2K +1.4%, 32K -0.4%; ring 192 4K +11.8%, 8K +4.4%, 32K -1.3%. New default build against the old one: 2K +2.3%, 4K +16.1%,
8K +2.7% (3/3 rounds each), 20K and 32K +0.1%, decode unchanged. UD-Q4_K_XL (MMQ): ring 192 4K +6.9%, 8K +9.5%, 20K +4.0%.
The ring is staging depth only: first-token full-vocabulary logits are bit-identical (md5) for ring 96 / 192 / 384 on 6 prompts of 8K to 64K; greedy
128-token hashes with static residency are identical on UD-Q4 (12/12) and, on IQ3_S, the few divergent requests occur in ring-96 engines as often
as in ring-192 engines (the known HIP timing divergence of an engine's first requests).
Chunks of 12,288 tokens and more keep 96. The ring is capped at ~800 MiB of blobs. STRATA_PREFILL_RING=N and STRATA_GFX12_RING=0 restore the old value.
Only the Linux gfx12 branch (matrix-core prompt attention active) changes; gfx1151 and Windows keep their rule.
2026-11-03 05:09:40 +01:00
Niko1221 e364620c56 Engine: two tester switches for the auto CPU/PCIe miss share: STRATA_PCIE_SHARE_MAX (ceiling, default 0.60) and STRATA_PCIE_K_RULE=1 (pool factor k falls from 1.85 at 30 GB/s of expert bytes to 0.8 at 12). Defaults unchanged. 2026-11-03 04:46:13 +01:00
Niko1221 400e26e3a8 split: fix the two log lines of the hcsplit decision 2026-11-03 04:20:27 +01:00
Niko1221 0ed3a193ac tests: split hcsplit pictures 2026-11-03 04:10:15 +01:00
Niko1221 9f891886d6 split: the hyper-connection split (exact mode) is the default on two NVIDIA cards when the calibration finds the algorithm; 4 chunks a pass unless other requests decode; tests 2026-11-03 04:09:53 +01:00
Niko1221 73dabf548d 0.2.10.1 App: Auto uses the all-cards split engine wherever Control says the Terminal does
Host: draftingPlan/draftingFetch no longer pass --no-split (only when told noSplit, after the draft layer for the split could not be downloaded); the start asks Control
(splitEngine) to turn the run config into the split engine's before the profile is saved, the second try after a failed start is never split, the load waits at least 10 minutes
for a split start, and the Details line says what runs (or that the server started the layer split). A failed MTP layer fetch ends in one plain note and the 0.2.10 layer split
plan. Screen: the split word counts with two or more cards; the download note and the Drafting hint say Auto is the split with the MTP draft layer (text only, two new strings in nine languages).
Tests: tests/split-app.test.mjs (fake Control, 13 tests); drafting.test.mjs and ui-r3.test.mjs updated.
2026-11-03 04:06:55 +01:00
Niko1221 3f15274e8f 0.2.10.1 Control: the app starts the all-cards split engine like the Terminal
`strata split-engine apply --config IN --out OUT` turns the run config the app built into the split engine's (same steps as strata launch: rule, strata-split-N beside the
engine, draft layer on disk, lanes 1..8 from "parallel"); nothing is written on a no. Shared helpers in split_engine.py (exe_finder, draft_layer_present, apply_run_config,
split_plan_line), used by the launcher too. `strata drafting plan` asks the rule with the choice in hand (a saved DFlash2 under an Auto on the screen is Auto), says split for
an explicit MTP as the Terminal does, and uses the Terminal's plan line; expected() refuses a conversation memory other than int8 as the start does. --no-split stays.
Tests: AppPlanEqualsTerminalPlan, AppStartsTheSplit replace AppDoesNotSplit; docs/SPLIT_ENGINE.md describes the app.
2026-11-03 04:06:55 +01:00
Niko1221 c984ab42a0 split: FNX_PF_HCSPLIT=1 splits only passes of whole 4096-row chunks and uses the calibrated algorithm only at 4096 and 2048 rows (exact mode); =2 any chunk of 512 rows or more 2026-11-03 03:52:10 +01:00
Niko1221 e8570ac616 split: FNX_PF_HCSPLIT runs the hc down GEMM through cublasLt with an algorithm calibrated at start to give the same rows at 2048 and 4096 rows (and cublasGemmEx's at 4096) 2026-11-03 03:42:59 +01:00
Niko1221 8088d056f0 split HIP: chunks of up to 256 rows are summed all-to-all instead of round the ring (FNX_PF_A2A_ROWS, with FNX_PF_PEER). Each card stores its f32 addends into the other cards' mailboxes, raises a flag per peer, and sums the chunk itself in the ring's own order and roundings: bit-identical to the ring, one cross-card latency instead of three hops and the gather. 29-row prompt pass 212 -> 139 ms, 6-row pass 206 -> 145 ms (RCCL: 156 / 149); greedy answers (short, 6.5K, 26K) identical to the ring and to RCCL 2026-11-03 03:21:46 +01:00
Niko1221 deebb73a65 split HIP: the prompt chunk sums through the peers' device memory (FNX_PF_PEER, default on when the peer push is on and RCCL is not in use). Receive slots, gather rows and flags live in fine-grained device memory of the receiving card; the sender's kernels store into it. Same sum kernels as the host exchange: greedy answers at 6.5K / 26K / 103K tokens identical to the host sums, 103K prompt 33.4 s -> 30.4 s 2026-11-03 02:52:12 +01:00
Niko1221 750cbf0dbd split HIP: i-quant decode dots (IQ3_S, IQ3_XXS, IQ2_S) with a cheaper sign expansion, the same integers as ggml's (0 of 25M random dots differ, tools/iqdot_test.cu; IQ3_S greedy 26K-token answer identical to the base build) 2026-11-03 02:52:12 +01:00
Niko1221 697fec9d06 split: FNX_PF_HCSPLIT_FULL=2 debug 2026-11-03 02:19:42 +01:00
Niko1221 8969119a42 split: FNX_ONEHOP=2 (screen): the one-hop window sum on two cards 2026-11-03 02:15:50 +01:00
Niko1221 a2aa1f3122 split: FNX_PF_HCSPLIT_FULL debug switch 2026-11-03 02:11:05 +01:00
Niko1221 d716ccfca6 split: FNX_PF_HCSPLIT swap on its own stream, FNX_PF_HCSPLIT_F16 (wip) 2026-11-03 02:05:20 +01:00
Niko1221 838b2e0de6 split: FNX_PFX_ATTN_I8 default back to off (agent-A10 failed 3 of 3 suite runs with it on); FNX_PFX_GDN_TC stays on; FNX_PF_HCSPLIT (screen, wip) 2026-11-03 02:00:38 +01:00
Niko1221 8fbf6b1902 Split engine on AMD: the default lane count is 8 (was 6). A plain launch on 4 R9700 ran 8 users in two waves (255 tok/s aggregate); with 8 lanes they run in one (350). The K/V pool and buffers of 8 lanes at 128K context leave 7.2 GiB free on each card. NVIDIA keeps 6; a number the person chose stays. 2026-11-03 01:35:59 +01:00
Niko1221 1bf5e13e7f split: the INT8 prompt attention (FNX_PFX_ATTN_I8) and the tensor-core GDN scan (FNX_PFX_GDN_TC) are on by default on NVIDIA sm_80+ (the owner's yes, 0.2.10.1); =0 turns each off; HIP keeps the old kernels 2026-11-03 01:16:47 +01:00
Niko1221 082af9c19a Split: tensor-core chunk scan for the prompt GDN behind FNX_PFX_GDN_TC (default off)
Strata's gdn_tc.cuh scan (mma.m16n8k16 f16 hi/lo split, state in registers, 2048-token super-blocks, prep kernel per value head) ported
into the split engine's prompt GDN (gdn_tc_prompt.cuh, part 4, CUDA sm_80+; HIP keeps the sub-chunk kernels). FNX_PFX_GDN_TC=1 runs it in
place of k_gdn_ch_prep / k_gdn_ch_rec when the card has the scratch for it. split_gdn_tc_test checks outputs and end state against the
sub-chunk kernels on random inputs (one card slice, C 33..32768) and times both. FNX_GDN_TIME=1 prints the prompt GDN's GPU time per card.
2026-11-03 01:16:47 +01:00
Niko1221 4a49d25975 split: FNX_PFX_ATTN_I8=1 (screen): the prompt attention on INT8 tensor cores, Strata's prompt_attn_i8v3 adapted to the split's K/V pages (scale per 32 values), card partials and cell lists (off by default) 2026-11-03 01:16:47 +01:00
1113 changed files with 222586 additions and 8386 deletions

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ docker/compose/* text eol=lf
# vendored KaTeX (serve/web/vendor): the files are hashed byte for byte (PROVENANCE.json), never convert their line endings.
serve/web/vendor/** -text
third_party/strata-ggml/** text eol=lf
+1
View File
@@ -1 +1,2 @@
github: Niko1221
buy_me_a_coffee: strataengine
+1 -1
View File
@@ -66,7 +66,7 @@ body:
attributes:
label: Strata version
description: "In the app: Settings > About. In the Terminal: `strata --version`. From source: the About tab of the web page, or `engine` in `GET /v1/status`, or the git commit."
placeholder: "Strata 0.2.10 · engine 0.1.56"
placeholder: "Strata 0.2.13 · engine 0.1.59"
validations:
required: true
- type: input
+218
View File
@@ -0,0 +1,218 @@
name: Backend compile checks
# Compile and link every engine backend with a pinned toolchain, without a GPU: nothing is run, so a call into a kernel that only
# exists for another backend (or an API a toolkit version dropped) fails here instead of on a user's machine. Hardware runs are separate.
# CUDA 12.9 and 13.0 nvidia/cuda devel container targets strata, strata_kq (CMake preset cuda-compile)
# HIP (ROCm 7.2) rocm/dev container target strata (CMake preset hip-compile)
# SYCL (oneAPI 2026.1) ubuntu + Intel's apt repo target strata of sycl/ (the commands of packaging/build_sycl_linux.sh)
# Metal macOS runner targets strata and strata_ds4 (metal/; metal-build.yml keeps its own strata build)
# llama.cpp (ggml) comes at the commit pinned in setup.py (LLAMA_CPP_COMMIT), the same one the release builds use.
# Architectures are fewer than the release package's (compile time); the flags that decide which sources and defines are built are the release's.
on:
push:
branches: [main]
paths:
- .github/workflows/backends.yml
- CMakeLists.txt
- CMakePresets.json
- cmake/**
- src/**
- include/**
- third_party/**
- sycl/**
- metal/**
- setup.py
- packaging/build_cuda_linux.sh
- packaging/build_hip_linux.sh
- packaging/build_sycl_linux.sh
pull_request:
paths:
- .github/workflows/backends.yml
- CMakeLists.txt
- CMakePresets.json
- cmake/**
- src/**
- include/**
- third_party/**
- sycl/**
- metal/**
- setup.py
- packaging/build_cuda_linux.sh
- packaging/build_hip_linux.sh
- packaging/build_sycl_linux.sh
workflow_dispatch:
permissions:
contents: read
concurrency:
group: backends-${{ github.ref }}
cancel-in-progress: true
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 2G
jobs:
cuda:
name: CUDA ${{ matrix.cuda }}
runs-on: ubuntu-24.04
timeout-minutes: 120
container: nvidia/cuda:${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
# the cuda12 package is the one for Pascal and Volta: sm_60 needs STRATA_EXPERIMENTAL_SM60 (packaging/build_cuda_linux.sh)
- { cuda: '12.9', image: '12.9.1-devel-ubuntu24.04', archs: '60-real;61-real;70-real;86-real', sm60: 'ON' }
- { cuda: '13.0', image: '13.0.2-devel-ubuntu24.04', archs: '75-real;86-real;120-real', sm60: 'OFF' }
steps:
- name: Tools
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates cmake ninja-build ccache
cmake --version | head -1
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: llama.cpp (ggml) at the pinned commit
run: |
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
mkdir llama.cpp && cd llama.cpp && git init -q
git remote add origin https://github.com/ggml-org/llama.cpp.git
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
- uses: actions/cache@v4
with:
path: .ccache
key: ccache-cuda${{ matrix.cuda }}-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'cmake/**') }}
restore-keys: ccache-cuda${{ matrix.cuda }}-
- name: Configure (preset cuda-compile)
run: |
nvcc --version | tail -2
cmake --preset cuda-compile -B build -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
"-DCMAKE_CUDA_ARCHITECTURES=${{ matrix.archs }}" -DSTRATA_EXPERIMENTAL_SM60=${{ matrix.sm60 }} \
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache
- name: Build strata and strata_kq
run: cmake --build build --target strata strata_kq -j "$(nproc)"
- name: Cache statistics
run: ccache -s | head -8
hip:
name: HIP ROCm 7.2
runs-on: ubuntu-24.04
timeout-minutes: 120
container: rocm/dev-ubuntu-24.04:7.2.4-complete
steps:
- name: Tools
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates cmake ninja-build ccache
cmake --version | head -1
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: llama.cpp (ggml) at the pinned commit
run: |
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
mkdir llama.cpp && cd llama.cpp && git init -q
git remote add origin https://github.com/ggml-org/llama.cpp.git
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
- uses: actions/cache@v4
with:
path: .ccache
key: ccache-hip72-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'cmake/**') }}
restore-keys: ccache-hip72-
- name: Configure (preset hip-compile)
run: |
/opt/rocm/llvm/bin/clang++ --version | head -1
cmake --preset hip-compile -B build -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
-DCMAKE_HIP_COMPILER=/opt/rocm/llvm/bin/clang++ -DCMAKE_HIP_COMPILER_ROCM_ROOT=/opt/rocm -DCMAKE_PREFIX_PATH=/opt/rocm \
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_HIP_COMPILER_LAUNCHER=ccache
- name: Build strata
run: cmake --build build --target strata -j "$(nproc)"
- name: Cache statistics
run: ccache -s | head -8
sycl:
# The engine needs oneAPI 2026.1 or newer (sycl/include/dpct/util.hpp uses the entangle and chunked_partition extensions, which the
# 2025.3.2 compiler does not have: it stops at "no member named 'entangle'"; the intel/oneapi-basekit images end at 2025.3), so the
# compiler comes from Intel's apt repository at a pinned version.
name: SYCL oneAPI 2026.1
runs-on: ubuntu-24.04
timeout-minutes: 120
container: ubuntu:24.04
steps:
- name: Tools and oneAPI 2026.1
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates cmake ninja-build ccache wget gpg
wget -qO- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | gpg --dearmor -o /usr/share/keyrings/oneapi-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" > /etc/apt/sources.list.d/oneAPI.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
intel-oneapi-compiler-dpcpp-cpp-2026.1=2026.1.1-325 intel-oneapi-mkl-devel-2026.1=2026.1.0-236
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: llama.cpp (ggml) at the pinned commit
run: |
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
mkdir llama.cpp && cd llama.cpp && git init -q
git remote add origin https://github.com/ggml-org/llama.cpp.git
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
- uses: actions/cache@v4
with:
path: .ccache
key: ccache-sycl-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'sycl/**') }}
restore-keys: ccache-sycl-
- name: Configure and build (the commands of packaging/build_sycl_linux.sh)
shell: bash
run: |
. /opt/intel/oneapi/setvars.sh --force >/dev/null
icpx --version | head -1
cmake -S sycl -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icpx \
-DSTRATA_SYCL_PARITY=OFF -DSTRATA_SYCL_IQ_DECODE_FAST=ON -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
cmake --build build --target strata -j "$(nproc)"
- name: Cache statistics
run: ccache -s | head -8
metal:
name: Metal strata + strata_ds4
# the macOS 26 SDK: metal/src/mtl_rt.mm names MTLGPUFamilyMetal4 behind @available (a run-time check); the names must be in the SDK
runs-on: macos-26
timeout-minutes: 60
env:
# metal/setup_mac.py's metal-cpp pin (the same as metal-build.yml)
METAL_CPP_URL: https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip
METAL_CPP_SHA256: 4df3c078b9aadcb516212e9cb03004cbc5ce9a3e9c068fa3144d021db585a3a4
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Tools
run: brew install ninja ccache
- name: metal-cpp
run: |
curl -fsSL "$METAL_CPP_URL" -o metal-cpp.zip
echo "$METAL_CPP_SHA256 metal-cpp.zip" | shasum -a 256 -c -
unzip -q metal-cpp.zip -d _mcpp
- name: llama.cpp (ggml) at the pinned commit
run: |
commit=$(sed -n 's/^LLAMA_CPP_COMMIT = "\(.*\)"/\1/p' setup.py)
mkdir llama.cpp && cd llama.cpp && git init -q
git remote add origin https://github.com/ggml-org/llama.cpp.git
git fetch -q --depth 1 origin "$commit" && git checkout -q FETCH_HEAD
- uses: actions/cache@v4
with:
path: .ccache
key: ccache-metal-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**', 'metal/**') }}
restore-keys: ccache-metal-
- name: Configure
run: |
cmake -S metal -B build-metal -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DMETAL_CPP_DIR="$PWD/_mcpp/metal-cpp" -DSTRATA_GGML_DIR="$PWD/llama.cpp" \
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Build and link strata and strata_ds4
run: cmake --build build-metal --target strata strata_ds4
+185
View File
@@ -0,0 +1,185 @@
name: Checks
# The fast checks (tools/check), one job per step so a failure names its step: the CPU CMake preset, the Python suites, the host's
# typecheck / test / build. No GPU, no model and no download of weights. The config snapshot has its own workflow (snapshot.yml)
# and the backend compile checks theirs (backends.yml). Run the same thing by hand: python tools/check --only <step>
on:
push:
branches: [main]
paths:
- .github/workflows/check.yml
- tools/check
- requirements*.txt
- CMakeLists.txt
- CMakePresets.json
- cmake/**
- src/**
- include/**
- bench/micro/**
- third_party/**
- tests/**
- serve/**
- strata.py
- setup.py
- strata_cli/**
- strata_control/**
- tools/**
- packaging/**
- docker/**
- apps/host/**
- apps/desktop/**
- metal/**
- sycl/**
pull_request:
paths:
- .github/workflows/check.yml
- tools/check
- requirements*.txt
- CMakeLists.txt
- CMakePresets.json
- cmake/**
- src/**
- include/**
- bench/micro/**
- third_party/**
- tests/**
- serve/**
- strata.py
- setup.py
- strata_cli/**
- strata_control/**
- tools/**
- packaging/**
- docker/**
- apps/host/**
- apps/desktop/**
- metal/**
- sycl/**
workflow_dispatch:
permissions:
contents: read
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
name: Linux / ${{ matrix.name }}
runs-on: ubuntu-24.04
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- { name: cpu CMake preset, only: cmake-cpu }
- { name: Python tests/, only: py-tests }
- { name: Python serve/, only: py-serve }
- { name: Python tools/, only: py-tools }
- { name: host typecheck + test + build, only: "host-typecheck,host-test,host-build" }
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements*.txt
- uses: actions/setup-node@v4
if: ${{ startsWith(matrix.only, 'host') }}
with:
node-version: '24'
cache: npm
cache-dependency-path: apps/host/package-lock.json
- name: Python packages
run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt
- name: tools/check
run: python tools/check --only "${{ matrix.only }}" --keep --scratch "$RUNNER_TEMP/check" --json "$RUNNER_TEMP/check/result.json"
- name: Logs
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: check-linux-${{ strategy.job-index }}
path: |
${{ runner.temp }}/check/*.log
${{ runner.temp }}/check/result.json
retention-days: 7
windows:
name: Windows / ${{ matrix.name }}
runs-on: windows-2022
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- { name: cpu CMake preset (MSVC), only: cmake-cpu }
- { name: Python tests/, only: py-tests }
- { name: Python serve/, only: py-serve }
- { name: Python tools/, only: py-tools }
- { name: host typecheck + test + build, only: "host-typecheck,host-test,host-build" }
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements*.txt
- uses: actions/setup-node@v4
if: ${{ startsWith(matrix.only, 'host') }}
with:
node-version: '24'
cache: npm
cache-dependency-path: apps/host/package-lock.json
- name: Python packages
run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt
# tools/check finds Visual Studio's compiler itself (vcvars64.bat); bash is Git for Windows' (the tester-README tests need it)
- name: tools/check
run: python tools/check --only "${{ matrix.only }}" --keep --scratch "$env:RUNNER_TEMP\check" --json "$env:RUNNER_TEMP\check\result.json"
- name: Logs
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: check-windows-${{ strategy.job-index }}
path: |
${{ runner.temp }}\check\*.log
${{ runner.temp }}\check\result.json
retention-days: 7
macos:
# Cheap and useful (the audit found the host and Python fixtures reading the Mac's own paths), but this job has not run on a
# Mac runner yet: it reports and does not block until its first green run.
name: macOS / ${{ matrix.name }}
runs-on: macos-15
timeout-minutes: 60
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- { name: Python tools/, only: py-tools }
- { name: host typecheck + test + build, only: "host-typecheck,host-test,host-build" }
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements*.txt
- uses: actions/setup-node@v4
if: ${{ startsWith(matrix.only, 'host') }}
with:
node-version: '24'
cache: npm
cache-dependency-path: apps/host/package-lock.json
- name: Python packages
run: python -m pip install -r requirements.txt -r requirements-dev.txt
- name: tools/check
run: python tools/check --only "${{ matrix.only }}"
+72
View File
@@ -0,0 +1,72 @@
name: Docs
# The documentation checks (python tools/check --only docs): every local link in the current pages resolves, the version lines and
# installer names agree with docs/data/release.json, the capability table and its summaries are what docs/data/capabilities.json says, and the
# settings page matches the register. Python standard library only; nothing is built, downloaded or run on a GPU.
# Archived pages (history/, release notes, bench/results) are not checked; their baseline is history/LINK-BASELINE.md.
on:
push:
branches: [main]
paths:
- .github/workflows/docs.yml
- '**/*.md'
- docs/data/release.json
- docs/data/capabilities.json
- data/settings-register.json
- tools/check
- tools/check_links.py
- tools/doc_pages.py
- tools/release_manifest.py
- tools/capability_table.py
- tools/settings_register.py
- strata_cli/__init__.py
- CMakeLists.txt
- apps/host/package.json
- apps/desktop/package.json
pull_request:
paths:
- .github/workflows/docs.yml
- '**/*.md'
- docs/data/release.json
- docs/data/capabilities.json
- data/settings-register.json
- tools/check
- tools/check_links.py
- tools/doc_pages.py
- tools/release_manifest.py
- tools/capability_table.py
- tools/settings_register.py
- strata_cli/__init__.py
- CMakeLists.txt
- apps/host/package.json
- apps/desktop/package.json
permissions:
contents: read
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
jobs:
docs:
name: Links, release manifest, capability table
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: tools/check docs
run: python tools/check --only docs --keep --scratch "$RUNNER_TEMP/check" --json "$RUNNER_TEMP/check/result.json"
- name: Log
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: docs-check
path: ${{ runner.temp }}/check/*.log
retention-days: 7
+83
View File
@@ -0,0 +1,83 @@
name: Config snapshot
# tests/snapshot: what `strata launch`, the app's plain Load and the app's Models-screen Load hand the engine (arguments, environment, drafter,
# confidence), for every simulated machine class and model set, against the golden files in tests/snapshot/golden. Nothing is loaded,
# downloaded or run on a GPU. Windows and Linux must give byte-identical output, so both run. A change that moves what users
# get has to update the golden files in the same commit (STRATA_SNAPSHOT_UPDATE=1 python -m pytest tests/snapshot, see tests/snapshot/README.md).
on:
push:
branches: [main]
paths:
- .github/workflows/snapshot.yml
- tests/snapshot/**
- serve/**
- strata_cli/**
- strata_control/**
- setup.py
- strata.py
- requirements*.txt
- apps/host/server/**
- apps/host/package*.json
- packaging/**
- tools/check
pull_request:
paths:
- .github/workflows/snapshot.yml
- tests/snapshot/**
- serve/**
- strata_cli/**
- strata_control/**
- setup.py
- strata.py
- requirements*.txt
- apps/host/server/**
- apps/host/package*.json
- packaging/**
- tools/check
workflow_dispatch:
permissions:
contents: read
concurrency:
group: snapshot-${{ github.ref }}
cancel-in-progress: true
jobs:
snapshot:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2022]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements*.txt
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: apps/host/package-lock.json
- name: Python packages
run: python -m pip install -r requirements.txt -r requirements-dev.txt
- name: Host packages (the app's Load runs in Node)
working-directory: apps/host
run: npm ci --no-audit --no-fund
- name: tools/check snapshot
run: python tools/check --only snapshot --keep --scratch "${{ runner.temp }}/check"
- name: Logs
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: snapshot-${{ matrix.os }}
path: ${{ runner.temp }}/check/*.log
retention-days: 7
+2 -2
View File
@@ -1,6 +1,6 @@
# AGENTS.md
Version 0.2.10, engine 0.1.56. The public model families are DeepSeekV4.1-Flash-1017,
Version 0.2.13, engine 0.1.59. The public model families are DeepSeekV4.1-Flash-1017,
Qwen3.8-Flash-Next and Qwen3.6-35B-A3B. DeepSeek V4.1-Flash runs in the app and in the Terminal
(`strata launch --family deepseek41 --model ist-das-2.00bpw`; sets in
[docs/MODELS.md](docs/MODELS.md#deepseek-v41-flash)). Read **[docs/AI_REFERENCE.md](docs/AI_REFERENCE.md)** for model availability, APIs, hardware limits and
@@ -15,7 +15,7 @@ Strata has three parts:
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
not want a terminal. One installer per system ([latest release](https://github.com/Niko1221/Strata/releases/latest)):
`Strata-0.2.9-windows-x64-setup.exe`, `Strata-0.2.9-linux-x86_64.AppImage`, `Strata-0.2.9-macos-arm64.dmg`.
`Strata-0.2.13-windows-x64-setup.exe`, `Strata-0.2.13-linux-x86_64.AppImage`, `Strata-0.2.13-macos-arm64.dmg`.
The Windows and Linux installers carry no engine: the app downloads the engine for the graphics card on first
start, or installs a file the user already has. The Mac dmg keeps its small Metal engine inside.
- **The Terminal** (`strata.py`, `strata_cli/`, `strata_control/`): the `strata` command. It sets up a model, serves
+145 -2478
View File
File diff suppressed because it is too large. Load diff
+78
View File
@@ -0,0 +1,78 @@
{
"version": 5,
"cmakeMinimumRequired": { "major": 3, "minor": 24, "patch": 0 },
"configurePresets": [
{
"name": "cpu-check",
"displayName": "CPU check build (no GPU, no network)",
"description": "The fast check: the CPU-only engine pieces and every test that needs no GPU and no model. STRATA_NATIVE_EXPERTS=OFF, so ggml is not needed. tools/check runs this preset.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"STRATA_BUILD_TESTS": "ON",
"STRATA_NATIVE_EXPERTS": "OFF"
}
},
{
"name": "cpu-check-native",
"displayName": "CPU check build with the native expert tests",
"description": "As cpu-check plus the native (IQ) expert kernels and their tests. ggml comes from the vendored third_party/strata-ggml (no download); -DSTRATA_GGML_DIR=<llama.cpp checkout> overrides it.",
"inherits": "cpu-check",
"cacheVariables": { "STRATA_NATIVE_EXPERTS": "ON" }
},
{
"name": "cuda-compile",
"displayName": "CUDA compile-only (the release flags of packaging/build_cuda_linux.sh, no tests)",
"description": "Builds the strata and strata_kq targets for the given CUDA architectures. No GPU is needed to compile; nothing is run. ggml is the vendored third_party/strata-ggml; -DSTRATA_GGML_DIR=<llama.cpp checkout> overrides it (CI still passes one).",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"STRATA_ENABLE_CUDA": "ON",
"STRATA_BUILD_TESTS": "OFF",
"STRATA_NATIVE_EXPERTS": "ON",
"STRATA_PORTABLE": "ON",
"STRATA_Q6K_EXPERTS": "ON",
"STRATA_MMQ_KQUANTS": "ON",
"STRATA_EXPERIMENTAL_SM60": "OFF",
"CMAKE_CUDA_ARCHITECTURES": "86-real;89-real;89-virtual"
}
},
{
"name": "hip-compile",
"displayName": "HIP compile-only (the release flags of packaging/build_hip_linux.sh, no tests)",
"description": "Builds the strata target for gfx1100 and gfx1201 with ROCm's hipcc. ggml is the vendored third_party/strata-ggml (-DSTRATA_GGML_DIR=<llama.cpp checkout> overrides it); if hipcc is not on PATH pass -DCMAKE_HIP_COMPILER=<rocm>/llvm/bin/clang++.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"STRATA_ENABLE_HIP": "ON",
"STRATA_ENABLE_CUDA": "OFF",
"STRATA_BUILD_TESTS": "OFF",
"STRATA_PREFILL_MMQ": "ON",
"STRATA_PORTABLE": "ON",
"CMAKE_HIP_ARCHITECTURES": "gfx1100;gfx1201"
}
}
],
"buildPresets": [
{ "name": "cpu-check", "configurePreset": "cpu-check", "jobs": 0 },
{ "name": "cpu-check-native", "configurePreset": "cpu-check-native", "jobs": 0 },
{ "name": "cuda-compile", "configurePreset": "cuda-compile", "targets": ["strata", "strata_kq"], "jobs": 0 },
{ "name": "hip-compile", "configurePreset": "hip-compile", "targets": ["strata"], "jobs": 0 }
],
"testPresets": [
{
"name": "cpu-check",
"configurePreset": "cpu-check",
"output": { "outputOnFailure": true, "shortProgress": true },
"execution": { "timeout": 120, "noTestsAction": "error", "jobs": 4 }
},
{
"name": "cpu-check-native",
"configurePreset": "cpu-check-native",
"inherits": "cpu-check"
}
]
}
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Strata herunterladen"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT-Lizenz"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Strata über GitHub Sponsors unterstützen"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Strata auf Buy Me a Coffee unterstützen"></a>
</p>
@@ -62,7 +63,7 @@ In der **Desktop-App** und im **Terminal**. Was nicht in den Arbeitsspeicher pas
| 2.00 bpw | 257 GB | etwa 265 GB |
| 2.25 bpw | 274 GB | etwa 280 GB |
Läuft auf NVIDIA RTX 30, 40 und 50 (Windows und Linux) und auf AMD Radeon AI PRO R9700 (Linux). Das Setup lädt die Dateien und die DeepSeek-Engine herunter und startet dann das Modell.
Läuft auf NVIDIA RTX 30, 40 und 50 (Windows und Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo (Windows) und Apple-Silicon-Macs mit 64 GB oder mehr ([Mac](docs/DEEPSEEK_MAC.md)). Das Setup lädt die Dateien und die DeepSeek-Engine herunter und startet dann das Modell.
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -95,7 +96,7 @@ Unsicher, was passt? [Wähle ein Modell für deinen Computer](docs/MODELS.md#pic
Installiere die App, wähle ein Modell und leg mit dem Chat los. Es gibt einen Installer pro System. Beim ersten Start lädt die App die Engine für deine Grafikkarte herunter. [Hilfe zum Installer](docs/INSTALL.md#the-app).
<sub>Dateien: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>Dateien: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata-Desktop-App mit Chat und Live-Codevorschau nebeneinander">
@@ -175,10 +176,27 @@ Führe **`strata connect`** aus, um die Einrichtung für dein Tool zu sehen. [Ve
## Strata unterstützen
**Hilf mit, lokale KI für alle offen zu halten.** Ein Kaffee hilft, Entwicklung und Tests auf mehr Hardware zu finanzieren.
**Hilf mit, lokale KI für alle offen zu halten.** Ein GitHub-Sponsoring oder ein Kaffee hilft, Entwicklung und Tests auf mehr Hardware zu finanzieren.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: Strata unterstützen" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: Strata unterstützen" height="48"></a>
### Treue Unterstützer
Danke an alle, die Strata unterstützen.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[MIT-Lizenz](LICENSE) · [Danksagung und Modelllizenzen](docs/HOW_IT_WORKS.md#credits) · [Datenschutz](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Descargar Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licencia MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Patrocina Strata en GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Apoya Strata en Buy Me a Coffee"></a>
</p>
@@ -62,7 +63,7 @@ En la **aplicación de escritorio** y en el **Terminal**. Lo que no cabe en la m
| 2.00 bpw | 257 GB | unos 265 GB |
| 2.25 bpw | 274 GB | unos 280 GB |
Funciona en NVIDIA RTX 30, 40 y 50 (Windows y Linux) y en AMD Radeon AI PRO R9700 (Linux). La instalación descarga los archivos y el motor de DeepSeek, y después inicia el modelo.
Funciona en NVIDIA RTX 30, 40 y 50 (Windows y Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo (Windows) y Macs con Apple Silicon de 64 GB o más ([Mac](docs/DEEPSEEK_MAC.md)). La instalación descarga los archivos y el motor de DeepSeek, y después inicia el modelo.
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -95,7 +96,7 @@ Medido con el conjunto de 2.00 bpw en Strata 0.2.5. «Salida» es la velocidad d
Instala la aplicación, elige un modelo y empieza a chatear. Hay un instalador por sistema. En el primer arranque, la aplicación descarga el motor para tu tarjeta gráfica. [Ayuda con el instalador](docs/INSTALL.md#the-app).
<sub>Archivos: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>Archivos: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplicación de escritorio de Strata con un chat y una vista previa del código en vivo, una junto a otra">
@@ -175,10 +176,27 @@ Ejecuta **`strata connect`** para ver las instrucciones de tu herramienta. [Guí
## Apoya Strata
**Ayuda a mantener la IA local abierta a todos.** Un café ayuda a financiar el desarrollo y las pruebas en más hardware.
**Ayuda a mantener la IA local abierta a todos.** Un patrocinio en GitHub o un café ayuda a financiar el desarrollo y las pruebas en más hardware.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: patrocina Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: apoya Strata" height="48"></a>
### Apoyos fieles
Gracias a todas las personas que apoyan Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[Licencia MIT](LICENSE) · [Créditos y licencias de los modelos](docs/HOW_IT_WORKS.md#credits) · [Privacidad](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Télécharger Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licence MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Soutenir Strata sur GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Soutenir Strata sur Buy Me a Coffee"></a>
</p>
@@ -62,7 +63,7 @@ Dans l'**application de bureau** et dans le **Terminal**. Ce qui ne tient pas en
| 2.00 bpw | 257 GB | environ 265 GB |
| 2.25 bpw | 274 GB | environ 280 GB |
Fonctionne sur les NVIDIA RTX 30, 40 et 50 (Windows et Linux) et sur l'AMD Radeon AI PRO R9700 (Linux). L'installation télécharge les fichiers et le moteur DeepSeek, puis démarre le modèle.
Fonctionne sur les NVIDIA RTX 30, 40 et 50 (Windows et Linux), l'AMD Radeon AI PRO R9700 (Linux), l'AMD Ryzen AI Max — Strix Halo (Windows) et les Mac Apple Silicon avec 64 Go ou plus ([Mac](docs/DEEPSEEK_MAC.md)). L'installation télécharge les fichiers et le moteur DeepSeek, puis démarre le modèle.
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -95,7 +96,7 @@ Vous ne savez pas ce qui tient ? [Choisir un modèle pour votre ordinateur](docs
Installez l'application, choisissez un modèle et commencez à discuter. Il y a un seul installateur par système. Au premier lancement, l'application télécharge le moteur de votre carte graphique. [Aide pour l'installateur](docs/INSTALL.md#the-app).
<sub>Fichiers: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>Fichiers: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Application de bureau Strata avec une discussion et un aperçu du code en direct côte à côte">
@@ -175,10 +176,27 @@ Lancez **`strata connect`** pour voir la configuration de votre outil. [Guide de
## Soutenir Strata
**Aidez à garder l'IA locale ouverte à tous.** Un café aide à financer le développement et les tests sur davantage de matériel.
**Aidez à garder l'IA locale ouverte à tous.** Un parrainage sur GitHub ou un café aide à financer le développement et les tests sur davantage de matériel.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors : soutenir Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee : soutenir Strata" height="48"></a>
### Fidèles soutiens
Merci à toutes les personnes qui soutiennent Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[Licence MIT](LICENSE) · [Crédits et licences des modèles](docs/HOW_IT_WORKS.md#credits) · [Confidentialité](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Strata をダウンロード"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT ライセンス"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="GitHub Sponsors で Strata を支援する"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Buy Me a Coffee で Strata を支援する"></a>
</p>
@@ -62,7 +63,7 @@
| 2.00 bpw | 257 GB | 約265 GB |
| 2.25 bpw | 274 GB | 約280 GB |
NVIDIA RTX 30・40・50 シリーズ(Windows と Linux)と、AMD Radeon AI PRO R9700(Linux)で動きます。セットアップがファイルと DeepSeek エンジンをダウンロードし、モデルを起動します。
NVIDIA RTX 30・40・50 シリーズ(Windows と Linux)、AMD Radeon AI PRO R9700(Linux)、AMD Ryzen AI Max — Strix Halo(Windows)、メモリ 64 GB 以上の Apple シリコン搭載 Mac([Mac](docs/DEEPSEEK_MAC.md))で動きます。セットアップがファイルと DeepSeek エンジンをダウンロードし、モデルを起動します。
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -95,7 +96,7 @@ Strata 0.2.5 で 2.00 bpw のセットを使って測定しました。「出力
アプリをインストールし、モデルを選んで、チャットを始めましょう。インストーラーはシステムごとに1つです。初回起動時に、アプリがお使いのグラフィックスカード用のエンジンをダウンロードします。[インストーラーのヘルプ](docs/INSTALL.md#the-app)。
<sub>ファイル: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>ファイル: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="チャットとライブのコードプレビューを並べて表示した Strata デスクトップアプリ">
@@ -175,10 +176,27 @@ strata launch
## Strata を支援する
**ローカル AI をみんなに開かれたものに。** コーヒー1杯が、開発と、より多くのハードウェアでのテストの支えになります。
**ローカル AI をみんなに開かれたものに。** GitHub でのスポンサーやコーヒー1杯が、開発と、より多くのハードウェアでのテストの支えになります。
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: Strata を支援する" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: Strata を支援する" height="48"></a>
### いつも支えてくださる方々
Strata を支えてくださるすべての方に感謝します。
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[MIT ライセンス](LICENSE) · [クレジットとモデルのライセンス](docs/HOW_IT_WORKS.md#credits) · [プライバシー](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Download Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT license"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Sponsor Strata on GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Support Strata on Buy Me a Coffee"></a>
</p>
@@ -65,7 +66,7 @@ disk space.
| ISTA-DAS Coder (half of the experts removed) | 191 GB | about 197 GB |
Runs on NVIDIA RTX 30, 40 and 50 series (Windows and Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo
(Windows), and Apple Silicon Macs with 64 GB or more, text only ([Mac](docs/DEEPSEEK_MAC.md)). Setup downloads the files and the DeepSeek engine, then starts the model.
(Windows), and Apple Silicon Macs with 64 GB or more ([Mac](docs/DEEPSEEK_MAC.md)). Setup downloads the files and the DeepSeek engine, then starts the model.
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -100,7 +101,7 @@ Not sure what fits? [Choose a model for your computer](docs/MODELS.md#pick-by-me
Install the app, choose a model, and start chatting. There is one installer for each system. On first start the app
downloads the engine for your graphics card. [Help with the installer](docs/INSTALL.md#the-app).
<sub>Files: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>Files: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata desktop app with a chat and live code preview side by side">
@@ -181,10 +182,27 @@ Run **`strata connect`** to see setup instructions for your tool. [Connection gu
## Support Strata
**Help keep local AI open to everyone.** A coffee helps fund development and testing on more hardware.
**Help keep local AI open to everyone.** A GitHub sponsorship or a coffee helps fund development and testing on more hardware.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: sponsor Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: support Strata" height="48"></a>
### Loyal supporters
Thank you to everyone who supports Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[MIT license](LICENSE) · [Credits and model licenses](docs/HOW_IT_WORKS.md#credits) · [Privacy](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Pobierz Stratę"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licencja MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Wesprzyj Stratę na GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Wesprzyj Stratę na Buy Me a Coffee"></a>
</p>
@@ -64,7 +65,7 @@ dużo wolnego miejsca na dysku.
| 2.25 bpw | 274 GB | około 280 GB |
Działa na kartach NVIDIA RTX serii 30, 40 i 50 (Windows i Linux), AMD Radeon AI PRO R9700 (Linux) oraz AMD Ryzen AI Max — Strix Halo
(Windows). Instalacja pobiera pliki i silnik DeepSeek, a potem uruchamia model.
(Windows) oraz na komputerach Mac z Apple Silicon i co najmniej 64 GB pamięci ([Mac](docs/DEEPSEEK_MAC.md)). Instalacja pobiera pliki i silnik DeepSeek, a potem uruchamia model.
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -99,7 +100,7 @@ Nie wiesz, co się zmieści? [Wybierz model do swojego komputera](docs/MODELS.md
Zainstaluj aplikację, wybierz model i zacznij rozmawiać. Dla każdego systemu jest jeden instalator. Przy pierwszym
uruchomieniu aplikacja pobiera silnik dla twojej karty graficznej. [Pomoc z instalatorem](docs/INSTALL.md#the-app).
<sub>Pliki: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>Pliki: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplikacja Strata na komputer: czat i podgląd kodu na żywo obok siebie">
@@ -179,10 +180,27 @@ Uruchom **`strata connect`**, aby zobaczyć instrukcję konfiguracji dla swojego
## Wesprzyj Stratę
**Pomóż zachować lokalną sztuczną inteligencję otwartą dla wszystkich.** Kawa pomaga finansować rozwój i testy na większej liczbie sprzętu.
**Pomóż zachować lokalną sztuczną inteligencję otwartą dla wszystkich.** Sponsoring na GitHubie lub kawa pomaga finansować rozwój i testy na większej liczbie sprzętu.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: wesprzyj Stratę" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: wesprzyj Stratę" height="48"></a>
### Wierni wspierający
Dziękujemy wszystkim, którzy wspierają Stratę.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[Licencja MIT](LICENSE) · [Podziękowania i licencje modeli](docs/HOW_IT_WORKS.md#credits) · [Prywatność](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Baixar o Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licença MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Apoie o Strata no GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Apoie o Strata no Buy Me a Coffee"></a>
</p>
@@ -62,7 +63,7 @@ No **aplicativo de desktop** e no **Terminal**. O que não cabe na memória é l
| 2.00 bpw | 257 GB | cerca de 265 GB |
| 2.25 bpw | 274 GB | cerca de 280 GB |
Roda em NVIDIA RTX 30, 40 e 50 (Windows e Linux) e em AMD Radeon AI PRO R9700 (Linux). A instalação baixa os arquivos e o motor do DeepSeek e depois inicia o modelo.
Roda em NVIDIA RTX 30, 40 e 50 (Windows e Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo (Windows) e Macs com Apple Silicon e 64 GB ou mais ([Mac](docs/DEEPSEEK_MAC.md)). A instalação baixa os arquivos e o motor do DeepSeek e depois inicia o modelo.
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -95,7 +96,7 @@ Não sabe o que cabe? [Escolha um modelo para o seu computador](docs/MODELS.md#p
Instale o aplicativo, escolha um modelo e comece a conversar. Há um instalador por sistema. Na primeira vez que abre, o aplicativo baixa o motor da sua placa de vídeo. [Ajuda com o instalador](docs/INSTALL.md#the-app).
<sub>Arquivos: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>Arquivos: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplicativo de desktop do Strata com um chat e uma prévia do código ao vivo lado a lado">
@@ -175,10 +176,27 @@ Execute **`strata connect`** para ver as instruções da sua ferramenta. [Guia d
## Apoie o Strata
**Ajude a manter a IA local aberta para todos.** Um café ajuda a financiar o desenvolvimento e os testes em mais hardware.
**Ajude a manter a IA local aberta para todos.** Um patrocínio no GitHub ou um café ajuda a financiar o desenvolvimento e os testes em mais hardware.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: apoie o Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: apoie o Strata" height="48"></a>
### Apoiadores fiéis
Obrigado a todas as pessoas que apoiam o Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[Licença MIT](LICENSE) · [Créditos e licenças dos modelos](docs/HOW_IT_WORKS.md#credits) · [Privacidade](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+21 -3
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="下载 Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT 许可证"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="在 GitHub Sponsors 上支持 Strata"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="在 Buy Me a Coffee 上支持 Strata"></a>
</p>
@@ -62,7 +63,7 @@
| 2.00 bpw | 257 GB | 约265 GB |
| 2.25 bpw | 274 GB | 约280 GB |
支持 NVIDIA RTX 30、40 和 50 系列(Windows 和 Linux),以及 AMD Radeon AI PRO R9700(Linux)。安装程序会下载模型文件和 DeepSeek 引擎,然后启动模型。
支持 NVIDIA RTX 30、40 和 50 系列(Windows 和 Linux)、AMD Radeon AI PRO R9700(Linux)、AMD Ryzen AI Max — Strix Halo(Windows),以及内存 64 GB 或以上的 Apple 芯片 Mac([Mac](docs/DEEPSEEK_MAC.md))。安装程序会下载模型文件和 DeepSeek 引擎,然后启动模型。
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -95,7 +96,7 @@ strata launch --family deepseek41 --model ist-das-2.00bpw --plan
安装应用,选择模型,就可以开始聊天。每个系统只有一个安装程序。首次启动时,应用会下载适合你显卡的引擎。[安装程序帮助](docs/INSTALL.md#the-app)。
<sub>文件: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<sub>文件: `Strata-0.2.13-windows-x64-setup.exe` · `Strata-0.2.13-linux-x86_64.AppImage` · `Strata-0.2.13-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata 桌面应用,聊天和实时代码预览并排显示">
@@ -175,10 +176,27 @@ strata launch
## 支持 Strata
**帮助本地 AI 保持开放。** 一杯咖啡能帮助我们在更多硬件上开发和测试。
**帮助本地 AI 保持开放。** GitHub 赞助或一杯咖啡,都能帮助我们在更多硬件上开发和测试。
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors:支持 Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee:支持 Strata" height="48"></a>
### 忠实支持者
感谢每一位支持 Strata 的人。
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<a href="https://github.com/kod88vn"><img src="https://github.com/kod88vn.png?size=96" width="48" height="48" alt="kod88vn" title="kod88vn"></a>
<a href="https://github.com/seoulworker"><img src="https://github.com/seoulworker.png?size=96" width="48" height="48" alt="seoulworker" title="seoulworker"></a>
<a href="https://github.com/jackyini"><img src="https://github.com/jackyini.png?size=96" width="48" height="48" alt="jackyini" title="jackyini"></a>
<a href="https://github.com/router0mail"><img src="https://github.com/router0mail.png?size=96" width="48" height="48" alt="router0mail" title="router0mail"></a>
<!-- supporters:end -->
[MIT 许可证](LICENSE) · [致谢和模型许可证](docs/HOW_IT_WORKS.md#credits) · [隐私](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.10",
"version": "0.2.13",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Is every static-PIE program inside an AppImage still the program it was staged as? (#4337)
Why: the bundled ripgrep (`harness/node_modules/@vscode/ripgrep-linux-x64/bin/rg`) is a statically linked PIE. Such a program relocates itself at
start-up by reading its own `.dynamic` section through a fixed offset in its code. linuxdeploy, which Tauri runs over the AppDir, treats every ELF file
as a library to fix up and rewrites its RPATH; for a static PIE that moves `.dynamic` to a new segment and leaves the old place filled with a filler
byte, so the start-up code reads garbage. Reporters on #4337 see the same crash offset in every run (`rg + 0x3ffcf1`) and filler bytes (0x5A) where
`.rela.dyn` and `.dynamic` were. A static PIE whose `.dynamic` is filled does not start: tools/test_appimage_elf.py shows that on a real one.
apps/desktop/scripts/repack-appimage.sh puts the staged bytes back; this file is the check that says whether it did.
appimage_elf.py scan <folder> every static PIE under <folder> must have a sound .dynamic (exit 1 if not)
appimage_elf.py compare <staged-folder> <folder> the same files, byte for byte, plus what differs in their ELF headers
What is checked: only static PIE programs. A shared library (.so, Python's and Node's native modules, Pillow, numpy, sharp) is not one, however it is
named: it has no entry point, or a DT_SONAME and no DF_1_PIE flag, and its RPATH/RUNPATH is normal. A static PIE is an ET_DYN with no interpreter, an entry
point, and DF_1_PIE in DT_FLAGS_1; one whose .dynamic cannot be read at all (overwritten) is reported as damaged, since the overwrite takes the flags too.
Standard library only; reads ELF64 little endian (x86-64 and arm64), anything else is ignored.
"""
import os
import struct
import sys
ET_DYN = 3
PT_LOAD, PT_DYNAMIC, PT_INTERP = 1, 2, 3
DT_NULL, DT_NEEDED, DT_RELA, DT_RELASZ, DT_RELAENT, DT_SONAME, DT_RPATH, DT_RUNPATH = 0, 1, 7, 8, 9, 14, 15, 29
DT_FLAGS_1, DF_1_PIE = 0x6FFFFFFB, 0x08000000
FILLERS = (0x5A, 0x58) # 'Z' (what the reports found) and 'X' (what patchelf documents)
RUN = 128 # that many equal filler bytes in a row is not code or data
def _known_tag(tag):
"""The dynamic tags the ELF specification and the GNU and processor ranges define: anything else is garbage."""
return 0 <= tag <= 0x22 or 0x6000000D <= tag <= 0x6FFFFFFF or 0x70000000 <= tag <= 0x7FFFFFFF
def read_elf(data):
"""The facts about an ELF64 little-endian file, or None when it is not one."""
if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1:
return None
e_type = struct.unpack_from("<H", data, 16)[0]
e_entry = struct.unpack_from("<Q", data, 0x18)[0]
phoff = struct.unpack_from("<Q", data, 0x20)[0]
phentsize, phnum = struct.unpack_from("<HH", data, 0x36)
if phentsize < 56 or phoff + phentsize * phnum > len(data):
return {"type": e_type, "entry": e_entry, "phdrs": [], "broken": "the program header table is outside the file"}
phdrs = []
for i in range(phnum):
p_type, flags, off, vaddr, _paddr, filesz, memsz, align = struct.unpack_from("<IIQQQQQQ", data, phoff + i * phentsize)
phdrs.append({"type": p_type, "flags": flags, "off": off, "vaddr": vaddr, "filesz": filesz, "memsz": memsz, "align": align})
return {"type": e_type, "entry": e_entry, "phdrs": phdrs, "broken": None}
def is_pie_candidate(info):
"""An ET_DYN with no interpreter, a dynamic segment and an entry point: what a static PIE looks like from outside the dynamic section.
A shared library normally has no entry point (e_entry 0) and is not a candidate; the few that do (the C library itself, the dynamic loader)
have a readable dynamic section and are told apart by it (static_pie_kind). The test uses nothing the damage we look for can erase."""
return bool(info) and info["type"] == ET_DYN and not info["broken"] and info.get("entry", 0) != 0 and not any(p["type"] == PT_INTERP for p in info["phdrs"]) and any(p["type"] == PT_DYNAMIC for p in info["phdrs"])
def static_pie_kind(data, info):
"""'static-pie' for a sound static PIE (DT_FLAGS_1 has DF_1_PIE, no DT_SONAME), 'damaged' for a candidate whose dynamic section cannot be read
(a static PIE with an overwritten .dynamic loses its flags with it, so unreadable is treated as damaged), None for everything else:
shared libraries, which carry a DT_SONAME and no DF_1_PIE, and programs that are not static PIEs."""
if not is_pie_candidate(info):
return None
entries, why = dynamic_entries(data, info)
if why or any(not _known_tag(t) for t, _ in entries):
return "damaged"
tags = {t: v for t, v in entries}
if DT_SONAME in tags:
return None
return "static-pie" if tags.get(DT_FLAGS_1, 0) & DF_1_PIE else None
def is_static_pie(info):
"""Kept for callers: a candidate. check_static_pie decides with static_pie_kind."""
return is_pie_candidate(info)
def _mapped(info, vaddr, size):
"""The file offset of [vaddr, vaddr+size) when one PT_LOAD maps all of it from the file, else None."""
for p in info["phdrs"]:
if p["type"] == PT_LOAD and p["vaddr"] <= vaddr and vaddr + size <= p["vaddr"] + p["filesz"]:
return p["off"] + (vaddr - p["vaddr"])
return None
def dynamic_entries(data, info):
"""(tag, value) pairs of the PT_DYNAMIC segment up to DT_NULL, and the problem if it does not end properly."""
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
if seg["off"] + seg["filesz"] > len(data):
return [], "the dynamic segment is outside the file"
out = []
for at in range(seg["off"], seg["off"] + seg["filesz"] - 15, 16):
tag, val = struct.unpack_from("<qQ", data, at)
if tag == DT_NULL:
return out, None
out.append((tag, val))
return out, "the dynamic segment does not end with DT_NULL"
def _longest_filler(data, start, end):
best = run = 0
prev = None
for b in data[start:end]:
run = run + 1 if b == prev and b in FILLERS else (1 if b in FILLERS else 0)
prev = b
best = max(best, run)
return best
def check_static_pie(data):
"""The problems of one file (a list of sentences); empty for a sound static PIE and for anything that is not one."""
info = read_elf(data)
if static_pie_kind(data, info) is None:
return []
problems = []
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
if _mapped(info, seg["vaddr"], max(seg["filesz"], 1)) is None:
problems.append("the dynamic segment is not inside any loaded segment")
entries, why = dynamic_entries(data, info)
if why:
problems.append(why)
bad = [t for t, _ in entries if not _known_tag(t)]
if bad:
problems.append(f"{len(bad)} dynamic entries have tags that do not exist (first {bad[0]:#x}): the section is overwritten")
if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in entries):
problems.append("a static PIE has an RPATH or RUNPATH: something rewrote it after it was linked")
values = dict(entries)
if DT_RELA in values:
size = values.get(DT_RELASZ, 0)
at = _mapped(info, values[DT_RELA], max(size, 1))
if size == 0 or at is None:
problems.append("the relocation table named by DT_RELA is empty or outside the loaded segments")
elif _longest_filler(data, at, at + size) >= min(RUN, size):
problems.append("the relocation table is filled with one byte")
for p in info["phdrs"]:
if p["type"] == PT_LOAD and _longest_filler(data, p["off"], p["off"] + p["filesz"]) >= RUN:
problems.append(f"{RUN} or more filler bytes (0x5A or 0x58) in a row inside a loaded segment")
break
return problems
def elf_differences(staged, packaged):
"""What differs between two builds of one program in the parts that matter for start-up: for the report."""
a, b = read_elf(staged), read_elf(packaged)
if not a or not b:
return [] if staged == packaged else ["one of the two is not an ELF64 file"]
out = []
ta, tb = [p["type"] for p in a["phdrs"]], [p["type"] for p in b["phdrs"]]
if ta != tb:
out.append(f"program headers {len(ta)} -> {len(tb)} ({ta.count(PT_LOAD)} -> {tb.count(PT_LOAD)} loaded segments)")
if is_pie_candidate(a) and is_pie_candidate(b):
da, _ = dynamic_entries(staged, a)
db, _ = dynamic_entries(packaged, b)
if da != db:
out.append(f"dynamic section: {len(da)} -> {len(db)} entries" + ("; an RPATH/RUNPATH was added" if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in db) and not any(t in (DT_RPATH, DT_RUNPATH) for t, _ in da) else ""))
return out
def _files(folder):
for base, dirs, names in os.walk(folder):
dirs.sort()
for name in sorted(names):
path = os.path.join(base, name)
if os.path.islink(path) or not os.path.isfile(path):
continue
yield path
def scan(folder):
"""{relative path: problems} for every static PIE under `folder` that has any."""
found = {}
for path in _files(folder):
try:
with open(path, "rb") as f:
head = f.read(4)
if head != b"\x7fELF":
continue
data = head + f.read()
except OSError:
continue
problems = check_static_pie(data)
if problems:
found[os.path.relpath(path, folder)] = problems
return found
def compare(staged_folder, folder):
"""{relative path: sentences} for every staged ELF file that is missing or differs in `folder`."""
found = {}
for path in _files(staged_folder):
rel = os.path.relpath(path, staged_folder)
with open(path, "rb") as f:
want = f.read()
if want[:4] != b"\x7fELF":
continue
other = os.path.join(folder, rel)
if not os.path.isfile(other):
found[rel] = ["missing"]
continue
with open(other, "rb") as f:
have = f.read()
if have != want:
found[rel] = ["bytes differ"] + elf_differences(want, have)
return found
def main(argv):
if len(argv) >= 3 and argv[1] == "scan":
bad = scan(argv[2])
for rel, problems in bad.items():
print(f"BROKEN {rel}: " + "; ".join(problems))
print(f"{len(bad)} static PIE file(s) with a damaged dynamic section")
return 1 if bad else 0
if len(argv) >= 4 and argv[1] == "compare":
bad = compare(argv[2], argv[3])
for rel, why in bad.items():
print(f"DIFFERS {rel}: " + "; ".join(why))
print(f"{len(bad)} ELF file(s) differ from what was staged")
return 1 if bad else 0
print(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))
+29 -2
View File
@@ -9,7 +9,9 @@
# installer's own integrity promise ("what we staged is what runs") is broken. This script unpacks the AppImage, copies the staged
# folders (control, harness, node, engine-bundle) over the AppDir byte for byte, and packs it again with the same runtime.
#
# --check does not write anything: it lists every engine-bundle file whose bytes differ from the manifest in the AppImage.
# --check does not write anything: it lists every file of control, harness, node and engine-bundle whose bytes differ from what was staged, then
# looks at every static PIE in the AppImage (appimage_elf.py: the bundled ripgrep is one; linuxdeploy's RPATH rewrite leaves such a program with a
# filled .dynamic and it crashes at start, #4337) and runs the bundled `rg` once. A single "0 file(s) differ" and "ok" is what a release needs.
set -euo pipefail
IN="$(realpath "${1:?in.AppImage}")"; OUT="${2:-}"
@@ -33,13 +35,36 @@ LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name engine-bundle -prin
[ -n "$LIB" ] || LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name control -printf '%h\n' | head -1)"
[ -n "$LIB" ] || { echo "no Strata resource folder in the AppImage" >&2; exit 1; }
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Every static PIE under the AppDir must still have a sound .dynamic, and the bundled ripgrep must start (x86-64 Linux only: it is run).
elf_and_rg_check() {
local bad=0
if command -v python3 >/dev/null 2>&1; then
python3 "$HERE/appimage_elf.py" scan "$LIB" || bad=1
else
echo "python3 is not here: the static PIE check was skipped" >&2
fi
local rg
rg="$(find "$LIB/harness" -path '*ripgrep-linux-x64/bin/rg' -type f 2>/dev/null | head -1)"
if [ -n "$rg" ] && [ "$(uname -s)" = Linux ] && [ "$(uname -m)" = x86_64 ]; then
local t; t="$(mktemp -d -t strata-rg.XXXXXX)"
echo 'needle in a haystack' > "$t/h.txt"
if "$rg" --version >/dev/null 2>&1 && [ "$("$rg" --no-config -c needle "$t/h.txt" 2>/dev/null)" = 1 ]; then echo "bundled rg runs: ok"
else echo "the bundled rg does not run: $rg" >&2; bad=1; fi
rm -rf "$t"
fi
return $bad
}
if [ "$CHECK" = 1 ]; then
bad=0
while IFS= read -r f; do
rel="${f#$RES/}"
if ! cmp -s "$f" "$LIB/$rel"; then echo "differs: $rel"; bad=$((bad + 1)); fi
done < <(find "$RES/engine-bundle" "$RES/node" -type f ! -name .gitignore)
done < <(for d in control harness node engine-bundle; do [ -d "$RES/$d" ] && find "$RES/$d" -type f ! -name .gitignore; done)
echo "$bad file(s) differ from what was staged"
elf_and_rg_check || bad=$((bad + 1))
[ "$bad" = 0 ]
exit
fi
@@ -49,6 +74,8 @@ for d in control harness node engine-bundle; do
[ -d "$RES/$d" ] || continue
rsync -aL --checksum --delete --exclude .gitignore "$RES/$d/" "$LIB/$d/"
done
# What was put back must be sound before it is packed: a damaged static PIE (the bundled ripgrep) stops the pack here, not on a person's computer.
elf_and_rg_check || { echo "the restored AppDir failed the static PIE / ripgrep check; not packing" >&2; exit 1; }
# The launcher records the environment the AppImage was started from, before its own hooks add the AppImage's libraries and GTK/GIO
# settings (LD_LIBRARY_PATH, GTK_*, XDG_DATA_DIRS ...). The host gives it back to every program that is not Strata's own (zenity,
# kdialog, xdg-open, the assistant's shell): apps/host/server/external-env.mjs. Without it a system program loads the AppImage's libssl
+4 -4
View File
@@ -616,10 +616,10 @@ await scenario('stop', { installed: true, env: { FAKE_TOKEN_DELAY: '0.1', FAKE_R
await scenario('latest', {}, async ({ base, browser, shot }) => {
const page = await newPage(browser, base);
const now = Date.now();
await page.evaluate(now => {
await page.evaluate(async now => {
const messages = [];
for (let i = 0; i < 40; i++) { messages.push({ id: 'u' + i, role: 'user', content: 'question ' + i, at: now }, { id: 'a' + i, role: 'assistant', content: 'answer number ' + i + ' with some text to fill the line and more of it.', at: now, tokens: 20, inputTokens: 5 }); }
localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }]));
await fetch('/api/chats/s_latest', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }) });
localStorage.setItem('strata.active-session.v1', 's_latest');
}, now);
await page.reload({ waitUntil: 'domcontentloaded' });
@@ -690,12 +690,12 @@ if (only.has('restart')) {
await run(async (context, { base }) => {
const page = context.pages()[0] ?? await context.newPage();
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 'chat-1', title: 'My chat' }])); });
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); return fetch('/api/chats/chat-1', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 'chat-1', title: 'My chat', messages: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }) }); });
});
await run(async (context, { base }) => {
const page = context.pages()[0] ?? await context.newPage();
await page.goto(base, { waitUntil: 'domcontentloaded' });
const saved = await page.evaluate(() => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: localStorage.getItem('strata.sessions.v1'), theme: document.documentElement.dataset.theme }));
const saved = await page.evaluate(async () => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: JSON.stringify(await (await fetch('/api/chats', { headers: { 'x-strata-client': 'ui' } })).json()), theme: document.documentElement.dataset.theme }));
check(saved.prefs.contentFontSize === 19 && saved.prefs.language === 'de', 'text size and language survive a restart on the same port', JSON.stringify(saved.prefs));
check(saved.theme === 'dark', 'the theme survives too', saved.theme);
check(/My chat/.test(saved.chats ?? ''), 'and the chat list', saved.chats);
+3 -1
View File
@@ -3625,7 +3625,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.10"
version = "0.2.13"
dependencies = [
"gtk",
"serde",
@@ -3637,6 +3637,8 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"webkit2gtk",
"webview2-com",
"windows-core",
]
[[package]]
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.10"
version = "0.2.13"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
@@ -11,6 +11,11 @@ license = "MIT"
gtk = "0.18"
webkit2gtk = "2.0"
# Windows: the shell listens for the page's renderer failing (WebView2 ProcessFailed, see hook_renderer_failures in main.rs); these are the versions wry itself uses.
[target.'cfg(windows)'.dependencies]
webview2-com = "0.39"
windows-core = "0.62"
[build-dependencies]
tauri-build = { version = "2", features = [] }
+2 -2
View File
@@ -188,7 +188,7 @@ fn graphics_environment() -> Vec<String> {
const NAMES: &[&str] = &[
"XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "DISPLAY", "WAYLAND_DISPLAY", "GDK_BACKEND", "GTK_THEME",
"WEBKIT_DISABLE_DMABUF_RENDERER", "WEBKIT_DISABLE_COMPOSITING_MODE", "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "LIBGL_ALWAYS_SOFTWARE",
"GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
"FONTCONFIG_FILE", "FONTCONFIG_PATH", "STRATA_FONT_CONFIG", "GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
];
NAMES.iter().filter_map(|n| std::env::var(n).ok().map(|v| format!(" {n}={v}"))).collect()
}
@@ -584,7 +584,7 @@ mod tests {
let path = write_startup_failure(&dir, "the app stopped unexpectedly: boom (at main.rs:1, thread main)", Some(Path::new("/x/desktop-1.log"))).unwrap();
assert!(path.file_name().unwrap().to_string_lossy().starts_with("desktop-crash-"));
let text = std::fs::read_to_string(&path).unwrap();
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", "0.2.0", "System:", "Meaning:"] {
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", env!("CARGO_PKG_VERSION"), "System:", "Meaning:"] {
assert!(text.contains(needle), "{needle}");
}
let _ = std::fs::remove_dir_all(&dir);
+291
View File
@@ -0,0 +1,291 @@
//! Linux: keep the colour emoji font that the AppImage's WebKitGTK cannot draw away from the app window (#4533).
//!
//! The WebKitGTK inside the AppImage paints a COLRv1 colour font through Skia, and its `colrv1_configure_skpaint` reads past the end of a
//! gradient's colour-stop list. The library is built with libstdc++ range checks on, so the read becomes `abort()` and `WebKitWebProcess`
//! dies: the window goes grey the first time a chat title or a message draws an emoji (Fedora 44 and newer Ubuntu ship
//! `Noto-COLRv1.ttf` as the default colour emoji font; the reporters on #4533 name the same assertion and the same workaround).
//!
//! fontconfig reads one file when `FONTCONFIG_FILE` is set, and that file replaces the normal configuration, so the file written here
//! includes the system configuration first and only then rejects the COLRv1 faces. Emoji are then drawn from whatever else the system
//! has (the outline "Noto Emoji" is preferred); nothing else about the fonts changes.
//!
//! Only the window process is told (main.rs run_window_process, before WebKit starts its own processes). The shell owns the host, the
//! engine and the agent, and they keep the fonts the person chose. A machine with no COLRv1 font is not touched at all.
//!
//! This file is plain std so its decisions are unit-tested on every platform; only the window process on Linux uses it.
//! The idea is that of community PR #4485; the code is written for this tree.
use std::path::{Path, PathBuf};
/// File name patterns of the COLRv1 faces (fontconfig globs, case sensitive, so both spellings). Bitmap (`CBDT`) and outline faces are not
/// listed: they draw fine, and hiding them would leave an empty box where an emoji should be.
pub const COLRV1_GLOBS: &[&str] = &["*COLRv1*", "*colrv1*", "*COLRV1*"];
/// The outline family preferred in place of the colour one.
const OUTLINE_EMOJI: &str = "Noto Emoji";
/// fontconfig's own configuration. `FONTCONFIG_FILE` replaces it, so the written file pulls it back in.
pub const SYSTEM_CONFIG: &str = "/etc/fonts/fonts.conf";
/// `STRATA_FONT_CONFIG=off` turns this off; `STRATA_FONT_CONFIG=/path/to/fonts.conf` uses the person's own file.
const OFF: &str = "off";
/// The name of the file written under the data folder.
const FILE_NAME: &str = "fonts-no-colrv1.conf";
/// Folders fontconfig scans by default, where a colour emoji font is found: the system ones, the person's own, and the XDG data folders.
pub fn font_dirs(env: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
match env("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
Some(data) => dirs.push(PathBuf::from(data).join("fonts")),
None => {
if let Some(h) = &home {
dirs.push(h.join(".local/share/fonts"));
}
}
}
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
}
if let Some(list) = env("XDG_DATA_DIRS") {
for d in list.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d).join("fonts"));
}
}
dirs.dedup();
dirs
}
/// A COLRv1 font file installed under `dirs`, if any. The scan is shallow and stops at the first one found, so a window that starts on a
/// machine without such a font costs a few directory reads and nothing else.
pub fn colrv1_font_present(dirs: &[PathBuf]) -> Option<PathBuf> {
fn scan(dir: &Path, depth: u8, found: &mut Option<PathBuf>) {
if found.is_some() || depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan(&path, depth - 1, found);
}
else if path.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase().contains("colrv1")).unwrap_or(false) {
*found = Some(path);
return;
}
if found.is_some() {
return;
}
}
}
let mut found = None;
for dir in dirs {
scan(dir, 5, &mut found);
if found.is_some() {
break;
}
}
found
}
/// The configuration file's text: the system's own rules, then the rejection of the COLRv1 faces, then the outline family preferred.
pub fn config_text(system_config: &str) -> String {
let mut lines: Vec<String> = vec![
"<?xml version=\"1.0\"?>".into(),
"<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">".into(),
"<!-- Written by Strata for its own window only. The WebKitGTK inside the AppImage aborts while it draws a COLRv1 colour font,".into(),
" so those faces are hidden from the window; every other font is the system's own setup, included first.".into(),
" Off: STRATA_FONT_CONFIG=off. Your own file: set FONTCONFIG_FILE yourself. -->".into(),
"<fontconfig>".into(),
format!(" <include ignore_missing=\"yes\">{system_config}</include>"),
" <selectfont>".into(),
];
// fontconfig rejects a font only inside <selectfont>
for glob in COLRV1_GLOBS {
lines.push(format!(" <rejectfont><glob>{glob}</glob></rejectfont>"));
}
lines.push(" </selectfont>".into());
lines.push(" <alias binding=\"strong\">".into());
lines.push(" <family>Noto Color Emoji</family>".into());
lines.push(format!(" <prefer><family>{OUTLINE_EMOJI}</family></prefer>"));
lines.push(" </alias>".into());
lines.push("</fontconfig>".into());
lines.join("\n") + "\n"
}
/// Write the file under `dir` and return its path, or None when it cannot be written or there is no system configuration to include
/// (then the window would be left without any font). Rewritten only when the contents differ, so a start-up touches the disk once, and a
/// file left by an older Strata is put right. Written through a temporary file and moved, so a second instance starting at the same moment
/// never hands fontconfig half a file.
pub fn ensure_config(dir: &Path, system_config: &Path) -> Option<PathBuf> {
if !system_config.exists() {
return None;
}
std::fs::create_dir_all(dir).ok()?;
let path = dir.join(FILE_NAME);
let want = config_text(&system_config.to_string_lossy());
if matches!(std::fs::read_to_string(&path), Ok(have) if have == want) {
return Some(path);
}
let tmp = dir.join(format!("{FILE_NAME}.{}.tmp", std::process::id()));
std::fs::write(&tmp, want.as_bytes()).ok()?;
if std::fs::rename(&tmp, &path).is_err() {
let _ = std::fs::remove_file(&tmp);
// another writer got there first: take its file only if it is the one wanted
return (std::fs::read_to_string(&path).ok()? == want).then_some(path);
}
Some(path)
}
/// The file the window process should hand to fontconfig, or None to leave the environment exactly as it is. In this order, so a person's
/// own choice always wins and a machine without the font is untouched:
/// 1. `FONTCONFIG_FILE` already set: theirs.
/// 2. `STRATA_FONT_CONFIG=off`: nothing.
/// 3. `STRATA_FONT_CONFIG=<path>`: that file, when it exists.
/// 4. no COLRv1 font installed: nothing.
pub fn config_for(env: &dyn Fn(&str) -> Option<String>, dir: &Path, dirs: &[PathBuf], system_config: &Path) -> Option<PathBuf> {
if env("FONTCONFIG_FILE").is_some() || env("STRATA_FONT_CONFIG").as_deref() == Some(OFF) {
return None;
}
if let Some(custom) = env("STRATA_FONT_CONFIG") {
let p = PathBuf::from(custom);
return p.is_file().then_some(p);
}
colrv1_font_present(dirs)?;
ensure_config(dir, system_config)
}
#[cfg(test)]
mod tests {
use super::*;
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let owned: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k: &str| owned.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone())
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("strata-fontguard-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn the_file_includes_the_system_config_first_and_rejects_only_colrv1_faces_inside_selectfont() {
let text = config_text("/etc/fonts/fonts.conf");
assert!(text.starts_with("<?xml"));
let include = text.find("/etc/fonts/fonts.conf").expect("the system's fonts are kept");
let select = text.find("<selectfont>").expect("a rejection needs <selectfont>");
let select_end = text.find("</selectfont>").expect("closed");
assert!(include < select && select < select_end);
assert_eq!(text.matches("<rejectfont>").count(), COLRV1_GLOBS.len());
for glob in COLRV1_GLOBS {
let at = text.find(&format!("<glob>{glob}</glob>")).unwrap_or_else(|| panic!("{glob} is rejected"));
assert!(at > select && at < select_end, "{glob} is inside <selectfont>");
}
// the bitmap and the outline faces stay
assert!(!text.contains("NotoColorEmoji.ttf") && !text.contains("NotoEmoji-Regular.ttf"));
assert!(text.contains("<prefer><family>Noto Emoji</family></prefer>"));
assert!(!text.contains('{') && !text.contains('}'), "no placeholder left: {text}");
// well formed enough: every opened tag is closed
for tag in ["fontconfig", "selectfont", "alias"] {
assert_eq!(text.matches(&format!("<{tag}")).count(), text.matches(&format!("</{tag}>")).count(), "{tag}");
}
}
#[test]
fn the_globs_match_the_real_file_names_and_not_the_faces_that_draw_fine() {
// fontconfig's glob is `*` anywhere; this is the same rule on the file names that matter
let hit = |name: &str| COLRV1_GLOBS.iter().any(|g| { let core = g.trim_matches('*'); name.contains(core) });
for bad in ["/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf", "/usr/share/fonts/truetype/noto/NotoColorEmoji-COLRv1.ttf",
"/home/me/.fonts/Twemoji-colrv1.ttf", "/usr/share/fonts/NotoSansCOLRv1-Regular.ttf"] {
assert!(hit(bad), "{bad}");
}
for good in ["/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf"] {
assert!(!hit(good), "{good}");
}
}
#[test]
fn a_persons_own_fontconfig_or_an_opt_out_leaves_the_environment_alone() {
let dir = scratch("own");
let system = dir.join("system.conf");
std::fs::write(&system, b"<fontconfig/>").unwrap();
let fonts = dir.join("fonts");
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("Noto-COLRv1.ttf"), b"x").unwrap();
let dirs = vec![fonts];
assert_eq!(config_for(&env_from(&[("FONTCONFIG_FILE", "/home/me/fonts.conf")]), &dir, &dirs, &system), None);
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "off")]), &dir, &dirs, &system), None);
let theirs = dir.join("theirs.conf");
std::fs::write(&theirs, b"<fontconfig/>").unwrap();
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", theirs.to_str().unwrap())]), &dir, &dirs, &system), Some(theirs));
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "/no/such/fonts.conf")]), &dir, &dirs, &system), None);
// no setting at all: the font is there, so the file is written
assert!(config_for(&env_from(&[]), &dir.join("out"), &dirs, &system).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_machine_without_a_colrv1_font_is_not_touched() {
let dir = scratch("none");
let system = dir.join("system.conf");
std::fs::write(&system, b"<fontconfig/>").unwrap();
let fonts = dir.join("fonts");
std::fs::create_dir_all(fonts.join("noto")).unwrap();
std::fs::write(fonts.join("noto/NotoColorEmoji.ttf"), b"bitmap").unwrap();
std::fs::write(fonts.join("noto/NotoEmoji-Regular.ttf"), b"outline").unwrap();
std::fs::write(fonts.join("noto/NotoSansArabic-Regular.ttf"), b"arabic").unwrap();
assert_eq!(colrv1_font_present(&[fonts.clone()]), None);
assert_eq!(config_for(&env_from(&[]), &dir.join("out"), &[fonts.clone()], &system), None);
assert!(!dir.join("out").exists(), "nothing is written");
std::fs::write(fonts.join("noto/Noto-COLRv1.ttf"), b"colour").unwrap();
assert!(colrv1_font_present(&[fonts]).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_scan_is_shallow_and_a_missing_folder_is_fine() {
let dir = scratch("deep");
let mut deep = dir.clone();
for i in 0..9 { deep = deep.join(format!("d{i}")); }
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("Noto-COLRv1.ttf"), b"x").unwrap();
assert_eq!(colrv1_font_present(&[dir.clone(), dir.join("missing")]), None, "nine levels down is not a font folder");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_folders_follow_the_xdg_variables() {
let none = font_dirs(&env_from(&[("HOME", "/home/me")]));
assert!(none.contains(&PathBuf::from("/usr/share/fonts")));
assert!(none.contains(&PathBuf::from("/home/me/.local/share/fonts")));
assert!(none.contains(&PathBuf::from("/home/me/.fonts")));
let set = font_dirs(&env_from(&[("HOME", "/home/me"), ("XDG_DATA_HOME", "/data/me"), ("XDG_DATA_DIRS", "/usr/local/share:/var/lib/flatpak/exports/share")]));
assert!(set.contains(&PathBuf::from("/data/me/fonts")));
assert!(!set.contains(&PathBuf::from("/home/me/.local/share/fonts")));
assert!(set.contains(&PathBuf::from("/var/lib/flatpak/exports/share/fonts")));
}
#[test]
fn the_file_is_written_once_an_old_one_is_put_right_and_nothing_is_left_behind() {
let dir = scratch("write");
let system = dir.join("system.conf");
std::fs::write(&system, b"<fontconfig/>").unwrap();
let out = dir.join("out");
let first = ensure_config(&out, &system).expect("written");
assert_eq!(ensure_config(&out, &system).unwrap(), first, "the same path every start");
std::fs::write(&first, "<fontconfig/>").unwrap();
assert_eq!(ensure_config(&out, &system), Some(first.clone()));
assert_eq!(std::fs::read_to_string(&first).unwrap(), config_text(&system.to_string_lossy()));
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "one file, no temporary");
// no system configuration to include: nothing is written (the window would have no font at all)
assert_eq!(ensure_config(&dir.join("out2"), &dir.join("absent.conf")), None);
assert!(!dir.join("out2").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+97 -1
View File
@@ -39,7 +39,11 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
mod updater;
mod desktoplog;
mod recovery;
mod shell;
// Plain std, built on every platform so its decisions are tested everywhere; only the Linux window process uses it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod fontguard;
#[cfg(target_os = "linux")]
mod supervisor;
@@ -879,7 +883,19 @@ fn main() {
/// Windows and macOS: one process owns the host and the window.
#[cfg(not(target_os = "linux"))]
fn run_embedded() {
tauri::Builder::default()
let builder = tauri::Builder::default();
// macOS: WKWebView ends the page's web content process under memory pressure and leaves a blank window; the page is loaded again (#5079).
#[cfg(target_os = "macos")]
let builder = builder.on_web_content_process_terminate(|webview| {
let port = webview.app_handle().state::<HostPort>().0.lock().ok().and_then(|p| *p);
let Some(port) = port else { return };
let failed = webview.url().ok().map(|u| u.to_string());
let target = webview.clone();
on_renderer_failure(format!("http://127.0.0.1:{port}"), recovery::Failure::RendererExited, "web content process terminated".to_string(), failed, move |url| {
if let Ok(url) = url.parse::<tauri::Url>() { let _ = target.navigate(url); }
});
});
builder
// First, as the plugin requires: a second launch ends here after telling the running app to show itself.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
@@ -987,9 +1003,80 @@ fn open_window(app: &tauri::App, port: u16, shell_token: &str) -> Result<(), Box
}
#[cfg(target_os = "linux")]
hook_linux_drops(&window, port, shell_token);
#[cfg(windows)]
hook_renderer_failures(&window, port);
Ok(())
}
/// Milliseconds since the first failure report or window (a clock that only goes forward, for the recovery limit).
#[cfg(not(target_os = "linux"))]
fn uptime_ms() -> u64 {
static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_millis() as u64
}
/// The page's renderer is gone (#5079). Writes it to the run log, and, when the window needs the page again, loads it again through
/// `navigate` after a short wait: the same screen with `?recovery=1` the first time (the page then starts with the sidebar previews
/// closed once), the home screen the second time, and nothing after two in two minutes. The chats are the host's and are not touched.
#[cfg(not(target_os = "linux"))]
fn on_renderer_failure(origin: String, failure: recovery::Failure, detail: String, failed: Option<String>, navigate: impl FnOnce(String) + Send + 'static) {
static GUARD: Mutex<recovery::Guard> = Mutex::new(recovery::Guard::new());
let say = |text: &str| { if let Some(log) = desktoplog::run_log() { log.line("shell", text); } };
say(&format!("the page's {} ({detail}); it was showing {}", failure.name(), failed.as_deref().unwrap_or("an unknown address")));
let plan = match GUARD.lock() {
Ok(mut guard) => recovery::plan(&mut guard, uptime_ms(), &origin, failure, failed.as_deref()),
Err(_) => recovery::Plan::Stop,
};
match plan {
recovery::Plan::Ignore => {}
recovery::Plan::Stop => say("the page's renderer failed again within two minutes of two recoveries; the window is left as it is (Reload in the page loads it again)"),
recovery::Plan::Reload { attempt, url } => {
say(&format!("loading the page again (recovery {attempt}): {url}"));
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(recovery::RELOAD_DELAY_MS));
navigate(url);
});
}
}
}
/// Windows: WebView2 tells the shell when a process of the page fails; the page's renderer dying is recovered by on_renderer_failure.
#[cfg(windows)]
fn hook_renderer_failures(window: &tauri::WebviewWindow, port: u16) {
use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2ProcessFailedEventArgs2, COREWEBVIEW2_PROCESS_FAILED_KIND};
use webview2_com::{take_pwstr, ProcessFailedEventHandler};
use windows_core::{Interface, PWSTR};
let target = window.clone();
let origin = format!("http://127.0.0.1:{port}");
let _ = window.with_webview(move |platform| unsafe {
let Ok(core) = platform.controller().CoreWebView2() else { return };
let handler = ProcessFailedEventHandler::create(Box::new(move |sender, args| {
let Some(args) = args else { return Ok(()) };
let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default();
args.ProcessFailedKind(&mut kind)?;
let mut detail = format!("kind {}", kind.0);
if let Ok(more) = args.cast::<ICoreWebView2ProcessFailedEventArgs2>() {
let mut reason = Default::default();
let mut exit_code = 0i32;
if more.Reason(&mut reason).is_ok() { detail = format!("{detail}, reason {}", recovery::reason_name(reason.0)); }
if more.ExitCode(&mut exit_code).is_ok() { detail = format!("{detail}, exit code {exit_code:#x}"); }
}
let failed = sender.and_then(|view| {
let mut source = PWSTR::null();
view.Source(&mut source).ok()?;
Some(take_pwstr(source))
});
let window = target.clone();
on_renderer_failure(origin.clone(), recovery::Failure::from_webview2(kind.0), detail, failed, move |url| {
if let Ok(url) = url.parse::<tauri::Url>() { let _ = window.navigate(url); }
});
Ok(())
}));
let mut token = 0i64;
let _ = core.add_ProcessFailed(&handler, &mut token);
});
}
/// A file:// address from a drag, as a path (percent-escapes decoded); anything else (http, mailto, text) is not a dropped file.
#[cfg_attr(windows, allow(dead_code))]
fn file_uri_to_path(uri: &str) -> Option<String> {
@@ -1143,6 +1230,15 @@ fn run_window_process() -> ! {
eprintln!("[strata] STRATA_WINDOW_TEST_CRASH is set: the window process aborts on purpose.");
std::process::abort();
}
// WebKitGTK aborts while it draws a COLRv1 colour emoji font (fontguard.rs, #4533): this process is told to leave that font out. Only this
// process, and before WebKit starts its own: the host, the engine and the agent keep the fonts the person chose.
{
let env = |k: &str| std::env::var(k).ok();
let dirs = fontguard::font_dirs(&env);
if let Some(conf) = fontguard::config_for(&env, &desktoplog::data_dir().join("fonts"), &dirs, Path::new(fontguard::SYSTEM_CONFIG)) {
std::env::set_var("FONTCONFIG_FILE", conf);
}
}
// The shell is this process's parent: when it is gone (killed, crashed, logged off) the window goes too.
let parent = std::os::unix::process::parent_id();
std::thread::spawn(move || loop {
+260
View File
@@ -0,0 +1,260 @@
//! A page whose renderer dies is brought back, not left on the browser's error page (#5079).
//!
//! WebView2 (Windows) reports a dead renderer through its ProcessFailed event, and WKWebView (macOS) through
//! `webViewWebContentProcessDidTerminate`; until now the shell listened to neither, so the window kept the browser's own "This page has a
//! problem" page, nothing reached the log, and the person's Reload brought back the same chat that had just crashed. Linux does not need it:
//! there the window is its own process and the shell moves to browser mode when it dies (supervisor.rs).
//!
//! This file is only decisions, so it builds and tests everywhere; the platform hooks are in main.rs.
//! - which failures are worth a reload (the page's own renderer; not a GPU process that restarts by itself, not a frame of the page);
//! - how many automatic recoveries are allowed in a window of time (a renderer that dies again at once is not reloaded for ever);
//! - where the reload goes: the same screen first, with the sidebar previews closed once; the home screen the second time.
//! The page reads `?recovery=<n>` in apps/host/src/lib/recovery.ts. The chats are the host's: nothing here touches them.
/// Automatic recoveries allowed inside `WINDOW_MS`; the next failure leaves the window as it is for the person to reload.
pub const MAX_AUTOMATIC: usize = 2;
/// The window of time the limit counts over.
pub const WINDOW_MS: u64 = 120_000;
/// Let the browser start a new renderer before the page is asked for again.
pub const RELOAD_DELAY_MS: u64 = 400;
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_KIND values, by number.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Failure {
BrowserExited,
/// The renderer of the page itself.
RendererExited,
Unresponsive,
/// The renderer of a frame inside the page (a website preview); the page itself is alive.
FrameRendererExited,
Gpu,
Other(i32),
}
impl Failure {
pub fn from_webview2(kind: i32) -> Failure {
match kind {
0 => Failure::BrowserExited,
1 => Failure::RendererExited,
2 => Failure::Unresponsive,
3 => Failure::FrameRendererExited,
6 => Failure::Gpu,
other => Failure::Other(other),
}
}
/// Only the page's own renderer dying leaves a window that has to be loaded again. A GPU process is restarted by the browser, a frame
/// shows its own sad face, an unresponsive page may only be busy, and a dead browser process cannot navigate at all.
pub fn needs_reload(self) -> bool {
self == Failure::RendererExited
}
pub fn name(self) -> String {
match self {
Failure::BrowserExited => "browser process exited".into(),
Failure::RendererExited => "renderer exited".into(),
Failure::Unresponsive => "renderer unresponsive".into(),
Failure::FrameRendererExited => "frame renderer exited".into(),
Failure::Gpu => "gpu process exited".into(),
Failure::Other(n) => format!("process failure kind {n}"),
}
}
}
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_REASON, for the log.
pub fn reason_name(reason: i32) -> &'static str {
match reason {
0 => "unexpected",
1 => "unresponsive",
2 => "terminated",
3 => "crashed",
4 => "launch failed",
5 => "out of memory",
6 => "profile deleted",
_ => "unknown",
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Load the page again; `attempt` is 1 for the first recovery of the window, 2 for the second.
Reload { attempt: usize },
/// Too many in a short time: leave the window alone.
Stop,
}
/// Counts recoveries over a sliding window of time. `now_ms` is any clock that only goes forward (the caller's uptime).
#[derive(Default, Debug)]
pub struct Guard {
times: Vec<u64>,
}
impl Guard {
pub const fn new() -> Guard {
Guard { times: Vec::new() }
}
pub fn on_failure(&mut self, now_ms: u64) -> Action {
self.times.retain(|t| now_ms.saturating_sub(*t) < WINDOW_MS);
if self.times.len() >= MAX_AUTOMATIC {
return Action::Stop;
}
self.times.push(now_ms);
Action::Reload { attempt: self.times.len() }
}
}
/// The address a recovery loads. The first goes back to the screen that died (its `#/...` part) with `?recovery=1`, which makes the page
/// start with the sidebar previews closed once; the second goes to the home screen, in case the screen itself is what crashes.
/// `failed` is the address the window was showing; it is used only when it is on `origin`.
pub fn recovery_url(origin: &str, attempt: usize, failed: Option<&str>) -> String {
let origin = origin.trim_end_matches('/');
let base = format!("{origin}/?recovery={}", attempt.clamp(1, 9));
if attempt > 1 {
return base;
}
let Some(failed) = failed else { return base };
// on this origin: after it comes the end, a path, a query or a fragment (not another digit of a longer port)
let rest = match failed.strip_prefix(origin) {
Some(rest) if rest.is_empty() || rest.starts_with(['/', '?', '#']) => rest,
_ => return base,
};
match rest.find('#') {
Some(at) => {
let hash = &rest[at..];
// only what a page address holds: no spaces, quotes, angle brackets or control characters
let safe = hash.len() < 2048 && hash.starts_with("#/") && hash.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '"' | '<' | '>' | '\\' | '`' | '{' | '}' | '|' | '^'));
if safe { format!("{base}{hash}") } else { base }
}
None => base,
}
}
/// What the shell does about one failure report.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Plan {
/// Not a failure that leaves the window needing a reload: only logged.
Ignore,
/// A reload is due, to `url`.
Reload { attempt: usize, url: String },
/// Too many recent recoveries: the window is left as it is.
Stop,
}
/// One failure report in, the decision out. The platform hooks (main.rs) only collect the facts and carry the decision out.
pub fn plan(guard: &mut Guard, now_ms: u64, origin: &str, failure: Failure, failed: Option<&str>) -> Plan {
if !failure.needs_reload() {
return Plan::Ignore;
}
match guard.on_failure(now_ms) {
Action::Stop => Plan::Stop,
Action::Reload { attempt } => Plan::Reload { attempt, url: recovery_url(origin, attempt, failed) },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_pages_own_renderer_is_reloaded() {
assert!(Failure::from_webview2(1).needs_reload());
for kind in [0, 2, 3, 4, 5, 6, 7, 8, 9, 42] {
assert!(!Failure::from_webview2(kind).needs_reload(), "kind {kind}");
}
assert_eq!(Failure::from_webview2(6), Failure::Gpu);
assert_eq!(Failure::from_webview2(1).name(), "renderer exited");
assert_eq!(Failure::from_webview2(42).name(), "process failure kind 42");
}
#[test]
fn reasons_are_named_for_the_log() {
assert_eq!(reason_name(5), "out of memory");
assert_eq!(reason_name(3), "crashed");
assert_eq!(reason_name(99), "unknown");
}
#[test]
fn two_recoveries_in_two_minutes_then_the_window_is_left_alone() {
let mut g = Guard::new();
assert_eq!(g.on_failure(1_000), Action::Reload { attempt: 1 });
assert_eq!(g.on_failure(5_000), Action::Reload { attempt: 2 });
assert_eq!(g.on_failure(6_000), Action::Stop);
assert_eq!(g.on_failure(100_000), Action::Stop);
// the first has left the window: one more is allowed, and it is the second of the window
assert_eq!(g.on_failure(1_000 + WINDOW_MS), Action::Reload { attempt: 2 });
assert_eq!(g.on_failure(1_000 + WINDOW_MS + 1), Action::Stop);
}
#[test]
fn a_renderer_that_dies_later_starts_over_at_the_first_recovery() {
let mut g = Guard::new();
assert_eq!(g.on_failure(0), Action::Reload { attempt: 1 });
assert_eq!(g.on_failure(10), Action::Reload { attempt: 2 });
assert_eq!(g.on_failure(WINDOW_MS + 100), Action::Reload { attempt: 1 });
}
#[test]
fn a_stopped_loop_stays_stopped_until_the_window_has_passed() {
let mut g = Guard::new();
let mut reloads = 0;
// a renderer that dies every second for ten minutes: never more than two reloads in any two-minute window
let mut loaded = Vec::new();
for second in 0..600u64 {
if let Action::Reload { .. } = g.on_failure(second * 1_000) {
reloads += 1;
loaded.push(second);
}
}
assert!(reloads <= 2 * (600 / (WINDOW_MS / 1_000) as usize + 1));
for w in loaded.windows(3) {
assert!(w[2] - w[0] >= WINDOW_MS / 1_000, "three reloads inside one window: {w:?}");
}
}
#[test]
fn the_first_recovery_keeps_the_screen_and_the_second_goes_home() {
let o = "http://127.0.0.1:47831";
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/?recovery=1#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
assert_eq!(recovery_url(o, 2, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=2");
assert_eq!(recovery_url(o, 1, None), "http://127.0.0.1:47831/?recovery=1");
assert_eq!(recovery_url("http://127.0.0.1:47831/", 1, None), "http://127.0.0.1:47831/?recovery=1");
}
#[test]
fn an_address_that_is_not_ours_or_not_plain_is_not_carried_over() {
let o = "http://127.0.0.1:47831";
for failed in [
"https://example.com/#/chat/x",
"http://127.0.0.1:47832/#/chat/x",
"http://127.0.0.1:47831/#/chat/x y",
"http://127.0.0.1:47831/#/chat/\"onload=",
"http://127.0.0.1:47831/#/chat/<script>",
"http://127.0.0.1:47831/#nothing",
"http://127.0.0.1:47831/#/chat/\u{e9}",
"chrome-error://chromewebdata/",
] {
assert_eq!(recovery_url(o, 1, Some(failed)), "http://127.0.0.1:47831/?recovery=1", "{failed}");
}
let long = format!("http://127.0.0.1:47831/#/{}", "a".repeat(3000));
assert_eq!(recovery_url(o, 1, Some(&long)), "http://127.0.0.1:47831/?recovery=1");
// a look-alike host is not the origin
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:478310/#/chat/x")), "http://127.0.0.1:47831/?recovery=1");
}
#[test]
fn a_failure_report_becomes_one_decision() {
let o = "http://127.0.0.1:47831";
let mut g = Guard::new();
let chat = Some("http://127.0.0.1:47831/#/chat/s_1");
// a GPU process or a frame going down never costs the person their page, and does not use up the allowance
assert_eq!(plan(&mut g, 0, o, Failure::Gpu, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 1, o, Failure::FrameRendererExited, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 2, o, Failure::Unresponsive, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 3, o, Failure::BrowserExited, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 1_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 1, url: "http://127.0.0.1:47831/?recovery=1#/chat/s_1".into() });
assert_eq!(plan(&mut g, 4_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 2, url: "http://127.0.0.1:47831/?recovery=2".into() });
assert_eq!(plan(&mut g, 7_000, o, Failure::RendererExited, chat), Plan::Stop);
}
}
+44 -5
View File
@@ -1,15 +1,54 @@
# Strata app source
# Strata app: developer guide
Start with [the complete developer handoff](../handoff.md) and [the runbook](../docs/RUNBOOK.md).
This folder is the app's Node host (`server/`) and its web interface (`src/`, React and Vite). The desktop shell that wraps
them is [`../desktop`](../desktop) (Tauri). The engine and Strata Control are Python and C++ and live elsewhere in the
repository; see [docs/SOURCE_INSTALL.md](../../docs/SOURCE_INSTALL.md) and [packaging/README.md](../../packaging/README.md).
## Start from a fresh clone
You need **Node 24.12 or newer** (`engines` in `package.json`) and npm. Nothing else: no model, no engine, no GPU, no
sibling design folder.
```sh
cd apps/host
npm ci
npm run preview:ui
npm run build # makes dist/, which the preview and `npm run serve` need (dist/ is not checked in)
npm run preview:ui # simulated hardware and models; open http://localhost:5272/#/models
```
The built UI is included. To rebuild after source edits, run `npm run build`. `preview:ui` uses isolated simulated data; `npm run dev` connects the normal host.
`preview:ui` starts an isolated host and a fixture server on temporary data and prints "UI PREVIEW ONLY". Nothing it shows
is real: no engine is started, no model is loaded, no files outside its temporary folder are touched. Ctrl+C stops it.
The previous project README is retained in [docs/README-historical.md](docs/README-historical.md). It contains older implementation status and is historical reference.
| Command | What it does |
| --- | --- |
| `npm run build` | Builds the interface into `dist/`. |
| `npm run typecheck` | `tsc --noEmit`. |
| `npm test` | The host and interface tests (`tests/*.test.mjs`, `node --test`). They write to scratch copies of the per-user folders, never to your own profile. A test that hangs fails after 10 minutes (`STRATA_TEST_TIMEOUT_MS`). |
| `npm run dev` | The source host (port 8791) and the Vite dev server (port 5199) together; Vite proxies `/api` to the host. |
| `npm run serve` | The host with the built `dist/` (run `npm run build` first). |
| `npm run check` | Typecheck plus the interface audits (`audit`, `contrast`, `measure`, `luminance`). |
## Preview, source host and packaged Control
- **Simulated preview** (`npm run preview:ui`): fixtures only. Use it to look at screens.
- **Source host** (`npm run dev`, `npm run serve`): the real host code from this checkout. Without `STRATA_REAL_ENGINE=1` it
runs a clearly labelled example engine; with it, the host looks for a Strata installation as a source checkout does.
Loading a real model needs an engine and model files; none of the commands above do that.
- **Packaged app**: the installer ships the host next to Strata Control (Python plus `strata_control`), which does installs,
updates and engine starts. A source checkout has no Control, so those paths keep their older behaviour; how Control is
packaged is in [packaging/README.md](../../packaging/README.md).
The version numbers in `package.json` are the app's. They are checked against `docs/data/release.json` by `python tools/release_manifest.py --check`.
## Styles, icons and fonts
The checked-in files are the source. There is no generator and no sibling design folder.
- Kit styles: `src/styles/kit/tokens.css` and `components.css`; app styles sit on top in `src/styles/*.css`.
- Icons: `public/icons/sprite.svg`. Add an icon as a `<symbol id="i-NAME">`; `npm run audit` fails on an icon that is used but missing.
- Fonts: `public/fonts`.
- The standalone server page (`serve/web/`) serves its own copy of `components.css`. The two copies are byte-identical and
`tests/kit-copy.test.mjs` fails when they differ: change both together.
## Languages
+4 -70
View File
@@ -1,71 +1,5 @@
# Strata UI fixes — handoff for Claude Code
# Moved
This package comes from the Strata UI audit of 7 October 2026: 177 findings across 210 screen states. Here they are split into 19 tasks. Each task file lists the findings it covers, the files to change, and how to check the result.
## What's inside
- `tasks/` has one file per task, ready to give to a subagent.
- `findings.json` has all 177 findings with severity, state, issue, suggested fix and assigned task.
- `designs/` holds the reference designs for T09 (Model) and T10 (Engine v3). They are design-component source files: read them for structure, copy, colours and behaviour. They won't run on their own.
- `.claude/agents/strata-fixer.md` is a subagent definition (Sonnet, focused on one task).
- `PROMPTS.md` has the prompts to paste into Claude Code.
- `REVIEW.md` is the checklist for the review after each phase.
- `PROGRESS.md` is the log that each task appends to.
## Setup
1. Copy this folder into the repo root as `audit-fixes/` (so paths read `audit-fixes/tasks/T01-…`).
2. Copy `audit-fixes/.claude/agents/strata-fixer.md` to `.claude/agents/strata-fixer.md` in the repo.
3. Make sure `docs/evidence/audit-2026-10-07/` (the screenshots) is in the repo, because the task files point to it.
4. Run `npm install`, then `npm run check` and `node --test tests/` once. Note anything that already fails, so it isn't blamed on a task.
## Order
Phase 1 fixes shared components, so later tasks get smaller. Tasks in the same phase with no dependency between them can run in parallel on separate branches.
### Phase 1 — root causes (shared components)
| Task | Title | Findings | High / Med / Low | Depends on |
|---|---|---|---|---|
| T01 | One model, one name | 11 | 6 / 2 / 3 | — |
| T02 | Overlay layering and dialogs | 5 | 2 / 3 / 0 | — |
| T03 | Primary, disabled and on states | 11 | 1 / 7 / 3 | — |
| T04 | One error component, no fixture text | 10 | 0 / 9 / 1 | — |
| T05 | Show numbers when they matter | 6 | 2 / 2 / 2 | T01 |
| T06 | Fix the response hover layout | 1 | 1 / 0 / 0 | — |
| T07 | Hide what is not built | 2 | 0 / 1 / 1 | — |
| T08 | Engine and Model in the main nav | 1 | 1 / 0 / 0 | T01 |
### Phase 2 — rebuild Model and Engine
| Task | Title | Findings | High / Med / Low | Depends on |
|---|---|---|---|---|
| T09 | Build the single-model Model page | 17 | 0 / 9 / 8 | T01, T03, T04 |
| T10 | Build Engine v3 | 17 | 2 / 9 / 6 | T01, T02, T03 |
### Phase 3 — remaining findings per screen
| Task | Title | Findings | High / Med / Low | Depends on |
|---|---|---|---|---|
| T11 | Chat: remaining findings | 38 | 0 / 20 / 18 | T01, T05, T06, T07 |
| T12 | Time-skip: remaining findings | 6 | 0 / 3 / 3 | — |
| T13 | Serving: remaining findings | 19 | 0 / 6 / 13 | T04 |
| T14 | Monitor: remaining findings | 2 | 0 / 1 / 1 | T05 |
| T15 | Skills and Tasks: remaining findings | 9 | 0 / 2 / 7 | T02, T03, T04 |
| T16 | Settings and Setup: remaining findings | 7 | 0 / 2 / 5 | T02, T09 |
| T17 | Navigation and overlays: remaining findings | 4 | 0 / 2 / 2 | T02, T08 |
| T18 | Preview panel: remaining findings | 7 | 0 / 3 / 4 | — |
| T19 | Dark mode: remaining findings | 4 | 0 / 1 / 3 | T02, T03 |
## Running it cheaply
- Do high and medium first. Every task file puts low findings under "Optional polish". Skip them in the first pass. That cuts about a third of the work.
- Use a fresh session per task. Each subagent starts clean with only its task file, so context stays small.
- Check with tests first, and use screenshots only for the states listed in the task.
- After each phase, run one review (see REVIEW.md) instead of reviewing after every task.
Rough cost at API list prices (Sonnet 5.5, medium effort, for the subagents; one review per phase):
- Phase 1 (8 tasks): $10–20
- Phase 2 (2 large tasks): $8–16
- Phase 3 (9 tasks): $10–20, or about half that if you skip the low findings
- Reviews: $8–15
## Ground rules for every task
- Stay within the task's scope. If a fix needs a file that another task owns, make the smallest change and note it in PROGRESS.md.
- Don't change the design kit tokens in `src/styles/kit/` unless the task says so. Run `npm run sync:kit` if you do.
- Keep both themes working. Check light and dark.
- Existing tests must stay green. Add tests for new logic (formatters, clamping, fit rules).
- A finding can be closed as "already fixed by Txx" or "not reproducible", with a one-line reason.
The UI audit-fix campaign (7 October 2026: 19 tasks, the finding register, the review and the scripts that applied them) is finished.
It is kept whole, unchanged, in [history/apps/host/audit-fixes](../../../history/apps/host/audit-fixes/README.md).
Open items from it are in [../docs/BACKLOG.md](../docs/BACKLOG.md).
+54
View File
@@ -0,0 +1,54 @@
# UI backlog from the October 2026 campaign
When the dated UI documents moved to [history/apps/host](../../../history/apps/host/docs), the requirements in them that were not done, or not shown to be done, were copied here.
Each row names its source in `history/`. "Open" means a search of `src/` and `server/` on 7 November 2026 found nothing for it. "Check" means related
code exists but nothing shows the requirement was met or tested. This is a list to triage, not a promise.
## Open
| Item | Source |
| --- | --- |
| Measure dragging, scrolling and streaming together on representative hardware (`scripts/motionbudget.mjs` reports NOT MEASURED). | docs/engine-models-onboarding-2026-10-05.md, Remaining 6 |
| A browser performance benchmark with a 3,000-row model catalog. | docs/engine-models-onboarding-2026-10-05.md, Verification |
| Decide the model card metrics (B08: reading speed, answer speed and task quality) and the profile names (B13: keep Agentic / Balanced / Performance, or rename) with real participants. The shipped choices were provisional. | docs/persona-review-2026-10-05/decision-report.md, Unresolved choices |
| Platform checks never run: a normal-browser export download, launch from the installed manifest, favicon fallbacks, OS appearance changes, forced-colors mode, a deliberate image failure, native icon packaging. | docs/design-improvements-2026-10-05.md, Remaining boundaries 2 |
| A safety cue when the engine settings leave their defaults. | docs/plans/03-engine-tuning.md |
| "Copy as cURL" in the Serving request drawer. | docs/plans/05-serving.md |
| Settings: grouping and per-section resets. | docs/plans/08-settings.md |
| First run: a confirmation about local data. | docs/plans/09-first-run.md |
| Command palette: a Recents group. | docs/plans/11-command-palette.md |
| Time expectations on long operations. | docs/plans/14-states.md |
| An accessibility "fatigue budget". | docs/plans/16-accessibility.md |
## Check
| Item | Source |
| --- | --- |
| A screen-reader pass, forced colors, contrast in every interactive state, the reading-size and focus matrix. 200% browser zoom was checked for Engine, Models and setup only. | engine-models-onboarding (Remaining 5); design-improvements (UI-18); persona implementation-verification |
| Model fit from measured free headroom and the native engine's real footprint (the recommendation uses memory class). | engine-models-onboarding (Remaining 4); design-improvements (MODEL-03) |
| Benchmark evidence: engine build and driver identity, and rules for when a measurement stops being valid. | engine-models-onboarding, Remaining 3 |
| Curated agent and tool candidates, the Balanced ordering and comparable throughput data. | engine-models-onboarding, Remaining 2 |
| Download resume and browser fault injection on the native path. | engine-models-onboarding, Verification and Recovery |
| Real subagent orchestration (the demo engine had none). | annotations-followup-2026-10-09.md |
| PDF input to the model (pictures work). | annotations-followup-2026-10-09.md |
| Scheduled tasks that use file, shell, web, email and calendar tools. | user-feedback-2026-10-06.md |
| LAN sharing as an explicit opt-in, with a test. | user-feedback-2026-10-06.md; dsh-recon.md |
| A Windows and Linux save picker for the Monitor image export. | ui-followup-2026-10-06.md |
| A live Cloudflare tunnel run (only a connector fixture was used). | ui-followup-2026-10-06.md |
| Live microphone dictation (tested with simulated events only). | annotations-19-2026-10-09.md |
| One coordinated arrow-key navigation system for menus (optional). | design-improvements, UI-11-12 |
| Session-log export delivery in a normal browser. | design-improvements, CHAT-08 |
| Windows and Linux runtime lifecycle, native tool and image grants, privacy and logging, native sensors. | design-improvements, Remaining boundaries 1 |
| A human credential workflow test for Serving; native cancellation; assistive-technology announcements for invalid fields. | design-improvements, SERVE-05 to 07, UI-01-04 |
| Plans: first-run scan substance, a labelled theme toggle, gauge animation, skeletons, operation-failed empties, a motion duration budget. | docs/plans/09, 10, 12, 14, 15 |
| Shared items with no recorded closure: the loaded model shown in four places, context described in different words, destructive actions styled like navigation, value fills that jump. | docs/plans/INDEX-CHECKLIST.md, "Present on multiple screens" |
## Not captured individually
- The 16 screen plans list about 190 numbered items, of which the index says about a quarter were executed. The rest stay in
[history/apps/host/docs/plans](../../../history/apps/host/docs/plans/README.md); read the plan for a screen before changing that screen.
- The audit-fix campaign left 80 of its 177 findings as "optional, not scheduled" (all low priority); 91 were implemented and 6 closed by removal.
The finding ids and the task each belongs to are in [history/apps/host/audit-fixes/FINDING-STATUS.md](../../../history/apps/host/audit-fixes/FINDING-STATUS.md).
By task: T11 chat 18, T13 serving 13, T09 model page 8, T15 skills and tasks 7, T10 engine page 6, T16 settings and setup 5, T18 preview panel 4,
T01, T03, T12 and T19 three each, T05 and T17 two each, T04, T07 and T14 one each.
- Declined by the owner, so not backlog: Tuning's nine flat fields in two groups, the Settings section order, renaming "More" to Settings, and repeating About's config paths.
+47
View File
@@ -0,0 +1,47 @@
# App developer docs
Current:
- [interaction-guidelines.md](interaction-guidelines.md): motion, materials, navigation and contrast rules. `scripts/audit-ui.mjs` and the style sheets cite its sections.
- [design-system-notes.md](design-system-notes.md): how the app uses the kit styles in `src/styles/kit/`.
- [BACKLOG.md](BACKLOG.md): UI requirements from the October 2026 campaign that are still open or unchecked.
- [../README.md](../README.md): start here (build, preview, tests).
## How the host is organised
This replaces the 14 October 2026 native-engine handover (kept in [history](../../../history/apps/host/docs/native-engine-handover.md); it was written against engine 0.1.38).
| Module | Responsibility |
| --- | --- |
| `server/native-control.mjs` | The bridge to Strata Control and the installed engine: catalog, status, install jobs, saved job state, per-instance config copies, process start and stop, benchmark, calibration and updates. |
| `server/native-settings.mjs` | Allowed per-model settings, their bounds, MCP configuration validation and credential masking. |
| `server/native-gateway.mjs` | Forwards Chat Completions, Anthropic and Responses requests to the selected instance. |
| `server/chat-images.mjs` | Validation of attached pictures. |
| `src/lib/native.ts` | Shared native state and polling. |
| `src/components/NativeModels.tsx` | Model setup, device assignment, version management and load. |
| `src/components/NativeSettings.tsx`, `NativeMcpEditor.tsx` | Per-model parameters, JSON and tool-server configuration. |
| `src/components/NativeMonitor.tsx` | Rates per instance, per-instance detail and host measurements. |
Contracts that still hold:
- Each loaded instance runs `serve/server.py` on its own private loopback port with a random API key, from a config copy in the app's data folder.
The one public URL shown in Serving forwards to it; the public model id is an alias in that config, and unknown or ambiguous ids fail before streaming.
- A source checkout finds a Strata installation through `STRATA_NATIVE_REPO` (it must contain `setup.py`, `tools/strata_mcp.py` and `serve/server.py`) and a Python through
`STRATA_PYTHON` or `STRATA_NATIVE_PYTHON` (`server/python-resolver.mjs`). The installed app uses the Control that ships beside it instead.
- Saving settings validates first, keeps unrelated keys and non-Strata environment values, writes a `.bak` and replaces the config atomically. New settings apply on the next load.
## Moved to history
Pointer: [history/README.md](../../../history/README.md). The files, unchanged:
| Was | Now |
| --- | --- |
| `audit-fixes/` (next to this folder) | [history/apps/host/audit-fixes](../../../history/apps/host/audit-fixes/README.md) |
| `README-historical.md` | [history/apps/host/docs/README-historical.md](../../../history/apps/host/docs/README-historical.md) |
| `native-engine-handover.md` | [history/apps/host/docs/native-engine-handover.md](../../../history/apps/host/docs/native-engine-handover.md) |
| `annotations-19-2026-10-09.md`, `annotations-32-2026-10-09.md`, `annotations-followup-2026-10-09.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
| `chat-shell-2026-10-06.md`, `ui-followup-2026-10-06.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
| `design-improvements-2026-10-05.md`, `dsh-recon.md`, `engine-models-onboarding-2026-10-05.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
| `user-feedback-2026-10-05.md`, `user-feedback-2026-10-06.md` | [history/apps/host/docs/](../../../history/apps/host/docs) |
| `persona-review-2026-10-05/` | [history/apps/host/docs/persona-review-2026-10-05](../../../history/apps/host/docs/persona-review-2026-10-05/decision-report.md) |
| `plans/` | [history/apps/host/docs/plans](../../../history/apps/host/docs/plans/README.md) |
+2 -6
View File
@@ -7,17 +7,13 @@ tints composited over `--st-surface` first.
## 1. How the app consumes the kit
`src/styles/kit/{tokens,components}.css` are synced verbatim from `../ui` by
`npm run sync:kit` (the only rewrite is making the `@font-face` URLs absolute).
`src/styles/kit/{tokens,components}.css` are the checked-in kit styles and the source of truth
(there is no generator; the kit was first imported from a separate design workspace that is not in this repository).
`src/styles/app.css` sits on top and contains, in order: extensions to the kit's
classes, app shell layout, utilities, app-only components, and the first-run
takeover. There are no gradients in it and no literal colours — every value is
either a kit token or a layout constant.
```
npm run sync:kit # re-import tokens/components/fonts/icons from ../ui
```
Every component the kit defines is in use:
| Kit class | Where the app uses it |
+1 -1
View File
@@ -6,7 +6,7 @@ measure` / `npm run motion` (runtime), so a violation fails a command rather tha
a code review.
Companion documents: [`design-system-notes.md`](design-system-notes.md) for how
the app maps onto the kit, [`dsh-recon.md`](dsh-recon.md) for the product this
the app maps onto the kit, [`dsh-recon.md`](../../../history/apps/host/docs/dsh-recon.md) (archived) for the product this
one is modelled on.
---
@@ -0,0 +1,5 @@
# Moved
The 5 October 2026 persona review (eight personas, debates, ballot, decision report) is kept in
[history/apps/host/docs/persona-review-2026-10-05](../../../../history/apps/host/docs/persona-review-2026-10-05/decision-report.md).
Open requirements from it are in [../BACKLOG.md](../BACKLOG.md).
+3 -66
View File
@@ -1,67 +1,4 @@
# Strata · per-screen improvement plans
# Moved
One plan per page and per scenario, written against the two rulebooks the app is
built on — [interaction-guidelines.md](../interaction-guidelines.md) (Strata's own
rules) and Apple's Liquid Glass HIG — and against what the code actually does
today, not what it is meant to do.
Every plan follows the same shape: **what it is today** (grounded in the file and
in measured numbers), **where it falls short**, **the plan**, **motion**, and
**how the result is verified**. Claims about the present are checkable with the
scripts in `scripts/`; claims about the future are written so they can be checked
the same way once built.
## Screens
| # | Page | Route | Plan |
|---|---|---|---|
| 01 | Chat | `#/chat` | [01-chat.md](01-chat.md) |
| 02 | Models | `#/models` | [02-models.md](02-models.md) |
| 03 | Engine tuning | `#/tuning` | [03-engine-tuning.md](03-engine-tuning.md) |
| 04 | Monitor | `#/monitor` | [04-monitor.md](04-monitor.md) |
| 05 | Serving | `#/serving` | [05-serving.md](05-serving.md) |
| 06 | Workspace | `#/workspace` | [06-workspace.md](06-workspace.md) |
| 07 | Skills | `#/skills` | [07-skills.md](07-skills.md) |
| 08 | Settings | `#/settings` | [08-settings.md](08-settings.md) |
| 09 | First run | modal | [09-first-run.md](09-first-run.md) |
## Scenarios
| # | Scenario | Plan |
|---|---|---|
| 10 | Sidebar and navigation | [10-sidebar.md](10-sidebar.md) |
| 11 | Command palette | [11-command-palette.md](11-command-palette.md) |
| 12 | Context rail | [12-context-rail.md](12-context-rail.md) |
| 13 | Overlays — drawers, menus, selects, popovers | [13-overlays.md](13-overlays.md) |
| 14 | Empty, loading and error states | [14-states.md](14-states.md) |
| 15 | Motion and fluidity | [15-motion.md](15-motion.md) |
| 16 | Accessibility and long sessions | [16-accessibility.md](16-accessibility.md) |
## The shared vocabulary these plans assume
Fixed by the design system, so plans do not re-litigate them:
- **Light is the reference theme.** Every change is designed light-first and
verified in dark, never the reverse.
- **Capsules are for controls; the 8/14/20/24 scale is for surfaces.** Audit-enforced.
- **Glass is only for the floating control layer.** Glass never samples glass;
`npm run luminance` fails the build if a material ends up nested in another.
- **One motion scale** — 100/160/240/320/460 ms, one standard curve, springs only
for direct manipulation. No layout property is transitioned directly; grid
tracks and opacity carry the movement.
- **Contrast is measured on rendered pairings**, compositing alpha, in every
interactive state.
- **Reading measure is capped** at 68 characters in the conversation, verified at
10, 16 and 22 px.
## Verification harness these plans refer to
```
npm run check typecheck + audit + contrast + measure + luminance
npm run audit off-scale values, materials, elevation, capsules, icons, native selects
npm run contrast rendered pairings, composited, per state, both themes
npm run measure characters per line at every content size
npm run luminance dark-mode brightness budget + material layering
npm run motion interruptibility, frame-sampled contact sheets
npm run flow <name> scripted screen flows; `tour` and `tour-dark` cover every route
```
The 18 screen plans and their checklist (October 2026) are kept in [history/apps/host/docs/plans](../../../../history/apps/host/docs/plans/README.md).
Open requirements from them are in [../BACKLOG.md](../BACKLOG.md).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.10",
"version": "0.2.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.10",
"version": "0.2.13",
"dependencies": {
"html-to-image": "^1.11.13",
"katex": "^0.19.0",
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "strata",
"private": true,
"version": "0.2.10",
"version": "0.2.13",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
@@ -12,8 +12,7 @@
"serve": "node server/index.mjs --dist dist",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"sync:kit": "node scripts/sync-design-kit.mjs",
"shot": "node scripts/shoot.mjs",
"shot": "node scripts/shoot.mjs",
"flow": "node scripts/flow.mjs",
"audit": "node scripts/audit-ui.mjs",
"contrast": "node scripts/contrast.mjs",
+3 -20
View File
@@ -17,7 +17,6 @@ import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const STYLE_DIR = join(ROOT, 'src/styles');
const KIT_SRC = join(ROOT, '..', 'ui');
const KIT_COPY = join(ROOT, 'src/styles/kit');
/** The app's own stylesheets, in cascade order: the last one wins. */
@@ -264,24 +263,8 @@ const allAppCss = APP_SHEETS.map((sheet) => sheet.css).join('\n');
/* ------------------------------------------------- kit sync + motion scale -- */
for (const file of ['tokens.css', 'components.css']) {
const a = join(KIT_SRC, file);
const b = join(KIT_COPY, file);
// Some checkouts of this branch do not carry the design-kit source (apps/ui),
// only the synced copy. There is nothing to compare against then, so the check
// reports that instead of failing every run with a condition no one can fix.
if (!existsSync(a)) {
notes.push(`kit copy: ${file} has no source in this checkout — drift check skipped`);
continue;
}
if (!existsSync(b)) {
fail('kit', 0, `${file} missing from src/styles/kit — run npm run sync:kit`);
continue;
}
// The sync rewrites @font-face URLs to absolute; everything else must match.
const norm = (t) => t.replace(/url\((["']?)\/fonts\//g, 'url($1fonts/').trim();
if (norm(readFileSync(a, 'utf8')) !== norm(readFileSync(b, 'utf8'))) {
fail('kit', 0, `${file} has drifted from ../ui — run npm run sync:kit`);
}
// The checked-in files in src/styles/kit are the source of the kit styles; there is no generator.
if (!existsSync(join(KIT_COPY, file))) fail('kit', 0, `${file} is missing from src/styles/kit (restore it from git)`);
}
// Which selectors have been given the capsule radius. Checked across the whole
@@ -328,7 +311,7 @@ for (const file of [...readdirSync(join(ROOT, 'src'), { recursive: true })].filt
for (const m of readFileSync(full, 'utf8').matchAll(/<(?:Icon|IconBtn)\s[^>]*?\bname="([a-z-]+)"/g)) referenced.add(m[1]);
}
for (const name of referenced) {
if (!symbols.has(name)) fail('src', 0, `icon "${name}" is referenced but not in the sprite — run npm run sync:kit`);
if (!symbols.has(name)) fail('src', 0, `icon "${name}" is referenced but not in the sprite: add a <symbol id="i-NAME"> to public/icons/sprite.svg`);
}
/* ---------------------------------------------------------- undefined classes -- */
+1 -1
View File
@@ -24,7 +24,7 @@ async function metrics(tag) {
await cdp.send('HeapProfiler.collectGarbage').catch(() => {});
const m = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(x => [x.name, x.value]));
const dom = await cdp.send('Memory.getDOMCounters').catch(() => ({}));
const sess = await page.evaluate(() => { try { return (localStorage.getItem('strata.sessions.v1') ?? '').length; } catch { return -1; } });
const sess = await page.evaluate(async () => { try { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return l.chats.reduce((n, c) => n + (c.bytes ?? 0), 0); } catch { return -1; } });
console.log(`${tag.padEnd(22)} heap=${(m.JSHeapUsedSize / 1048576).toFixed(0)}MB total=${(m.JSHeapTotalSize / 1048576).toFixed(0)}MB nodes=${dom.nodes ?? m.Nodes} listeners=${dom.jsEventListeners ?? '-'} docs=${m.Documents} ls=${(sess / 1e6).toFixed(2)}M`);
return m.JSHeapUsedSize;
}
+8 -5
View File
@@ -1235,14 +1235,17 @@ if (flow === 'unread') {
await page.waitForTimeout(600);
// make the older conversation look updated since it was last read
await page.evaluate(() => {
const raw = JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]');
await page.evaluate(async () => {
// The chats are files on the host (owner decision 11-04): read them and save the older one again, as the page would.
const h = { 'x-strata-client': 'ui' };
const list = await (await fetch('/api/chats', { headers: h })).json();
const raw = await Promise.all(list.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session));
const older = raw.filter((s) => s.messages.length > 0).at(-1);
if (older) {
older.updatedAt = new Date(Date.now() + 120000).toISOString();
older.lastReadAt = new Date(Date.now() - 120000).toISOString();
await fetch('/api/chats/' + encodeURIComponent(older.id), { method: 'PUT', headers: h, body: JSON.stringify(older) });
}
localStorage.setItem('strata.sessions.v1', JSON.stringify(raw));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1800);
@@ -1352,7 +1355,7 @@ if (flow === 'branch') {
// remember the original so "untouched" means unchanged by the branch, not a
// hardcoded length
const before = await page.evaluate(() =>
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
const originalBefore = before.find((s) => !s.title.includes('branch'));
@@ -1370,7 +1373,7 @@ if (flow === 'branch') {
console.log('from user :', JSON.stringify(await view()), '<- branch must show only the user turn, and the url must follow');
const sessions = await page.evaluate(() =>
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
console.log('stored :', JSON.stringify(sessions));
const originalAfter = sessions.find((s) => !s.title.includes('branch'));
@@ -0,0 +1,63 @@
"""Writes src/locales/sav-<lang>.json: the notice for chats the host could not write (owner wording 11-04, #5192).
Run from apps/host: python scripts/make-chatsave-locales.py
Every entry names all nine languages; the keys are sav01.. in the order of S. sav-en.json is the English source. Placeholders ({0}) must be kept in every
language. A separate file keeps this work from colliding with the other catalogs (the same reason upd-, df2-, pick- and the others exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
add("Some chats could not be saved",
"部分聊天无法保存",
"一部のチャットを保存できませんでした",
"Einige Chats konnten nicht gespeichert werden",
"No se pudieron guardar algunos chats",
"Не удалось сохранить некоторые чаты",
"Alguns chats não puderam ser guardados",
"일부 채팅을 저장하지 못했습니다",
"Certaines conversations n'ont pas pu être enregistrées")
add("Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.",
"Strata 的聊天存储空间已满,因此 {0} 个聊天的最新更改目前只保存在此窗口中。请删除旧聊天以腾出空间,之后它们会自动保存。",
"Strata のチャット保存領域がいっぱいのため、{0} 件のチャットの最新の変更はこのウィンドウにしかありません。古いチャットを削除して空きを作ると、自動的に保存されます。",
"Der Chat-Speicher von Strata ist voll, daher gibt es die neuesten Änderungen an {0} Chat(s) nur in diesem Fenster. Löschen Sie alte Chats, um Platz zu schaffen; dann werden sie automatisch gespeichert.",
"El almacenamiento de chats de Strata está lleno, así que los cambios más recientes de {0} chat(s) solo están en esta ventana. Elimina chats antiguos para liberar espacio; después se guardarán solos.",
"Хранилище чатов Strata заполнено, поэтому последние изменения в чатах ({0}) есть только в этом окне. Удалите старые чаты, чтобы освободить место; после этого они сохранятся сами.",
"O armazenamento de chats do Strata está cheio, por isso as alterações mais recentes de {0} chat(s) só existem nesta janela. Apague chats antigos para libertar espaço; depois eles guardam-se sozinhos.",
"Strata의 채팅 저장 공간이 가득 차서 {0}개 채팅의 최신 변경 내용이 이 창에만 있습니다. 오래된 채팅을 삭제해 공간을 확보하세요. 그러면 자동으로 저장됩니다.",
"L'espace de stockage des conversations de Strata est plein : les dernières modifications de {0} conversation(s) ne sont que dans cette fenêtre. Supprimez d'anciennes conversations pour faire de la place ; elles s'enregistreront alors d'elles-mêmes.")
add("Copy unsaved chats",
"复制未保存的聊天",
"未保存のチャットをコピー",
"Nicht gespeicherte Chats kopieren",
"Copiar chats sin guardar",
"Скопировать несохранённые чаты",
"Copiar chats não guardados",
"저장되지 않은 채팅 복사",
"Copier les conversations non enregistrées")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"sav{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"sav-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
print("written", len(S), "strings in", len(LANGS), "languages")
+45
View File
@@ -175,6 +175,51 @@ add("Drafting: MTP (DFlash2 didn't start)",
"Previsão: MTP (o DFlash2 não iniciou)",
"초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
"Anticipation : MTP (DFlash2 n'a pas démarré)")
add("Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once.",
"自动模式使用全部显卡的拆分引擎和 MTP 草稿层:约 {0} GB,只下载一次。",
"自動では、全カードのスプリットエンジンと MTP ドラフト層を使います。約 {0} GB を一度だけダウンロードします。",
"Automatisch nutzt die Alle-Karten-Split-Engine mit der MTP-Entwurfsschicht: etwa {0} GB, einmalig herunterzuladen.",
"Automático usa el motor de reparto entre todas las tarjetas con la capa de borrador MTP: unos {0} GB, una sola vez.",
"Авто использует движок с разделением по всем картам и черновой слой MTP: около {0} ГБ, скачивается один раз.",
"O automático usa o motor de divisão entre todas as placas com a camada de rascunho MTP: cerca de {0} GB, uma vez.",
"자동은 모든 카드를 쓰는 분할 엔진과 MTP 초안 층을 사용합니다(약 {0}GB, 한 번만 내려받음).",
"Auto utilise le moteur réparti sur toutes les cartes avec la couche de brouillon MTP : environ {0} Go à télécharger, une seule fois.")
add("The all-cards split engine could not start, so the normal layer split runs.",
"全显卡拆分引擎未能启动,因此改用常规的分层拆分。",
"全カードのスプリットエンジンを起動できなかったため、通常のレイヤー分割で動作しています。",
"Die Alle-Karten-Split-Engine konnte nicht starten, deshalb läuft die normale Schichtaufteilung.",
"El motor de reparto entre todas las tarjetas no pudo arrancar, así que se usa el reparto normal por capas.",
"Движок с разделением по всем картам не запустился, поэтому работает обычное послойное разделение.",
"O motor de divisão entre todas as placas não conseguiu iniciar, então a divisão normal por camadas está em uso.",
"모든 카드를 쓰는 분할 엔진을 시작하지 못해 일반 레이어 분할로 실행합니다.",
"Le moteur réparti sur toutes les cartes n'a pas pu démarrer, la répartition normale par couches est donc utilisée.")
add("Drafting: off (the cards were short of memory)",
"预测草稿:关闭(显卡内存不足)",
"先読み:オフ(グラフィックスカードのメモリが不足していました)",
"Vorausraten: aus (den Karten fehlte Speicher)",
"Predicción: desactivada (a las tarjetas les faltaba memoria)",
"Предсказание: выключено (на картах не хватило памяти)",
"Previsão: desligada (faltou memória nas placas)",
"초안 예측: 꺼짐(카드 메모리가 부족했습니다)",
"Anticipation : désactivée (les cartes manquaient de mémoire)")
add("On your Mac, DFlash2 was faster than MTP on this model in our tests.",
"在您的 Mac 上,我们的测试中 DFlash2 在此模型上比 MTP 更快。",
"お使いの Mac では、私たちのテストでこのモデルにおいて DFlash2 は MTP より速くなりました。",
"Auf Ihrem Mac war DFlash2 bei diesem Modell in unseren Tests schneller als MTP.",
"En su Mac, en nuestras pruebas DFlash2 fue más rápido que MTP con este modelo.",
"На вашем Mac в наших тестах DFlash2 с этой моделью был быстрее MTP.",
"No seu Mac, nos nossos testes o DFlash2 foi mais rápido que o MTP com este modelo.",
"고객님의 Mac에서 저희 테스트 결과 이 모델에서는 DFlash2가 MTP보다 빨랐습니다.",
"Sur votre Mac, DFlash2 a été plus rapide que MTP avec ce modèle lors de nos tests.")
add("DFlash2 is off by default on this graphics card for now; choose it in Drafting to use it.",
"目前在这张显卡上 DFlash2 默认关闭;如需使用,请在“预测草稿”中选择它。",
"現在、このグラフィックスカードでは DFlash2 は既定でオフです。使うには「先読み」で選んでください。",
"DFlash2 ist auf dieser Grafikkarte vorerst standardmäßig aus; wählen Sie es unter „Vorausraten“ aus, um es zu nutzen.",
"DFlash2 está desactivado de forma predeterminada en esta tarjeta gráfica por ahora; elíjalo en Predicción para usarlo.",
"На этой видеокарте DFlash2 пока выключен по умолчанию; чтобы использовать его, выберите его в разделе «Предсказание».",
"O DFlash2 está desligado por predefinição nesta placa de vídeo por agora; escolha-o em Previsão para o usar.",
"현재 이 그래픽 카드에서는 DFlash2가 기본적으로 꺼져 있습니다. 사용하려면 ‘초안 예측’에서 선택하세요.",
"DFlash2 est désactivé par défaut sur cette carte graphique pour le moment ; choisissez-le dans Anticipation pour l'utiliser.")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
-87
View File
@@ -1,87 +0,0 @@
/**
* Sync the Strata UI kit (../ui) into the app's public/ folder.
*
* node scripts/sync-design-kit.mjs
*
* Copies fonts, and rebuilds a single clean icon sprite at public/icons/sprite.svg.
* The kit's own SVG files carry C2PA manifest metadata (~12 KB each, base64);
* we strip <metadata> so the shipped sprite stays small.
*
* Also mirrors tokens.css / components.css into src/styles/kit/ so the app has a
* versioned copy that can be diffed against the kit.
*/
import { readFileSync, writeFileSync, mkdirSync, readdirSync, copyFileSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
const kit = join(root, '..', 'ui');
const pub = join(root, 'public');
const styles = join(root, 'src', 'styles', 'kit');
if (!existsSync(kit)) {
console.error(`Design kit not found at ${kit}`);
process.exit(1);
}
mkdirSync(join(pub, 'fonts'), { recursive: true });
mkdirSync(join(pub, 'icons'), { recursive: true });
mkdirSync(styles, { recursive: true });
// ---- fonts -----------------------------------------------------------------
let fonts = 0;
for (const f of readdirSync(join(kit, 'fonts'))) {
if (!f.endsWith('.woff2')) continue;
copyFileSync(join(kit, 'fonts', f), join(pub, 'fonts', f));
fonts++;
}
// ---- icon sprite -----------------------------------------------------------
const strip = (svg) =>
svg
.replace(/<metadata>[\s\S]*?<\/metadata>/g, '')
.replace(/<svg[^>]*>/, '')
.replace(/<\/svg>\s*$/, '')
.replace(/\s+xmlns(:\w+)?="[^"]*"/g, '')
.trim();
const iconDir = join(kit, 'icons');
const sources = new Map(readdirSync(iconDir)
.filter((f) => f.endsWith('.svg') && f !== 'sprite.svg')
.map((f) => [basename(f, '.svg'), join(iconDir, f)]));
const appIconDir = join(root, 'src', 'assets', 'icons');
if (existsSync(appIconDir)) {
for (const file of readdirSync(appIconDir).filter(f => f.endsWith('.svg'))) {
sources.set(basename(file, '.svg'), join(appIconDir, file));
}
}
const names = [...sources.keys()].sort();
const symbols = names.map((name) => {
const raw = readFileSync(sources.get(name), 'utf8');
const body = strip(raw);
const viewBox = /viewBox="([^"]+)"/.exec(raw)?.[1] ?? '0 0 24 24';
// The root <svg> carries fill="none"; without it every stroked path inherits
// the default black fill and the icons render as solid blobs.
const rootFill = /<svg[^>]*\sfill="([^"]+)"/.exec(raw)?.[1] ?? 'none';
return ` <symbol id="i-${name}" viewBox="${viewBox}" fill="${rootFill}">\n ${body}\n </symbol>`;
});
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">\n${symbols.join('\n')}\n</svg>\n`;
writeFileSync(join(pub, 'icons', 'sprite.svg'), sprite);
// ---- styles ----------------------------------------------------------------
// The kit references fonts relatively (fonts/x.woff2). In the app the fonts are
// served from public/fonts, so rewrite the URLs to be absolute — otherwise the
// browser resolves them against the CSS file and 404s in both dev and build.
for (const f of ['tokens.css', 'components.css']) {
const css = readFileSync(join(kit, f), 'utf8').replace(/url\((["']?)fonts\//g, 'url($1/fonts/');
writeFileSync(join(styles, f), css);
}
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
console.log(`synced ${fonts} fonts -> public/fonts/`);
console.log(`sprite: ${names.length} icons -> public/icons/sprite.svg (${kb(sprite.length)})`);
console.log(`styles: tokens.css, components.css -> src/styles/kit/`);
console.log(`icons: ${names.join(', ')}`);
+3 -1
View File
@@ -11,6 +11,8 @@ import {tmpdir} from 'node:os';
import {join} from 'node:path';
const scratch=mkdtempSync(join(tmpdir(),'strata-host-suite-profile-'));
const env={...process.env,STRATA_SETTINGS_FILE:join(scratch,'app-settings.json'),STRATA_COMPONENTS_DIR:join(scratch,'components'),LOCALAPPDATA:join(scratch,'Local'),APPDATA:join(scratch,'Roaming'),XDG_DATA_HOME:join(scratch,'xdg-data'),XDG_STATE_HOME:join(scratch,'xdg-state'),XDG_CONFIG_HOME:join(scratch,'xdg-config')};
const child=spawn(process.execPath,['--test',...files],{cwd:root,stdio:'inherit',env});
/* A test that never finishes fails after STRATA_TEST_TIMEOUT_MS (default 10 minutes) instead of holding the whole run for ever; extra arguments (a reporter, --test-name-pattern) go to node --test. */
const timeout=Number(process.env.STRATA_TEST_TIMEOUT_MS)||600000;
const child=spawn(process.execPath,['--test',`--test-timeout=${timeout}`,...process.argv.slice(2),...files],{cwd:root,stdio:'inherit',env});
child.on('error',error=>{console.error(error.message);process.exitCode=1;});
child.on('exit',(code,signal)=>{process.exitCode=code??(signal?1:0);});
+2 -1
View File
@@ -62,7 +62,8 @@ export async function open(scheme, width, height, sessions, { hash = '#/chat', p
try {
if (sessionStorage.getItem('ui-do-seeded')) return;
sessionStorage.setItem('ui-do-seeded', '1');
if (sessions) localStorage.setItem('strata.sessions.v1', JSON.stringify(sessions));
// The chats are files on the host: put the seed there (a synchronous request, so it is in place before the page starts).
for (const chat of sessions ?? []) { const request = new XMLHttpRequest(); request.open('PUT', '/api/chats/' + encodeURIComponent(chat.id), false); request.setRequestHeader('x-strata-client', 'ui'); request.send(JSON.stringify({ messages: [], ...chat })); }
if (Object.keys(prefs).length) localStorage.setItem('strata.prefs.v1', JSON.stringify(prefs));
} catch { /* storage refused */ }
}, { sessions, prefs });
+56 -6
View File
@@ -26,7 +26,7 @@ import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudg
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
import { ChildProjector } from './children.mjs';
import { nearestBudget } from './llm-shape.mjs';
import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
import { DeliverableTracker, ActivityGroups, FolderClaims } from './deliverables.mjs';
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
import { turnEngineSpeed } from './engine-speed.mjs';
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
@@ -127,6 +127,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const home = join(root, 'home');
const sessionsFile = join(root, 'sessions.json');
mkdirSync(root, { recursive: true });
/** Which chat's file tools wrote which file, for chats that share a folder (#5008). */
const folderClaims = new FolderClaims();
/**
* Where the turn's file tracker must never look (deliverables.mjs): the app, the engine and its components, Strata's own data, AppData
@@ -367,24 +369,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
let hideDeltas = !!researchTopic;
signal?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
// What the assistant itself wrote in this run (not our own step lines): a plan turn reads its plan from it, a step reads its summary.
const written = { all: '', step: '' };
const written = { all: '', step: '', reasoning: '' };
// The frame that closed the turn, kept with the turn's text for a page that comes back after it ended (attach).
let terminal = null;
// The turn guard (turn-guard.mjs): counts failing searches, searches, plan rewrites and a nearly full context. Set up once the route is known.
let guard = null;
const raw = frame => { try { send(frame); } catch { send = () => {}; attached = false; } };
const raw = frame => { if (frame.type === 'done' || frame.type === 'error') terminal = frame; try { send(frame); } catch { send = () => {}; attached = false; } };
// What the turn made (files) and compact activity lines (notes-split-agent8.md): derived from the cards the turn already sends.
const tracker = new DeliverableTracker(workspacePath, trackerFolders());
const tracker = new DeliverableTracker(workspacePath, { ...trackerFolders(), claims: folderClaims, owner: chatId });
let toolsRan = false; // a turn that ran no tool wrote nothing, so there is nothing to look for at its end
try { tracker.snapshot(); } catch { /* a folder that cannot be read has no deliverables */ }
const groups = new ActivityGroups({ emit: raw });
const skills = new SkillsUsed({ emit: raw, info: skillInfoFrom(skillsDir()) });
const emit = frame => {
if (frame.type === 'reasoning' && frame.text && written.reasoning.length < 2_000_000) written.reasoning += frame.text;
if (frame.type === 'delta' && !frame.own) { written.all += frame.text; written.step += frame.text; if (guard) trip(guard.text(frame.text)); if (hideDeltas) return; }
if (frame.own) { const { own, ...rest } = frame; frame = rest; }
if (frame.type === 'delta' && frame.text) groups.text();
if (frame.type === 'agent-tool' && frame.tool) {
if (['done', 'error'].includes(frame.tool.state) && !['read', 'read_image', 'glob', 'grep', 'web_fetch', 'web_search'].includes(frame.tool.name)) toolsRan = true;
let made = null;
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path); } catch { made = null; } }
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path, { explicit: true }); } catch { made = null; } }
raw(frame);
if (made) raw(made);
if (guard) trip(guard.card(frame.tool));
@@ -463,6 +468,20 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
};
c.turn = turn;
if (workspacePath) folderClaims.begin(workspacePath, chatId);
/**
* A page that comes back (a reload, a new window) takes the running turn over again. Everything that was said while no page was there is
* sent as one catch-up: the start, the text and thinking so far (a `resync`), every tool card as it is now (the ones waiting for a yes
* included) and the questions waiting for an answer. After that the turn's frames flow to the new page as they did to the old one.
*/
turn.reattach = ({ onFrame, signal: again }) => {
send = onFrame; attached = true;
again?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
raw({ type: 'start', model: route.name, contextMax: route.contextWindow, demo: false, remoteModel: route.kind === 'api', agent: true, mode: appliedMode, sessionId, requestId, skills: [], resumed: true, ...(planTask ? { planning: true } : {}) });
raw({ type: 'resync', content: hideDeltas ? '' : written.all, reasoning: written.reasoning });
for (const t of projector.tools.values()) if (t.card) raw({ type: 'agent-tool', tool: t.card });
for (const q of questions.values()) if (q.sessionId === sessionId) raw({ type: 'agent-question', callId: q.callId, questions: q.items });
};
/**
* A turn must always end. If the Harness sends nothing at all for a while (a tool that never returns, a model server that went
* quiet, a lost event) the turn is stopped and ended with a plain message instead of showing "thinking" forever. A running tool gets
@@ -500,6 +519,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
reporter.clear();
const extra = [];
try { if (toolsRan) for (const d of tracker.sweep()) { emit(d); groups.addFile(d); } } catch { /* never blocks the end of a turn */ }
if (workspacePath) folderClaims.end(workspacePath, chatId);
groups.end();
if (researchTopic && !failure && !turn.stopping) { const sources = parseSources(written.all); if (sources.length) extra.push({ type: 'research', topic: researchTopic, sources }); }
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
@@ -529,6 +549,8 @@ ${note.text}`, key: note.key, values: note.values, noteReason: 'cut' });
}
for (const f of frames.slice(-1)) emit(f);
c.turn = null;
// What a page that was away needs to show the end of this turn (attach, below).
c.last = { requestId, sessionId, mode: appliedMode, model: route.name, contextMax: route.contextWindow, remoteModel: route.kind === 'api', content: hideDeltas ? '' : written.all.slice(0, 4_000_000), reasoning: written.reasoning, cards: [...projector.tools.values()].filter(t => t.card).map(t => t.card), terminal };
turn.ended.resolve({ reason: projector.ended, failure: failure ?? null });
notifyIdle();
// A turn that filled the context, or that the guard stopped, leaves a long history behind: summarise the older part now so the next message has room.
@@ -1402,6 +1424,34 @@ ${note.text}
return { stopped: true, ended: result.reason?.kind ?? 'unknown', ms: Date.now() - t0 };
}
/** The turn running for a chat now, for a page that has just loaded; null when none. */
function turnOf(chatId) {
const t = chats.get(chatId)?.turn;
if (!t || t.finished) return null;
return { chatId, sessionId: t.sessionId, requestId: t.requestId, mode: t.mode, stopping: !!t.stopping, pending: pendingFor(chatId) };
}
/** Every chat with a turn running. */
function runningTurns() { return [...chats.keys()].map(turnOf).filter(Boolean); }
/**
* Take a turn over from a new page. A running turn: the catch-up, then the live frames until it ends or the page leaves again. A turn that
* ended while no page was there (`requestId` names it): its text, tool cards and closing frame once. null when there is nothing to take over.
*/
function attach({ chatId, requestId = null, onFrame, signal }) {
const c = chats.get(chatId);
const turn = c?.turn;
if (turn && !turn.finished && typeof turn.reattach === 'function') {
if (requestId && turn.requestId !== requestId) return null;
turn.reattach({ onFrame, signal });
return { ended: turn.ended.promise.then(() => undefined), live: true };
}
const last = c?.last;
if (!last || !requestId || last.requestId !== requestId) return null;
onFrame({ type: 'start', model: last.model, contextMax: last.contextMax, demo: false, remoteModel: last.remoteModel, agent: true, mode: last.mode, sessionId: last.sessionId, requestId: last.requestId, skills: [], resumed: true });
onFrame({ type: 'resync', content: last.content, reasoning: last.reasoning });
for (const card of last.cards) onFrame({ type: 'agent-tool', tool: card });
if (last.terminal) onFrame(last.terminal);
return { ended: Promise.resolve(), live: false };
}
function pendingFor(chatId) {
const sessionId = sessionMap[chatId]?.sessionId;
const helpers = chats.get(chatId)?.turn?.children;
@@ -1423,7 +1473,7 @@ ${note.text}
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, turnOf, runningTurns, attach, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
}
/** A goal that stopped by itself, in words. */
@@ -11,6 +11,7 @@
* timeoutSec the time allowed, 1 to 3600 (default 120). A command that runs over is ended together with the programs it started.
*/
import { spawn, spawnSync } from 'node:child_process';
import { endTree as endTreeNow, ownGroup } from '../process-tree.mjs';
import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
@@ -67,10 +68,7 @@ export const clampTimeoutSec = value => {
};
function endTree(child) {
try {
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10000 });
else child.kill('SIGKILL');
} catch { /* gone */ }
endTreeNow(child, 'SIGKILL'); // the command's whole process group on Linux and macOS (it is started as its own group below), taskkill /T on Windows
}
/**
@@ -85,7 +83,7 @@ export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto',
const limit = clampTimeoutSec(timeoutSec);
let child;
try {
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, detached: ownGroup(), cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) { return resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); }
let text = '', cut = false, timedOut = false;
const take = chunk => { if (text.length < OUT_MAX + 1) text += chunk.toString(); else cut = true; };
+51 -3
View File
@@ -49,6 +49,44 @@ export function isWithin(parent, child) {
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
}
/**
* Who made which file when several chats work in one folder (#5008). A folder chosen for two chats is shared on purpose, but a
* chat's list of "files this turn made" is private to that turn: a file another chat wrote must not be shown as this chat's work.
* One registry is shared by the turns of a host. A turn `begin`s on its folder, `claim`s the files its own file tools wrote, and
* `end`s. Turns that were running at the same time in the same folder are `overlapped`: for them the end-of-turn sweep reports only
* files nobody else claimed AND that no overlapping turn could have written; since a script's output cannot be told apart, it is
* left out for an overlapped turn rather than given to the wrong chat.
*/
export class FolderClaims {
#active = new Map(); // folder key -> Set(owner)
#overlap = new Map(); // folder key + owner -> true
#claims = new Map(); // folder key -> Map(rel path -> owner)
#key(root) { return same(resolve(String(root))); }
begin(root, owner) {
const k = this.#key(root);
const set = this.#active.get(k) ?? new Set();
const others = [...set].filter(o => o !== owner);
if (others.length) { for (const o of [...others, owner]) this.#overlap.set(`${k}|${o}`, true); }
set.add(owner); this.#active.set(k, set);
}
end(root, owner) {
const k = this.#key(root);
const set = this.#active.get(k);
if (!set) return;
set.delete(owner); this.#overlap.delete(`${k}|${owner}`);
if (!set.size) { this.#active.delete(k); this.#claims.delete(k); }
}
claim(root, rel, owner) {
const k = this.#key(root);
if (!this.#active.has(k)) return;
const m = this.#claims.get(k) ?? new Map();
m.set(rel, owner); this.#claims.set(k, m);
}
ownerOf(root, rel) { return this.#claims.get(this.#key(root))?.get(rel) ?? null; }
/** True when another turn was running in this folder at any time during this owner's turn. */
overlapped(root, owner) { return this.#overlap.get(`${this.#key(root)}|${owner}`) === true; }
}
export class DeliverableTracker {
/**
* `exclude`: folders that are never searched when they lie inside the chat's folder (the app, the engine and its components, AppData,
@@ -56,8 +94,9 @@ export class DeliverableTracker {
* tracked at all. The turn only reports files the turn itself wrote: an explicit file-tool call, or (for scripts) a file whose modified
* or creation time is after the turn started.
*/
constructor(root, { now = Date.now, exclude = [], forbid = [] } = {}) {
constructor(root, { now = Date.now, exclude = [], forbid = [], claims = null, owner = null } = {}) {
this.root = root ? resolve(root) : null;
this.claims = claims; this.owner = owner;
this.now = now;
if (this.root && forbid.filter(Boolean).some(p => isWithin(p, this.root))) this.root = null;
// only the folders that lie inside the chat's folder matter for the search
@@ -104,13 +143,15 @@ export class DeliverableTracker {
}
/** The deliverable for one path now, compared with the snapshot (and the last report), or null when nothing changed. */
check(rawPath) {
check(rawPath, { explicit = false } = {}) {
if (!this.root) return null;
const full = resolve(this.root, String(rawPath ?? ''));
const rel = relative(this.root, full).split(sep).join('/');
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split('/').some(seg => SKIP_DIR.test(seg)) || this.#excluded(dirname(full))) return null;
let st; try { st = statSync(full); } catch { return null; }
if (!st.isFile()) return null;
// A file this chat's own file tool wrote is this chat's, whoever else works in the folder (#5008).
if (explicit && this.claims && this.owner) this.claims.claim(this.root, rel, this.owner);
const old = this.before.get(rel);
if (old && old.size === st.size && old.mtimeMs === st.mtimeMs) return null;
const type = fileType(rel);
@@ -139,7 +180,14 @@ export class DeliverableTracker {
// modified during the turn; or created during it (a copy that kept its old modified time), which is only certain when the first look saw the whole folder
const fresh = st.mtimeMs >= since || (!this.snapshotTruncated && (st.birthtimeMs || 0) >= since);
if (!fresh) return;
const d = this.check(relative(this.root, full)); if (d) out.push(d);
const rel = relative(this.root, full).split(sep).join('/');
if (this.claims && this.owner) {
// Another chat's file tool wrote it: not ours. Written by a script while another chat was working in the same folder: nobody can say whose it is.
const by = this.claims.ownerOf(this.root, rel);
if (by && by !== this.owner) return;
if (!by && this.claims.overlapped(this.root, this.owner)) return;
}
const d = this.check(rel); if (d) out.push(d);
});
return out;
}
+1 -1
View File
@@ -6,7 +6,7 @@
* per saved API connection, so that each chat can pick its own model. This module writes the same two
* Cordis entries (`agent-default-model`, `llm-pi-ai`) for the current set.
*
* Rules carried over from the integration notes (docs/work/IMPLEMENTATION_STATUS.md section 1.8):
* Rules carried over from the integration notes (history/docs/work/IMPLEMENTATION_STATUS.md section 1.8):
* - `llm-pi-ai` config is `{ providers: { "<route>": {...} } }`; a route key at the top level is
* silently ignored.
* - the output budget must fit the whole request, so `maxTokens` is derived from the real context.
+2 -2
View File
@@ -22,7 +22,7 @@ export function refusedFolder(dir) {
const win = (process.env.SystemRoot ?? 'C:\\Windows').toLowerCase();
const q = p.toLowerCase();
if (q === win || q.startsWith(win + sep) || /^[a-z]:\\(program files( \(x86\))?|programdata)(\\|$)/.test(q)) return 'That is a system folder. Choose one of your own folders.';
} else if (/^\/(bin|boot|dev|etc|lib|lib64|proc|root|run|sbin|sys|usr|var)(\/|$)/.test(p)) return 'That is a system folder. Choose one of your own folders.';
} else if (/^\/(bin|boot|dev|etc|lib|lib64|proc|root|run|sbin|sys|usr|var)(\/|$)/.test(p) && !/^\/run\/media\/[^/]+\/./.test(p)) return 'That is a system folder. Choose one of your own folders.';
return null;
}
@@ -153,7 +153,7 @@ export function parseCsv(text, delimiter = ',', maxRows = 500) {
export function previewFile(root, rawPath, sessionId) {
const file = resolveChatFile(root, rawPath);
const st = statSync(file); const kind = fileType(file);
const base = { kind, name: basename(file), path: file, size: st.size, url: pathToFileURL(file).href };
const base = { kind, name: basename(file), path: file, size: st.size, mtime: Math.round(st.mtimeMs), url: pathToFileURL(file).href };
const raw = `/api/files/raw?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file)}`;
if (kind === 'image' || kind === 'pdf') return { ...base, rawUrl: raw, mime: mimeOf(file) };
if (kind === 'other') return { ...base, note: 'This kind of file has no preview. Open it in its own app.' };
+186
View File
@@ -0,0 +1,186 @@
/**
* The chats, saved by the host as files (owner decision 11-04: the browser's 5 MB storage limit is gone).
*
* <data folder>/chats/index.json { v: 1, chats: [{ id, rev, createdAt, updatedAt, bytes, title }] }
* <data folder>/chats/<id>.json { "v": 1, "rev": N, "savedAt": ISO, "session": { ...the chat exactly as the page sent it... } }
* <data folder>/chats/conflicts/ the version a later save displaced when two windows saved the same chat from an older copy
*
* - One file per chat. A save writes a temporary file next to it, flushes it and renames it over the chat: a crash or a full disk
* in the middle leaves the older file whole. Temporary files left by a crash are removed at the next start.
* - `rev` counts the saves of a chat. A save that names a `baseRev` older than the stored one (a second window saved first) is still
* written, but the version it displaces is kept in conflicts/ first, so nothing is lost.
* - The index is a list for fast start. If it is missing or does not match the files it is rebuilt from the files; a file that cannot
* be read is reported and left exactly as it is.
* - Nothing is cut: the chat is stored byte for byte as received (a text splice, no re-serialising).
* - Owner-only: the folder is created private (POSIX 0700 and files 0600; on Windows the folder gets a protected ACL that its files
* inherit), the same level as the rest of the profile.
*/
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from 'node:fs';
import { open, readFile, rename, unlink, mkdir, copyFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
export const CHAT_FORMAT = 1;
const ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
const RESERVED = /^(con|prn|aux|nul|com\d|lpt\d|index|import|conflicts)(\.|$)/i;
const KEEP_CONFLICTS = 5;
export class ChatStoreError extends Error {
constructor(message, status, code) { super(message); this.status = status; this.code = code; }
}
/** The disk operations, replaceable in tests to make a disk full, a permission failure or a crash in the middle of a write. */
export const realIo = {
async writeFile(path, text) {
const handle = await open(path, 'w', 0o600);
try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
},
rename, unlink, copyFile, readFile, readdir, mkdir,
};
export const validChatId = id => typeof id === 'string' && ID.test(id) && !RESERVED.test(id);
/** Map a failed disk operation to what the page is told: a full disk and a refused write are different words for the same "not saved". */
export function storeFailure(error) {
if (error instanceof ChatStoreError) return error;
const code = error?.code;
if (code === 'ENOSPC' || code === 'EDQUOT' || code === 'EFBIG') return new ChatStoreError('The disk is full.', 507, 'disk-full');
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return new ChatStoreError('Strata may not write its chat folder.', 403, 'permission');
return new ChatStoreError(error?.message ?? 'The chat could not be written.', 500, code ?? 'write-failed');
}
export function createChatStore({ dir, io = realIo, privateDir = () => {}, now = () => new Date() }) {
const root = join(dir, 'chats');
const conflictsDir = join(root, 'conflicts');
mkdirSync(root, { recursive: true, mode: 0o700 });
try { privateDir(root); } catch { /* the folder is inside the profile; the tighter ACL is best effort */ }
let tmpCount = 0;
let index = new Map(); // id -> entry
const damaged = new Set();
const fileOf = id => join(root, `${id}.json`);
const bytesOf = text => Buffer.byteLength(text, 'utf8');
const entryOf = (id, rev, session, bytes) => ({ id, rev, createdAt: session?.createdAt ?? null, updatedAt: session?.updatedAt ?? null, bytes, title: typeof session?.title === 'string' ? session.title.slice(0, 200) : '' });
// A chain, so saves of one host never overlap: the file, the index and the conflicts folder change one save at a time.
let chain = Promise.resolve();
const queue = work => { const run = chain.then(work, work); chain = run.catch(() => {}); return run; };
function loadSync() {
// Temporary files from a write that never finished.
let names = [];
try { names = readdirSync(root); } catch { /* empty */ }
for (const name of names) if (/\.tmp-\d+-\d+$/.test(name)) { try { unlinkSync(join(root, name)); } catch { /* left */ } }
const files = names.filter(n => n.endsWith('.json') && n !== 'index.json' && !/\.tmp-/.test(n)).map(n => n.slice(0, -5)).filter(validChatId);
let saved = null;
try { const parsed = JSON.parse(readFileSync(join(root, 'index.json'), 'utf8')); if (parsed?.v === CHAT_FORMAT && Array.isArray(parsed.chats)) saved = parsed.chats; } catch { /* rebuilt below */ }
const map = new Map();
if (saved) for (const e of saved) if (e && validChatId(e.id) && files.includes(e.id)) map.set(e.id, e);
// Files the index does not know (a crash before the index was written, an index lost): read from the files.
for (const id of files) {
if (map.has(id)) continue;
try {
const parsed = JSON.parse(readFileSync(fileOf(id), 'utf8'));
if (parsed?.v !== CHAT_FORMAT || !parsed.session || parsed.session.id !== id) throw new Error('not a chat file');
map.set(id, entryOf(id, Number(parsed.rev) || 1, parsed.session, statSync(fileOf(id)).size));
} catch { damaged.add(id); }
}
index = map;
}
loadSync();
async function writeAtomic(path, text) {
const temp = `${path}.tmp-${process.pid}-${++tmpCount}`;
try {
await io.writeFile(temp, text);
for (let attempt = 0; ; attempt++) {
try { await io.rename(temp, path); break; }
catch (e) { if (attempt >= 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(e?.code)) throw e; await new Promise(r => setTimeout(r, 40 * (attempt + 1))); } // an antivirus scan holds the file for a moment on Windows
}
} catch (e) {
try { await io.unlink(temp); } catch { /* nothing to remove */ }
throw e;
}
}
async function writeIndex() {
const list = [...index.values()];
try { await writeAtomic(join(root, 'index.json'), JSON.stringify({ v: CHAT_FORMAT, savedAt: now().toISOString(), chats: list })); }
catch { /* only a list: it is rebuilt from the files when it does not match */ }
}
async function keepDisplaced(id, rev) {
try {
await io.mkdir(conflictsDir, { recursive: true });
await io.copyFile(fileOf(id), join(conflictsDir, `${id}.rev${rev}.json`));
const mine = (await io.readdir(conflictsDir)).filter(n => n.startsWith(`${id}.rev`)).sort((a, b) => Number(a.match(/rev(\d+)/)?.[1]) - Number(b.match(/rev(\d+)/)?.[1]));
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP_CONFLICTS))) { try { await io.unlink(join(conflictsDir, old)); } catch { /* left */ } }
} catch (e) {
throw storeFailure(e); // not saved rather than saved over the only copy of the other window's work
}
}
return {
root,
/** The chats, newest first. */
list() {
const chats = [...index.values()].sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')) || a.id.localeCompare(b.id));
return { v: CHAT_FORMAT, chats, damaged: [...damaged] };
},
has: id => index.has(id),
rev: id => index.get(id)?.rev ?? 0,
/** The stored file text of a chat ({"v","rev","savedAt","session"}), or null. */
async read(id) {
if (!validChatId(id) || !index.has(id)) return null;
try { return await io.readFile(fileOf(id), 'utf8'); }
catch (e) { if (e?.code === 'ENOENT') { index.delete(id); return null; } throw storeFailure(e); }
},
/**
* Save one chat. `sessionText` is the chat's JSON as the page wrote it. Resolves { id, rev, bytes, conflict }.
* Rejects with a ChatStoreError (status 400 for a chat that is not a chat, 507 disk full, 403 refused, 500 other): nothing was changed.
*/
save(id, sessionText, { baseRev = null } = {}) {
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
let session;
try { session = JSON.parse(sessionText); } catch { return Promise.reject(new ChatStoreError('The chat must be JSON.', 400, 'bad-json')); }
if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== id) return Promise.reject(new ChatStoreError('The chat does not match its id.', 400, 'bad-chat'));
const body = sessionText.trim();
return queue(async () => {
const current = index.get(id);
const rev = (current?.rev ?? 0) + 1;
let conflict = false;
if (current && baseRev !== null && Number(baseRev) !== current.rev) { conflict = true; await keepDisplaced(id, current.rev); }
const text = `{"v":${CHAT_FORMAT},"rev":${rev},"savedAt":${JSON.stringify(now().toISOString())},"session":${body}}`;
try { await writeAtomic(fileOf(id), text); } catch (e) { throw storeFailure(e); }
const bytes = bytesOf(text);
index.set(id, entryOf(id, rev, session, bytes));
damaged.delete(id);
await writeIndex();
return { id, rev, bytes, conflict };
});
},
/** Delete a chat (the person deleted it). A chat that is not there is already gone. */
remove(id) {
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
return queue(async () => {
try { await io.unlink(fileOf(id)); } catch (e) { if (e?.code !== 'ENOENT') throw storeFailure(e); }
const had = index.delete(id);
await writeIndex();
return { id, removed: had };
});
},
/** Chats from the browser's old storage: every one the host does not have yet is saved; the ones it has are left alone. */
async importMany(sessions) {
const imported = [], skipped = [], failed = [];
for (const session of Array.isArray(sessions) ? sessions : []) {
const id = session?.id;
if (!validChatId(id)) { failed.push({ id: String(id ?? ''), error: 'not a chat id' }); continue; }
if (index.has(id)) { skipped.push(id); continue; }
try { await this.save(id, JSON.stringify(session)); imported.push(id); }
catch (e) { failed.push({ id, error: e.message, code: e.code }); if (e.code === 'disk-full') break; }
}
return { imported, skipped, failed };
},
/** Wait until every save queued so far is on disk (tests, shutdown). */
idle: () => chain,
};
}
+17 -6
View File
@@ -288,21 +288,32 @@ export function createControl({ control = findControl(), env = process.env, run
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
},
/**
* The DFlash2 drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter }. Control decides
* (the table of strata_control/drafting.py); the app never reads it. Nothing is downloaded.
* The drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, tuned_needed, gb, drafter_gb, tuned, file, line, note, drafter, split, mtp_needed, mtp_gb }. `model` (with `family`) names a model that has no run config yet. Control decides
* (the table of strata_control/drafting.py and the all-cards split engine's rule, strata_control/split_engine.py: the same answer as the Terminal's); the app never reads
* it. Nothing is downloaded. `noSplit` asks as if the split engine will not take the start (only after the draft layer for the split could not be downloaded).
*/
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--no-split', '--output', 'json'], { timeoutMs: 30_000 });
async draftingPlan({ configPath = null, mode = null, family = null, model = null, dataDir = null, noSplit = false, unloading = false } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(model && !configPath ? ['--model', model] : []), ...(dataDir ? ['--data-dir', dataDir] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The drafter could not be checked.');
},
/** Fetch and pack the drafter when the choice would use it and it is not here (`strata drafting fetch --if-needed`); `onEvent` gets {step, done, total}. */
async draftingFetch({ configPath, mode = null, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--no-split', '--if-needed', '--output', 'jsonl'];
async draftingFetch({ configPath, mode = null, noSplit = false, unloading = false, throttle = false, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--if-needed', ...(throttle ? ['--throttle'] : []), '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
if (result.json?.ok) return result.json;
throw Object.assign(new ControlError(result.json?.error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The DFlash2 drafter could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: 'drafting_failed' });
},
/**
* The all-cards split engine for the run config the app is about to start (`strata split-engine apply`, the step `strata launch` does itself): Control checks the rule, the
* engine package and the draft layer on this computer and writes the split engine's run config to `outPath` when the answer is yes. Resolves { split, line, warn, reasons, cards, written };
* `configPath` is never changed, nothing is downloaded.
*/
async splitEngine({ configPath, outPath } = {}) {
const result = await call(['split-engine', 'apply', '--config', configPath, '--out', outPath, '--output', 'json'], { timeoutMs: 60_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The all-cards split engine could not be checked.');
},
/**
* The picture files of an installed model that has none (strata_control/pictures.py): found or downloaded once (about 0.9 GB, shared), and the run
* config's `vision` section worked out for this engine. Nothing else of the model is touched and nothing is written to its run config: the caller does.
+81 -10
View File
@@ -46,12 +46,15 @@ import * as engine from './engine.mjs';
import * as monitor from './monitor.mjs';
import * as serving from './serving.mjs';
import * as store from './store.mjs';
import { createChatStore, storeFailure } from './chat-store.mjs';
import { makePrivateDir } from './private-file.mjs';
import * as supervisor from './supervisor.mjs';
import * as engineInstall from './engine-install.mjs';
import { detectBackend, currentBackend, streamChat, inferenceLine } from './inference.mjs';
import { CONTEXT_TOKENS, VARIANTS, localModels, variant } from './catalog.mjs';
import { browseLocalModels, inspectLocalModel } from './local-models.mjs';
import { EventHub, error, json, readBody, readRaw, uid } from './util.mjs';
import { keepStreamAlive } from './stream-beat.mjs';
import { EventHub, badRequest, error, json, readBody, readRaw, requestUrl, sameSecret, uid } from './util.mjs';
import { createUploads } from './chat-uploads.mjs';
import { createNativeDrops } from './native-drop.mjs';
import { createVisionProbe, effectiveVision, readsPicturesByDefault } from './vision-probe.mjs';
@@ -118,6 +121,8 @@ const WORKSPACE_NAME = process.env.STRATA_WORKSPACE ? basename(WORKSPACE) : 'Str
const insideApp = path => { const rel = relative(ROOT, resolve(path)); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
const hub = new EventHub();
/** The chats, as files in the profile's data folder (chat-store.mjs). */
const chatStore = createChatStore({ dir: store.DATA_DIR, privateDir: makePrivateDir });
const modelProviders = createModelProviders({ directory: store.DATA_DIR, knownThinks: (connection, modelId) => visionProbe.knownThinks(connection, modelId), changed: value => hub.send('providers', withPictureAnswers(value)) });
const imageModels = createImageModels({ dataDir: store.DATA_DIR, modelsDir: () => store.MODELS_DIR, real: models.REAL, changed: value => hub.send('image-models', value) });
/* The installed app ships Strata Control (python + strata_control) next to the host; a source checkout has none and keeps its older paths. */
@@ -943,7 +948,9 @@ const taskTimer = setInterval(() => tasks.tick(), 15000);
taskTimer.unref();
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
// The probe listens on the loopback address only: a probe on 0.0.0.0 made Windows Defender Firewall ask "Allow Node.js to communicate on these networks?" on the
// first start of every new install path (QA 0213). Reserved or taken ports are caught on any address; the share listener itself binds 0.0.0.0 only when the person turns sharing on.
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '127.0.0.1', { skip: [PORT] });
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
@@ -954,7 +961,8 @@ await cloudflare.inspect();
/* --------------------------------------------------------------- routing --- */
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const url = requestUrl(req);
if (!url) return badRequest(res); // #4397: a bad Host or request target is a 400, never an unhandled rejection
const path = url.pathname;
const method = req.method ?? 'GET';
@@ -985,12 +993,55 @@ const server = http.createServer(async (req, res) => {
return await staticFile(req, res, path);
} catch (err) {
console.error('[strata]', err);
if (res.headersSent) { res.end(); return; } // a stream already began: it ends here (a second writeHead would throw out of this handler and end the process)
error(res, 500, err.message ?? 'Internal error');
}
});
async function api(req, res, path, method, url) {
if(path==='/api/fonts'&&method==='GET')return json(res,200,await systemFonts());
// --- chats: one file each in the data folder -----------------------------
// Not held back by a model folder move: saving a chat has nothing to do with the models. A page must name itself (x-strata-client), which
// a web page from another site cannot do without the browser asking the host first.
const chatsRoute = /^\/api\/chats(?:\/([^/]+))?$/.exec(path);
if (chatsRoute) {
if (!req.headers['x-strata-client']) return error(res, 403, 'Not allowed.');
let id = null;
try { id = chatsRoute[1] ? decodeURIComponent(chatsRoute[1]) : null; } catch { return error(res, 400, 'That is not a chat id.', { code: 'bad-id' }); }
const from = typeof req.headers['x-strata-window'] === 'string' ? req.headers['x-strata-window'].slice(0, 64) : null;
try {
if (!id && method === 'GET') return json(res, 200, chatStore.list());
if (id === 'import' && method === 'POST') {
const body = JSON.parse((await readRaw(req, 512 * 1024 * 1024)).toString('utf8') || '{}');
const result = await chatStore.importMany(body.sessions);
for (const imported of result.imported) hub.send('chats', { id: imported, rev: chatStore.rev(imported), from });
return json(res, 200, result);
}
if (id && method === 'GET') {
const text = await chatStore.read(id);
if (text === null) return error(res, 404, 'No such chat.', { code: 'not-found' });
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text), 'cache-control': 'no-store' });
res.end(text);
return undefined;
}
if (id && method === 'PUT') {
const text = (await readRaw(req, 256 * 1024 * 1024)).toString('utf8');
const base = req.headers['x-strata-base-rev'];
const saved = await chatStore.save(id, text, { baseRev: typeof base === 'string' && /^\d+$/.test(base) ? Number(base) : null });
hub.send('chats', { id, rev: saved.rev, from });
return json(res, 200, saved);
}
if (id && method === 'DELETE') {
const removed = await chatStore.remove(id);
hub.send('chats', { id, deleted: true, from });
return json(res, 200, removed);
}
} catch (e) {
const failure = e instanceof SyntaxError ? { status: 400, message: 'The request must be JSON.', code: 'bad-json' } : storeFailure(e);
return error(res, failure.status, failure.message, { code: failure.code });
}
return error(res, 405, 'Method not allowed.');
}
if (storageChanging && !['GET', 'OPTIONS'].includes(method)) return error(res, 409, 'Wait for the model folder check to finish.');
// --- events -------------------------------------------------------------
if (path === '/api/events') return hub.add(res);
@@ -1047,7 +1098,7 @@ async function api(req, res, path, method, url) {
if(nativeInstance&&method==='POST')return json(res,200,nativeInstance[2]==='unload'?await native.unload(nativeInstance[1]):native.setDefault(nativeInstance[1]));
const nativeSettings=/^\/api\/native\/models\/([a-z0-9_.-]+)\/settings$/.exec(path);
/* Expert mode shows what will actually run: the saved tuning's values come with the settings (null when none applies or unreadable). */
if(nativeSettings&&method==='GET'){const settings=native.settings(nativeSettings[1]);let tuned=null;try{const t=await native.tuneEffective({model:nativeSettings[1],resourceProfile:url.searchParams.get('resourceProfile')||undefined,vision:url.searchParams.get('vision')||undefined});tuned=t.applied?t:null;}catch{tuned=null;}return json(res,200,{...settings,tuned});}
if(nativeSettings&&method==='GET'){const settings=native.settings(nativeSettings[1]);let tuned=null;try{const t=await native.tuneEffective({model:nativeSettings[1],resourceProfile:url.searchParams.get('resourceProfile')||undefined,vision:url.searchParams.get('vision')||undefined});tuned=t.applied?t:null;}catch{tuned=null;}return json(res,200,{...settings,tuned,hints:await native.settingsHints?.(nativeSettings[1])});}
if(nativeSettings&&method==='PATCH')return json(res,200,native.save(nativeSettings[1],await readBody(req)));
const nativeSettingsFile=/^\/api\/native\/models\/([a-z0-9_.-]+)\/settings-file$/.exec(path);
if(nativeSettingsFile&&method==='GET')return json(res,200,native.settingsFile(nativeSettingsFile[1]));
@@ -1195,9 +1246,7 @@ async function api(req, res, path, method, url) {
}
// One revision attempt for a job this host started: the app's assistant works inside the candidate folder.
if (path === '/api/internal/timeskip-revise' && method === 'POST') {
const given = Buffer.from(String(req.headers['x-strata-internal'] ?? ''));
const want = Buffer.from(INTERNAL_SECRET);
if (given.length !== want.length || !timingSafeEqual(given, want)) return error(res, 403, 'Not allowed.');
if (!sameSecret(req.headers['x-strata-internal'], INTERNAL_SECRET)) return error(res, 403, 'Not allowed.');
const body = await readBody(req);
const cwd = String(body.cwd ?? '');
// Only the job's own candidate folders may be worked in.
@@ -1307,8 +1356,7 @@ async function api(req, res, path, method, url) {
// The shell (and nothing else) holds the secret that lets it end the host or tell it which window is in use.
const fromShell = () => {
const token = process.env.STRATA_SHUTDOWN_TOKEN ?? '';
const given = String(req.headers['x-strata-shutdown'] ?? '');
return token.length >= 32 && given.length === token.length && timingSafeEqual(Buffer.from(given), Buffer.from(token));
return token.length >= 32 && sameSecret(req.headers['x-strata-shutdown'], token);
};
// Which window the person is using: the page shows "Quit Strata" in browser mode only.
if (path === '/api/app/mode' && method === 'GET') return json(res, 200, appUi.state());
@@ -1614,7 +1662,7 @@ async function api(req, res, path, method, url) {
if (cancelMatch && method === 'POST') return json(res, 200, serving.cancel(cancelMatch[1]));
// --- monitor ------------------------------------------------------------
if (path === '/api/monitor') { monitor.watch(); return json(res, 200, monitor.snapshot()); }
if (path === '/api/monitor') { monitor.watch(); if (url.searchParams.get('drives') === '1') monitor.watchDrives(); return json(res, 200, monitor.snapshot()); }
// --- skills / workspace -------------------------------------------------
if (path === '/api/skills' && method === 'GET') {
@@ -1989,6 +2037,28 @@ async function api(req, res, path, method, url) {
if (typeof b.sessionId !== 'string' || !['manual', 'plan', 'bypass'].includes(b.mode)) return error(res, 400, 'sessionId and mode (manual, plan or bypass) are required');
try { return json(res, 200, await agent.setMode(b.sessionId, b.mode)); } catch (e) { return error(res, e.code === 'mode-unavailable' ? 409 : 502, e.message); }
}
/* A page that has just loaded asks which chats have a turn running (a reload does not stop a turn), and takes one over again: the catch-up
of what was said while no page was there, then the live frames, as on /api/chat/stream. A turn that ended while the page was away
(the page names it by requestId) is sent once, whole. 404 when there is nothing to take over. */
if (path === '/api/chat/active' && method === 'GET') return json(res, 200, { turns: agent.runningTurns() });
if (path === '/api/chat/attach' && method === 'POST') {
const b = await readBody(req);
if (typeof b.sessionId !== 'string' || !b.sessionId) return error(res, 400, 'sessionId is required');
const controller = new AbortController();
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
let started = false;
const write = frame => {
if (res.writableEnded || res.destroyed) return;
if (!started) { started = true; res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive' }); keepStreamAlive(res); }
res.write(`${JSON.stringify(frame)}
`);
};
const attached = agent.attach({ chatId: b.sessionId, requestId: typeof b.requestId === 'string' ? b.requestId : null, onFrame: write, signal: controller.signal });
if (!attached) return error(res, 404, 'There is no turn to take over.', { code: 'no-turn' });
await Promise.race([attached.ended, new Promise(r => controller.signal.addEventListener('abort', r, { once: true }))]);
if (!res.writableEnded) res.end();
return undefined;
}
/* Stop one chat's turn. Resolves once the turn has really ended, not when the cancel was admitted. */
if (path === '/api/chat/stop' && method === 'POST') {
const b = await readBody(req);
@@ -2018,6 +2088,7 @@ async function api(req, res, path, method, url) {
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
});
keepStreamAlive(res); // #4878: a quiet turn stays open; the timer ends with the response
const controller = new AbortController();
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
+4 -1
View File
@@ -3,6 +3,7 @@ import http from 'node:http';
import os from 'node:os';
import { apiPath } from './api-paths.mjs';
import { listenProblem } from './ports.mjs';
import { badRequest, requestUrl } from './util.mjs';
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
@@ -13,7 +14,9 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
if (active) return state();
if (listener) throw new Error('Sharing is already starting.');
listener = http.createServer(async (req, res) => {
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
const url = requestUrl({ url: req.url, headers: {} }); // the Host header is not read here; a request target that is not a path is a 400 (#4397)
if (!url) return badRequest(res);
const path = apiPath(url.pathname);
const method = req.method ?? 'GET';
// #3208: with an API key set, a browser page of any site can call the shared API - the key decides, not the
// origin. The browser's preflight (OPTIONS) never carries the key (the spec says so), so it is answered here,
+8 -3
View File
@@ -62,7 +62,12 @@ let ticker = null;
let memoryWarningShown = false;
let sensorsPending = false, sensorsAt = 0;
/* The Monitor screen asks (GET /api/monitor) every few seconds while it is open; a model that is loaded counts as live (#4066). */
let watchedUntil = 0, liveProbe = () => false;
let watchedUntil = 0, drivesUntil = 0, liveProbe = () => false;
const hostStartedAt = Date.now();
/** The drive-temperature reader is not started in the first minute of the host: the app's own start-up traffic is not a person looking at the Monitor (#4661). */
export const DRIVES_AFTER_MS = 60000;
/** Only the Monitor screen's own ask (GET /api/monitor?drives=1) wants drive temperatures; the start-up fetch and the Models page do not show them. */
export function watchDrives() { drivesUntil = Date.now() + WATCH_MS; }
export function watch() {
const wasIdle = Date.now() >= watchedUntil;
watchedUntil = Date.now() + WATCH_MS;
@@ -127,14 +132,14 @@ function tick() {
if(!sensorsPending&&Date.now()-sensorsAt>=pace.intervalMs){
sensorsPending=true;sensorsAt=Date.now();
const hardware=cachedHardware()??hwCache;
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<watchedUntil?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<drivesUntil&&Date.now()-hostStartedAt>DRIVES_AFTER_MS?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
.then(([readings,system,temps])=>{state.hardware.gpus=readings;systemReading=system;temperatures=temps;})
.catch(()=>{state.hardware.gpus=[];systemReading=null;})
.finally(()=>{sensorsPending=false;});
}
/* #4661: the drive-temperature reader asks every physical drive for its temperature. Only while the Monitor is looked at, never from the first second of the app
(on some PCs the storage queries stall the whole system); it ends a couple of minutes after the Monitor was last seen. */
if(Date.now()>watchedUntil+120000)stopTemperatures();
if(Date.now()>drivesUntil+120000)stopTemperatures();
addEnergy(hardwareSnapshot().powerW,Date.now());
const hw = state.hardware;
const busy = state.phase === 'generating' || state.phase === 'reading' || state.phase === 'thinking';
+81 -21
View File
@@ -7,7 +7,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpa
import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'node:net';
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, resolveDrafter, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv, speedSwitchRows } from './native-settings.mjs';
import { draftingHint, NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, resolveDrafter, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv, speedSwitchRows } from './native-settings.mjs';
import { resourceSettings, saferStart, reserveChoice } from './native-resources.mjs';
import { USER_KEY, capture as captureUserArgs, merge as mergeUserArgs, setFlag as setUserFlag, stamped as stampedUserArgs } from './native-user-args.mjs';
import { resolvePython, venvPython } from './python-resolver.mjs';
@@ -27,6 +27,8 @@ import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs
import { configContextLimit } from './dsv41-context.mjs';
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
/* What the load says when the server had to start the normal layer split because the all-cards split engine would not come up (the engine's own reason stays in its log). */
const SPLIT_FELL_BACK='The all-cards split engine could not start, so the normal layer split runs.';
const safeId=value=>typeof value==='string'&&/^[a-z0-9](?:[a-z0-9_-]|\.(?!\.)){0,79}$/i.test(value); /* a dot inside the name: deepseek-v4.1-flash-ist-das-2.00bpw */
const busy=requests=>requests.some(r=>['queued','generating'].includes(r.state));
function readJson(path,fallback={}){try{return JSON.parse(readFileSync(path,'utf8').replace(/^\uFEFF/,''));}catch{return fallback;}}
@@ -38,6 +40,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const settingsPath=join(directory,'native-host.json');
let preferences=readJson(settingsPath,{repo:defaultRepo});
let child=null,seq=0,buffer='',pending=new Map(),definitions=[],refreshing=null,lastRead=0,connecting=null;
/* QAGUI 0213: until Control has answered once, the state says it is still being asked (`checking`), not that nothing is installed. A failed read during Control's start is read again after a few seconds instead of standing for 45 s; after READ_TRIES failures the answer is final and the error shows. */
let everRead=false,failedReads=0;const READ_TRIES=4,RETRY_MS=3000;
let snapshot={available:false,repo:preferences.repo,summary:'Connect a local Strata installation.',catalog:null,installed:null,hardware:null,error:null};
const instances=new Map(),processes=new Map(),jobs=new Map(),logs=[];
const savedJobs=readJson(join(directory,'native-jobs.json'),[]);/* An app update ends the app on purpose: the installer replaces it and starts it again. The job that asked for it was saved as running together with the app version it started from (appUpdateFrom); a start that finds a different version knows the update finished, instead of calling it an interrupted operation. */
@@ -71,7 +75,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
/* Does the engine in this release serve DFlash2? Control knows (strata_control/drafting.py); asked once, false until it answers. The Models screen offers the choice only when true. */
let dflash2Served=null,dflash2Support=null,dflash2Selq4=false,dflash2Split=false;
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;dflash2Support=p.serves===true&&p.support&&p.support_line?{level:p.support,line:p.support_line}:null;dflash2Selq4=p.serves===true&&p.selq4===true;dflash2Split=p.split===true;emit();}).catch(()=>{});}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true,dflash2Support,dflash2Selq4,dflash2Split}};}
function state(){const checking=!everRead&&failedReads<READ_TRIES;const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,checking,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true,dflash2Support,dflash2Selq4,dflash2Split}};}
let sourceCache={path:'',time:0,text:''};
function sourceText(){try{const path=join(packaged?control.control.dir:preferences.repo,'serve/server.py'),time=statSync(path).mtimeMs;if(sourceCache.path===path&&sourceCache.time===time)return sourceCache.text;sourceCache={path,time,text:readFileSync(path,'utf8')};return sourceCache.text;}catch{return '';}}
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
@@ -148,7 +152,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
if(refreshing)return !force&&snapshot.available?state():force?refreshing.then(()=>refresh(true)):refreshing; /* a forced read that meets one already running (started before an engine update finished) waits for it and reads again, so it never settles on the old answer */if(!force&&Date.now()-lastRead<45000)return state();
probeDrafting();
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();everRead=true;failedReads=0;if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){failedReads++;snapshot={...snapshot,available:false,error:error.message};lastRead=!everRead&&failedReads<READ_TRIES?Date.now()-45000+RETRY_MS:Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
}
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
@@ -160,11 +164,13 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
/* A mistake in the file holds the start back with the line, instead of an engine that dies on it. Warnings (an unknown key) never do. */
function checkedSettingsConfig(text){const check=checkAndParseRunConfig(text);if(!check.ok)throw new Error(`The settings file for this model has a mistake, so the model was not started. ${describeIssues(check.issues,'error').join(' ')}`);return check.config;}
function settings(model){const {entry,config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true,capabilities:{dsExperts:ownEngine(config)&&dsExpertsSupported(config)},speedSwitches:speedSwitchRows(config.env??{},{deepseek:ownEngine(config),arch:config.arch??null,backend:config.backend??entry?.backend??snapshot.installed?.engine?.backend??null,gpus:snapshot.hardware?.gpus??[]})};}
/* The Drafting hint of this model's settings: the size of the drafter DFlash2 would fetch for it (Control's plan with the choice DFlash2); the generic hint when Control does not say. */
async function settingsHints(model){try{if(!packaged||typeof control.draftingPlan!=='function')return {};const {path}=recordFile(model),d=await control.draftingPlan({configPath:path,mode:'dflash2'});return Number(d?.drafter_gb)>0?{drafting:draftingHint(d.drafter_gb)}:{};}catch{return {};}}
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};if(patch.ds_experts!=null&&!dsExpertsSupported(config))throw new Error('The Speed choice needs the newer DeepSeek engine. Update the engine in Settings, then choose it.');for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);const result=await tool('strata_install',installArgs(args,false));
/* A chosen drafter that is not on this computer yet is part of the download: its size is listed beside the model's (Control decides; nothing is fetched here). */
const choice=args.drafting??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_gb,line:d.line}};}catch{/* the model plan still stands */}}
const choice=args.drafting??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,model:args.model??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null,...(unloadsRunning(args)?{unloading:true}:{})});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_gb,line:d.line,...(d.needed&&d.tuned===true?{tuned:true}:{})}};}catch{/* the model plan still stands */}}
return result;}
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
@@ -317,22 +323,47 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
return {cfg,resources,visionPreferences,parallelChoice,parallelNote,dsNote};
}
/* One drafter per run. Control (strata_control/drafting.py) says which one this load runs and whether its file is on this computer: the plan of the saved model file with the choice the Load carries. */
async function draftPlan(args,configFile){
/* The engines this Load is about to unload (the load below unloads every instance unless it adds one beside them): their graphics memory is given back before the new engine starts, so Control must not count it as busy memory when it asks whether the all-cards split engine fits (`--engines-unloading`; the Terminal does the same, Runner.card_probe). A model added beside the others (`multiple`) keeps their memory busy. */
function unloadsRunning(args){return instances.size>0&&args?.multiple!==true;}
async function draftPlan(args,configFile,{noSplit=false}={}){
if(!packaged||typeof control.draftingPlan!=='function')return null;
const asked=args.values?.drafting;
try{return await control.draftingPlan({configPath:configFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked})});}catch{return null;}
try{return await withPlanFile(args,configFile,planFile=>control.draftingPlan({configPath:planFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked}),...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{})}));}catch{return null;}
}
/* The plan is for the cards this Load uses: the screen's own card choice goes over the ones the saved settings name (the all-cards split engine needs two or more). `use` gets the file to hand to Control; a temporary one is removed after. */
async function withPlanFile(args,configFile,use){
let planFile=configFile,temporary=null;
if(Array.isArray(args.gpus)&&args.gpus.length&&args.gpus.every(Number.isInteger)){try{const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});temporary=join(work,'drafting-plan-'+randomUUID()+'.json');writeFileSync(temporary,JSON.stringify({...readJson(configFile),gpu:args.gpus.length===1?args.gpus[0]:args.gpus}),{mode:0o600});planFile=temporary;}catch{temporary=null;planFile=configFile;}}
try{return await use(planFile);}
finally{if(temporary){try{rmSync(temporary,{force:true});}catch{/* best effort */}}}
}
/* Auto and its drafter (the tuned one where the set has one, else the standard DFlash2 drafter: whatever Control's Auto plan says is `needed`), on every path that starts a model: a plain load, a restart of the stopped models and a tuning run fetch it as the Models screen's own Load does (Control: pinned, checked, marked, licence and notice), but they were not asked for it, so
the 12 hour rule of the Terminal's launch applies (--throttle: a feed that had nothing a short while ago is not asked again, a feed that does not answer is not an error). A choice the person named or saved (MTP, DFlash2, off) wins: this never runs for it. */
function autoChoice(value){return value==null||String(value).toLowerCase()==='auto';}
async function fetchAutoDrafter(job,args,configFile,drafts,{noSplit=false,after}={}){
if(!packaged||typeof control.draftingFetch!=='function'||drafts?.needed!==true||!autoChoice(readJson(configFile).drafting))return false;
job.engineAbort??=new AbortController();
try{
const got=await withPlanFile(args,configFile,planFile=>control.draftingFetch({configPath:planFile,mode:'auto',throttle:true,...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{}),signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}}));
if(job.state==='running'&&after){job.summary=after;job.details=undefined;emit();}
return got?.pack!=null&&got?.skipped!==true;
}catch(error){if(job.state==='running'){job.drafterNote=error.message;if(after){job.summary=after;job.details=undefined;}emit();}return false;}
}
/* The same for a path that has no Load of its own around it (a restart of stopped models, a tuning run): plan the saved settings file, then fetch. */
async function autoDrafterFor(job,model,after){
try{const {path}=recordFile(model),args={model,multiple:true},drafts=await draftPlan(args,path);return await fetchAutoDrafter(job,args,path,drafts,{after});}catch{return false;}
}
/* The choice changed to the MTP and its layer is not here yet: it is fetched once, by the installer (finished steps are skipped), in this Load. Only the Models screen's own Load carries the choice; a restart or a tuning run never fetches. */
async function installMtpLayer(job,args,config){
const fam=snapshot.catalog?.families?.find(f=>f.sizes.some(s=>s.id===args.model)),size=fam?.sizes.find(s=>s.id===args.model);
if(!fam||!size)return;
job.summary='Downloading the MTP drafter…';emit();
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});}
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}}/* one short line, not the installer's own text; Drafting below says what runs instead */
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});return true;}
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}return false;}/* one short line, not the installer's own text; Drafting below says what runs instead */
}
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
Never stops the load: a drafter that cannot be had leaves the MTP running if its layer is here, else no drafter, and the job says so. The choice itself is remembered in the model's settings file. */
async function prepareDrafter(job,args,cfg,configFile,drafts){
async function prepareDrafter(job,args,cfg,configFile,drafts,{noSplit=false}={}){
const asked=args.values?.drafting;
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
/* Only the Models screen's own Load passes the choice (its dialog has listed the download): a restart, a tuning run or any other load leaves `asked` undefined and never fetches; the model then starts with the drafter that is on this computer and says why. */
@@ -341,7 +372,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
try{
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
job.engineAbort??=new AbortController();
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{}),signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
if(job.state==='running'){job.summary='Loading the model…';job.details=undefined;emit();}
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary=drafts.mtp_here===false?'The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…':'The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
finally{try{rmSync(probe,{force:true});}catch{/* best effort */}}
@@ -349,7 +380,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
async function noteDrafting(job,instance,key){let note='',fellBack=false;try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok){const engine=(await r.json())?.engine;note=String(engine?.drafting_note??'');fellBack=engine?.split_fallback===true;}}catch{/* a convenience */}/* the all-cards split engine: the line of what runs (the server starts the normal layer split by itself when the split engine would not come up) */const lines=[job.drafterNote,fellBack?SPLIT_FELL_BACK:instance.splitNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
/* ---- The picture files of an installed model that has none (strata_control/pictures.py, run by Control) ----
Only the Load or the "Download picture-reading files" button the person pressed reaches the network, and only for the picture file (about 0.9 GB, one copy
shared by the models of a data folder). The model's own files, its pack, its draft layer, the data folder and the settings' data_dir are not touched; the one change
@@ -420,11 +451,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
/* Read pictures is on and the picture files are not here: the Load fetches those files (about 0.9 GB, once) and nothing else of the model, then goes on. No internet: the model loads without pictures and the page says so. */
let picturesNote=null;{const got=await ensurePictures(job,args,configFile,config,{missing:entry.picturesMissing===true});if(job.state!=='running')return;config=got.config;args=got.args;picturesNote=got.note;}
/* One drafter per run (strata_control/drafting.py): switching to the MTP on a model set up for DFlash2 fetches the layer once, here; DFlash2 is fetched below. */
let drafts=await draftPlan(args,configFile);
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);}
let drafts=await draftPlan(args,configFile),noSplit=false;
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);
/* The draft layer for the split could not be had (no internet, disk): the load goes on as the 0.2.10 app did, the normal layer split with the plan DFlash2's again (the Models screen's own Load fetches that drafter below). One plain line says so. */
if(drafts?.split===true&&drafts.mtp_needed===true){noSplit=true;job.drafterNote=job.drafterNote??'The MTP drafter could not be downloaded.';job.summary='Loading the model…';emit();drafts=await draftPlan(args,configFile,{noSplit:true});}}
/* A plain load (the Models screen's own Load carries its choice and fetches below): Auto on a set that has the tuned drafter fetches it now, so the start uses it. Not on this computer and not reachable: the MTP or the stock drafter runs as before. */
if(args.values?.drafting===undefined&&drafts?.needed===true){const got=await fetchAutoDrafter(job,args,configFile,drafts,{noSplit,after:'Loading the model…'});if(job.state!=='running')return;if(got)drafts=await draftPlan(args,configFile,{noSplit});}
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config,drafts?.choice==='dflash2'&&drafts.needed===true&&args.values?.drafting===undefined?'mtp':drafts?.choice??null);/* (a drafter that is not here and will not be fetched by this load: the MTP runs, and the request slots stay) */
let parallelNote=chosenNote;
await prepareDrafter(job,args,cfg,configFile,drafts);if(job.state!=='running')return;
await prepareDrafter(job,args,cfg,configFile,drafts,{noSplit});if(job.state!=='running')return;
const fingerprint=createHash('sha256').update(JSON.stringify({...cfg,api_key:undefined,port:undefined})).digest('hex');
const placement=Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu];const matching=[...instances.values()].find(i=>i.model===args.model&&JSON.stringify(i.gpus)===JSON.stringify(placement));
if(matching?.status==='ready'&&matching.fingerprint===fingerprint&&(args.multiple||instances.size===1))return {summary:`${matching.label} is already ready.`,instanceId:matching.id};
@@ -443,25 +478,43 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
/* Another model on the same graphics card: the engine takes the card's free memory, so a second one usually finds none. Loading goes on (recommend, never force); if it fails, the reason is said. */
const sharedWith=instance.gpus.length?[...instances.values()].find(i=>i.id!==id&&i.status!=='error'&&i.model!==args.model&&i.gpus.some(g=>instance.gpus.includes(g))):null;
const sharedSentence=sharedWith?`GPU ${instance.gpus.find(g=>sharedWith.gpus.includes(g))+1} is already used by ${sharedWith.label}, so this model did not fit there. Load it on another GPU, or unload ${sharedWith.label} first.`:null;
let proc=null,started=null,retried=null,startWait=null;
let proc=null,started=null,retried=null,startWait=null,adopted=false;
if(packaged){
/* How long this start waits, and the server's own engine wait kept above it. The server ends the engine when its engine wait runs
out (900 s, or the config's engine_ready_s), so a start that waits longer than that can never win: as in `strata launch`, the
server gets a wait above the start's. It is only ever raised, never lowered, so a config that already names a longer one
(setup's 3600 for DeepSeek V4.1-Flash) is left alone. */
startWait=startWaitFor(config,entry,args.model);
/* The split engine reads every expert into the cards before it answers, and a start that falls back to the layer split begins again: the 10 minutes the Terminal gives a launch (never above the ceiling). */
if(!noSplit&&drafts?.split===true)startWait=Math.min(startWaitCeiling(),Math.max(startWait,600));
if(startWait+60>Math.max(900,Number(cfg.engine_ready_s)||0))cfg.engine_ready_s=startWait+60;
/* The engine starts under Control's owner runtime, not as our child: a saved profile per slot, then `runtime start`. */
const used=new Set([...instances.values()].map(i=>i.profile));let slot=1;while(used.has('app-'+slot))slot++;instance.profile='app-'+slot;
instances.set(id,instance);if(!defaultId||!args.multiple||wasDefault)defaultId=id;emit();
if(dsNote)append(id,dsNote);
const startWith=value=>{writeFileSync(configPath,JSON.stringify(value,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
/* The all-cards split engine: where the plan says it takes this start, Control turns the run config into the split engine's (the step `strata launch` does itself: the rule, the engine package and the draft layer are checked on this computer) and says so in one line. Any "no" leaves the layer split config as it is. The second try after a failed start is never split. */
const splitStart=async value=>{
if(noSplit||drafts?.split!==true||typeof control.splitEngine!=='function')return value;
const input=join(work,id+'.layer.json'),output=join(work,id+'.split.json');
try{
writeFileSync(input,JSON.stringify(value,null,2),{mode:0o600});rmSync(output,{force:true});
const answer=await control.splitEngine({configPath:input,outPath:output});
if(answer.line){instance.splitNote=answer.line;append(id,answer.line);}
if(answer.split===true&&answer.written)return JSON.parse(readFileSync(output,'utf8'));
}catch{/* the layer split config stands */}
finally{for(const f of [input,output]){try{rmSync(f,{force:true});}catch{/* best effort */}}}
return value;
};
const startWith=async(value,split=true)=>{const run=split?await splitStart(value):value;writeFileSync(configPath,JSON.stringify(run,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
/* A Control whose wait ran out has NOT stopped the engine: `runtime start` answers "startup_wait_expired" (exit 8), or "runtime operation wait expired" when the owner was too busy to answer in time, while the engine is still loading (#5177 #4293). That is not a failure: Control is asked, and a start it still reports as starting (or ready) is kept and watched below instead of being stopped and marked failed. */
const stillLoading=async e=>{if(!/startup_wait_expired|runtime operation wait expired/i.test(String(e?.message??'')+JSON.stringify(e?.output??'')))return false;if(!instance.profile||typeof control.status!=='function'||job.state!=='running')return false;try{const st=await control.status(instance.profile);return st?.state==='starting'||st?.state==='ready';}catch{return false;}};
started=startWith(cfg);
started.then(()=>append(id,'Strata Control reports the engine is ready.'),error=>{append(id,error.message);});
try{await started;}catch(firstError){let error=firstError;
/* A settings, profile or run file an elevated run made can be closed to this user: repair the access in Strata's own folders and start again (this is not the memory try below). */
const isAccess=e=>/PermissionError|access is denied|EACCES/i.test(String(e?.message??'')+JSON.stringify(e?.output??''));
if(isAccess(error)&&typeof control.repairAccess==='function'&&job.state==='running'){await control.repairAccess();append(id,'Strata repaired access to its own files; starting again.');try{await startWith(cfg);started=Promise.resolve();error=null;}catch(again){error=again;}}
if(error&&await stillLoading(error)){adopted=true;error=null;}
if(error&&sharedSentence&&!isAccess(error)){instance.status='error';instance.error=sharedSentence;emit();throw new Error(sharedSentence);}
if(error){const said=humanStartError(error);
if(isAccess(error)){instance.status='error';instance.error=said;emit();throw new Error(said);} // access errors are not memory stops: no safer retry
@@ -476,16 +529,19 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const words=safer?safer.words:FALLBACK_NOTE,detail=[safer?.detail,dropParallel?'one request at a time':null].filter(Boolean).join('; ');
retried={words,detail};if(dropParallel){instance.parallel=1;if(!safer)parallelNote=FALLBACK_NOTE;}
append(id,`The first start stopped (${said}). Starting once more: ${detail}`);job.summary=words;job.details=[said,`Second try: ${detail}`];instance.notice=words;emit();
try{await startWith(retryConfig);append(id,'Strata Control reports the engine is ready (second try).');}
catch(second){const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}
delete instance.splitNote;
try{await startWith(retryConfig,false);append(id,'Strata Control reports the engine is ready (second try).');}
catch(second){if(await stillLoading(second))adopted=true;else{const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}}
} else {
proc=spawnProcess(py,[join(repo(),'serve/server.py'),'--engine','strata','--config',configPath,'--port',String(cfg.port),'--host','127.0.0.1'],{cwd:repo(),detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:sourceServerEnvironment()});
instances.set(id,instance);processes.set(id,proc);if(!defaultId||!args.multiple||wasDefault)defaultId=id;
for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{const lines=chunk.toString().split('\n').filter(Boolean);lines.forEach(line=>append(id,line.replaceAll(key,'[redacted]')));job.summary=lines.at(-1)??job.summary;emit();});
proc.on('error',error=>{instance.status='error';instance.error=error.message;emit();});proc.on('exit',code=>{if(instances.has(id)&&instance.status!=='unloading'){instance.status='error';instance.error=`Engine stopped${code===null?'':` (exit ${code})`}. Open diagnostics or reload.`;emit();}});emit();
}
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
/* A start Control stopped waiting for is watched for as long as the start asked for (the server ends the engine itself when its own wait runs out, a minute after this one), and Control is asked now and then whether the engine is still there. */
const deadline=Date.now()+(adopted?Math.max(300,startWait??300)*1000:300000);let lastAsked=0;while(Date.now()<deadline&&job.state==='running'){
if(instance.status==='error')throw new Error(instance.error);
if(adopted&&instance.profile&&Date.now()-lastAsked>10000){lastAsked=Date.now();const st=await control.status(instance.profile).catch(()=>null);if(st&&['failed','stopped','stale'].includes(st.state)){instance.status='error';instance.error=humanStartError({message:st.error||'server_exited',output:st});emit();throw new Error(instance.error);}}
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||args.contextChosen===true||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}${picturesNote?' '+picturesNote:''}`,instanceId:id};}}catch{}
await new Promise(resolve=>setTimeout(resolve,1200));
}
@@ -496,7 +552,9 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
let pollCount=0;
/* The engine's metrics carry every finished request with `?requests=all`: a long session makes that a large JSON that the host parses and
pushes to the window every 2 s. The last dozen is what the app shows live; the full list is refreshed every tenth poll. */
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';}catch{instance.metrics=null;instance.phase='unavailable';}}));emit();}finally{polling=false;}}
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';instance.misses=0;}catch{instance.metrics=null;instance.phase='unavailable';instance.misses=(instance.misses??0)+1;await reconcileLost(instance);}}));emit();}finally{polling=false;}}
/* The engine of a ready instance stopped answering. After a few looks in a row Control is asked: an owner that says the engine is stopped, failed or lost ends the "ready" the app was still showing (the model looked loaded for good while every request failed, #4973); an engine that is only busy stays as it is. */
async function reconcileLost(instance){if(instance.misses<3||instance.status!=='ready'||!instance.profile||typeof control.status!=='function')return;let st=null;try{st=await control.status(instance.profile);}catch{return;}if(instance.status==='ready'&&['stopped','failed','stale'].includes(st?.state)){instance.status='error';instance.error='Engine stopped. Open diagnostics or reload.';}}
function ensurePoll(){if(!timer){timer=setInterval(()=>void poll(),2000);timer.unref?.();}void poll();}
async function benchmark(job,args){const instance=resolveInstance(args.instanceId);job.instanceId=instance.id;if(busy(requests().filter(r=>r.instanceId===instance.id)))throw new Error('Wait for this model to finish before benchmarking.');const started=Date.now();job.abort=new AbortController();const response=await fetcher(`http://127.0.0.1:${instance.port}/v1/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${instance.key}`},signal:AbortSignal.any([job.abort.signal,AbortSignal.timeout(180000)]),body:JSON.stringify({model:instance.apiModelId,messages:[{role:'user',content:'Explain how a rainbow forms in clear, simple language.'}],temperature:0,reasoning_effort:'none',max_tokens:200,stream:false})});const body=await response.json();if(!response.ok)throw new Error(body.error?.message??'Benchmark failed.');if(job.state!=='running')return;const result={summary:'Measured benchmark complete.',at:new Date().toISOString(),model:instance.apiModelId,durationMs:Date.now()-started,tokensIn:body.usage?.prompt_tokens??null,tokensOut:body.usage?.completion_tokens??null,outputTps:body.timings?.predicted_per_second??null,promptTps:body.timings?.prompt_per_second??null,source:'engine',settings:'Greedy · thinking off · up to 200 output tokens'};writeFileSync(join(directory,'native-benchmark.json'),JSON.stringify(result,null,2),{mode:0o600});return result;}
function resolveInstance(id){const aliases=[...instances.values()].filter(i=>i.aliases?.includes(id));if(aliases.length>1&&!instances.has(id)&&![...instances.values()].some(i=>i.apiModelId===id))throw new Error('This alias matches more than one instance. Choose its exact model ID.');const i=instances.get(id)??[...instances.values()].find(i=>i.apiModelId===id)??aliases[0]??(!id&&instances.size===1?[...instances.values()][0]:undefined);if(!i)throw new Error('Choose a loaded model ID.');if(i.status!=='ready')throw new Error('This model is not ready.');return i;}
@@ -572,6 +630,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
for(const old of offer.instances){
const instance={...old,status:'loading',phase:'loading',error:null,metrics:null,loadedAt:new Date().toISOString()};
instances.set(instance.id,instance);if(!defaultId||!instances.has(defaultId))defaultId=instance.id;job.summary=`Starting ${instance.label}. Loading can take a few minutes.`;emit();
await autoDrafterFor(job,instance.model,`Starting ${instance.label}. Loading can take a few minutes.`);
let wait=960;try{const {config,entry}=record(instance.model);wait=startWaitFor(config,entry,instance.model);}catch{/* the usual wait */}
try{
await control.start(instance.profile,{waitSeconds:wait});
@@ -626,6 +685,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const {path}=tuneConfig(args);
if(externalRunning().length)throw new Error('Stop the native server running outside this UI before tuning.');
if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish requests before tuning.');
await autoDrafterFor(job,args.model,'Finding the fastest settings for your PC…');if(job.state!=='running')return;
for(const id of [...instances.keys()])await unload(id);
if(job.state!=='running')return;
job.summary='Finding the fastest settings for your PC…';emit();
@@ -732,7 +792,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
// Remove nonserializable process references from public jobs.
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,dismissRestart,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),defaultInstanceId:()=>defaultId,close};
return {state:publicState,settingsHints,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,dismissRestart,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),defaultInstanceId:()=>defaultId,close};
}
/* A DeepSeek config on AMD written by setup 0.2.8 or earlier names ONE card and marks it "asked", though setup picked it itself (strata_control/gpu_pick.py): it must not stop the app's recommended cards from applying. A card setup named for itself is the first of the usable cards, largest memory first; another card number was the person's. Roles and a "gpus_by" marker are always honoured. */
export function setupPickedOneCard(config,hardware){
+1 -1
View File
@@ -1,7 +1,7 @@
/** Preserve native protocol events and tool payloads; the gateway owns identity and cancellation. */
import { once } from 'node:events';
export async function nativeGateway({req,res,path,body,control,serving,fetcher=fetch}){
let instance;try{instance=control.resolveInstance(body.model);}catch(e){res.writeHead(400,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'invalid_request_error',param:'model',code:null}}));return;}
let instance;try{instance=control.resolveInstance(body.model);}catch(e){/* #5312: an Anthropic client sends a model name of its own (Claude Desktop: claude-5-5-sonnet) and cannot change it: with exactly one model loaded the route answers with it */if(path==='/v1/messages'&&body.model){try{instance=control.resolveInstance(undefined);}catch{}}if(!instance){res.writeHead(400,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'invalid_request_error',param:'model',code:null}}));return;}}
const abort=new AbortController(),started=Date.now();let sawDone=false;/* #4690: a client that hangs up after `data: [DONE]` (the OpenAI SDK does) has the whole answer: that is not a cancel. */res.on('close',()=>{if(!res.writableEnded&&!sawDone)abort.abort();});
const entry=serving.open({client:req.headers['user-agent']?.slice(0,100)??'API client',endpoint:path.slice(3),model:instance.apiModelId,instanceId:instance.id,gpuIds:instance.gpus.map(String),cancel:()=>abort.abort()});
let tokensIn=null,tokensOut=null,tps=null,promptTps=null;
+6 -2
View File
@@ -101,7 +101,7 @@ export function engineFacts(config = {}) {
* @param {object} [input.values] what the person set for this load (Expert mode's "Concurrent requests")
* @param {object} [input.hardware] the host's hardware read (`gpus[{index, vram_gb}]`)
* @param {number} [input.requested] the default to aim for
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): dflash2 answers one request at a time
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): with dflash2 the app fills in one request at a time unless the person chose a number
* @returns {{slots:number, batchMtp:boolean, explicit:boolean, requested:number, note:string|null, slotGb:number}}
*/
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS, drafter = null }) {
@@ -112,10 +112,14 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
// A number someone chose (including 1) is the person's decision, and so is an explicit --batch in the engine arguments.
if (facts.explicitBatch || (chosen !== null && Number.isFinite(chosen))) {
const slots = facts.explicitBatch ? null : Math.max(1, Math.min(1024, Math.trunc(chosen)));
// DFlash2 chosen by name with a number of requests (owner, 2026-11-03: the person's own choice wins) runs DFlash2 with that many requests, a drafter per request
// (strata_control/drafting.py blocked_by `named`). Expert mode's batch-mtp wish is still added here as in 0.2.10: the server knows the backend and the engine, takes
// --batch-mtp out when DFlash2 runs, and keeps it when the MTP runs instead (Metal, which has no per-request drafters).
const wantMtp = (values.batchMtp ?? config.batchMtp) === true && !facts.batchMtp && facts.mtp && facts.gpus.length === 1 && (slots ?? 0) > 1;
return { slots: slots ?? 0, batchMtp: wantMtp, explicit: true, requested, note: null, slotGb: slot };
}
// DFlash2 chosen on purpose: it answers one request at a time, and request slots would keep the MTP (the engine says "not --batch"). Nobody chose a number here, so one.
// DFlash2 (named, or Auto where the engine has it) and nobody chose a number of requests: one at a time, the app does not fill in slots beside it. (A number the person chose is kept
// above: DFlash2 named runs it with that many requests; Auto keeps the MTP for several.)
if ((drafter ?? ((values.drafting ?? config.drafting) === 'dflash2' ? 'dflash2' : null)) === 'dflash2') return { slots: 1, batchMtp: false, explicit: false, requested, note: DFLASH2_NOTE, slotGb: slot };
const cards = (hardware?.gpus ?? []).filter(g => facts.gpus.includes(g.index)).map(g => Number(g.vram_gb)).filter(n => Number.isFinite(n) && n > 0);
// Without a hardware read nothing can be sized: one at a time is the safe default (a number can still be chosen).
+2
View File
@@ -125,6 +125,8 @@ export const engineVersionOfExe=exe=>{const m=/engine[\\/](\d+(?:\.\d+)+)-/.exec
technical line for Details. The caller tries once and never again, and does not save this config. */
export function saferStart(config,hardware,model,failure){
const text=String(failure??'');
// The engine refused an oversized prompt chunk at the start (an APU whose pool the expert cache fills; the person or the environment named the chunk): the same chunk would fail again, and a memory setting is not the cause.
if(/prompt chunk does not fit/i.test(text))return null;
// A start that died for a reason that is not memory (a missing or bad DLL, a crash, a blocked file) is not helped by a memory setting, and must not be called a memory failure.
if(/0xC0000135|0xC000007B|0xC0000142|0xC000001D|0xC0000005|0xC0000409|0xC0000022|error while loading shared libraries|cannot open shared object file|\bENOENT\b|exit code 127|exit code 139|illegal instruction/i.test(text))return null;
const ranOut=/out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate|not enough memory|bad_alloc|insufficient memory/i.test(text),first=ranOut?'The first start ran out of memory':'The first start did not finish';
+8 -19
View File
@@ -1,5 +1,8 @@
import { validMcpName, MCP_NAME_HELP } from './mcp-names.mjs';
import { SPEED_SWITCH_TABLE, DRAFTING_FAMILIES as GENERATED_DRAFTING_FAMILIES, DFLASH2_MODEL_PATTERN } from './policy-generated.mjs';
/** Editable native keys. Unknown config keys and program paths stay intact. */
/* The Drafting hint, word for word, with the size of the drafter this model would really fetch (the tuned one is 0.34 GB, 0.52 on an Arc card; the stock one 1.2). */
export function draftingHint(gb=1.2){const size=Number.isFinite(Number(gb))&&Number(gb)>0?Number(gb):1.2;return `Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise; on two or more identical graphics cards that the all-cards split engine takes, it is that engine with the MTP draft layer. DFlash2 downloads a ${size} GB drafter once, the MTP its draft layer once. With Auto, several requests at once use the MTP. If you choose DFlash2 yourself, it runs with the number of requests you set. If DFlash2 cannot run, the MTP is used alone and Details says why. Off turns the drafter off.`;}
export const NATIVE_FIELDS=[
{id:'reasoning_budget_tokens',label:'Thinking token budget',group:'Responses',type:'number',min:0,max:1048576,hint:'Leave blank for the model default.'},
{id:'fit_max_tokens',label:'Fit output to remaining context',group:'Responses',type:'boolean',hint:'Shortens the requested output cap when the prompt leaves less room.'},
@@ -9,7 +12,7 @@ export const NATIVE_FIELDS=[
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
{id:'mtp',notForModel:'deepseek',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
{id:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise. DFlash2 downloads a 1.2 GB drafter once, the MTP its draft layer once. If DFlash2 cannot run (several requests at once), the MTP is used alone and Details says why. Off turns the drafter off.'},
{id:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:draftingHint(1.2)},
{id:'batchMtp',notForModel:'deepseek',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
{id:'ds_experts',label:'Speed (DeepSeek)',group:'Serving',type:'enum',choices:['5','6'],forModel:'deepseek',hint:'Faster uses 5 of 6 experts per word: a small quality change, usually not noticeable. Full quality uses all 6. Left alone, the engine picks Faster on a PC whose graphics cards total 16 GB or less.'},
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
@@ -36,22 +39,8 @@ export const ENV_FLAG=/^(?:STRATA|DS4)_[A-Z0-9_]+$/;
* turns it on, and the STRATA_ROUTE_PRIOR=0 that 0.2.7's Off wrote is the default state now. The other two are on by default. The names are the engine switch contract (phaseA-tests/notes-027-plan.md); strata_control/speed_switches.py says the
* same for the Terminal (tests/terminal/test_speed_switches.py compares the two).
*/
export const SPEED_SWITCHES=[
{id:'faster_long_prompts',label:'Faster long prompts',group:'Speed',
hint:'Reads very long prompts faster by approximating part of the work. Off by default: answers about long documents can miss or mix up details.',
default:'off',env:{STRATA_KVA:'1'}},
{id:'faster_12gb_nvidia',label:'Faster answers on 12 GB NVIDIA cards',group:'Speed',
hint:'Prefers the parts of the model already on the graphics card, which makes answers faster on 12 GB cards. Off by default: in long agent sessions it can repeat itself and give weaker results. Turn on if you mostly chat and want the speed.',
default:'off',env:{STRATA_ROUTE_PRIOR:'1.5'}},
{id:'faster_radeon_9000',label:'Faster answers on Radeon RX 9000 / AI PRO R9700',group:'Speed',
hint:'Shares the work between the graphics card and the processor in a way that makes answers faster on these cards. Turn off to go back to the previous setting, which is slower.',
default:'on',env:{STRATA_PCIE_FRAC_DEFAULT:'old'}},
{id:'deepseek_speedups',label:'DeepSeek speed-ups',group:'Speed',
hint:'Reads long prompts faster and loads the parts of the model it needs faster. Turn off if DeepSeek stops working or its answers look wrong; it then runs as in version 0.2.6.',
default:'on',env:{DS4_DEFAULTS:'026',DS4_FAST_ATTN:'0',DS4_ATTN_SLAB:'0',DS4_COPY_PIN:'0',DS4_COPY_HUGETLB:'0',DS4_STORE_SPARE:'0',DS4_STORE_PUT:'0'},
/* an earlier test build wrote only these six for the same state: they read as the switch being set, and go with it */
legacy:{DS4_FAST_ATTN:'0',DS4_ATTN_SLAB:'0',DS4_COPY_PIN:'0',DS4_COPY_HUGETLB:'0',DS4_STORE_SPARE:'0',DS4_STORE_PUT:'0'}},
];
/* The table (ids, words, defaults, environment entries) is generated from strata_control/speed_switches.py into policy-generated.mjs; this file only adds the group the screen lists them under and the rules for where a switch applies (below), which tests/speed-switches.test.mjs and tests/terminal/test_speed_switches.py run against the same cases. */
export const SPEED_SWITCHES=SPEED_SWITCH_TABLE.map(s=>({id:s.id,label:s.label,group:'Speed',hint:s.hint,default:s.default,env:s.env,...(s.legacy?{legacy:s.legacy}:{})}));
const GFX12=/^gfx120[01]$/i, RADEON_9000=/\b(?:RX\s*9\d{3}|AI\s*PRO\s*R97\d{2}|R97\d{2})\b/i;
/**
* Which switches make sense for this model on this PC: only those are shown. `backend` is the engine's (cuda, hip, sycl, metal, cpu). `arch` is the "arch"
@@ -88,8 +77,8 @@ export function speedSwitchRows(env,context){return SPEED_SWITCHES.filter(s=>spe
/** Settings the app itself applies when it starts a model (they are not read from the engine's source, so the engine's text never says it supports them). */
export const APP_OWNED_FIELDS=['mtp','batchMtp','drafting','ds_experts'];
/** The families that have a Drafting choice and the model names the DFlash2 drafter was made for (strata_control/drafting.py SUPPORTED_FAMILIES, _SUPPORTED_NAME): Qwen3.8-Flash-Next, Gyro and Unsloth's quantizations of the same weights; not Swift, Coder or NVFP4. */
export const DRAFTING_FAMILIES=['qwen','gyro','unsloth'];
const DFLASH2_MODEL=/^qwen3\.8-flash-next(-gyro-[a-z0-9._-]+|-unsloth-[a-z0-9._-]+|-(q|iq|tq)\d[a-z0-9_.]*)?$/i;
export const DRAFTING_FAMILIES=GENERATED_DRAFTING_FAMILIES;
const DFLASH2_MODEL=new RegExp(DFLASH2_MODEL_PATTERN,'i');
/** The drafter a choice means (one per run; strata_control/drafting.py choice_of): Auto is DFlash2 where the engine has it (`served`, Control's word) and the model is one it was made for, else the MTP; off and mtp are what they say. `model` is a run config's model name or a family. */
/** `split` (Control's plan: the all-cards split engine will take this start) makes Auto the MTP, the draft layer that engine runs; a DFlash2 the person chose by name stays. */
export function resolveDrafter(choice,model,served,split){
+13
View File
@@ -0,0 +1,13 @@
// GENERATED by `python -m strata_control.app_policy` from strata_control/speed_switches.py and strata_control/drafting.py. Do not edit it by hand:
// change the Python and regenerate; tests/terminal/test_app_policy.py and apps/host/tests/policy-generated.test.mjs fail when this file is out of date.
/** The speed switches of the app's Expert settings (strata_control/speed_switches.py app_switches()): id, words, default, the environment entries outside the default. */
export const SPEED_SWITCH_TABLE = [
{"id": "faster_long_prompts", "label": "Faster long prompts", "hint": "Reads very long prompts faster by approximating part of the work. Off by default: answers about long documents can miss or mix up details.", "default": "off", "env": {"STRATA_KVA": "1"}},
{"id": "faster_12gb_nvidia", "label": "Faster answers on 12 GB NVIDIA cards", "hint": "Prefers the parts of the model already on the graphics card, which makes answers faster on 12 GB cards. Off by default: in long agent sessions it can repeat itself and give weaker results. Turn on if you mostly chat and want the speed.", "default": "off", "env": {"STRATA_ROUTE_PRIOR": "1.5"}},
{"id": "faster_radeon_9000", "label": "Faster answers on Radeon RX 9000 / AI PRO R9700", "hint": "Shares the work between the graphics card and the processor in a way that makes answers faster on these cards. Turn off to go back to the previous setting, which is slower.", "default": "on", "env": {"STRATA_PCIE_FRAC_DEFAULT": "old"}},
{"id": "deepseek_speedups", "label": "DeepSeek speed-ups", "hint": "Reads long prompts faster and loads the parts of the model it needs faster. Turn off if DeepSeek stops working or its answers look wrong; it then runs as in version 0.2.6.", "default": "on", "env": {"DS4_DEFAULTS": "026", "DS4_FAST_ATTN": "0", "DS4_ATTN_SLAB": "0", "DS4_COPY_PIN": "0", "DS4_COPY_HUGETLB": "0", "DS4_STORE_SPARE": "0", "DS4_STORE_PUT": "0"}, "legacy": {"DS4_FAST_ATTN": "0", "DS4_ATTN_SLAB": "0", "DS4_COPY_PIN": "0", "DS4_COPY_HUGETLB": "0", "DS4_STORE_SPARE": "0", "DS4_STORE_PUT": "0"}},
];
/** The families that have a Drafting choice (strata_control/drafting.py SUPPORTED_FAMILIES). */
export const DRAFTING_FAMILIES = ["qwen", "gyro", "unsloth"];
/** The model names the DFlash2 drafter was made for (strata_control/drafting.py _SUPPORTED_NAME), a case-insensitive pattern. */
export const DFLASH2_MODEL_PATTERN = "^qwen3\\.8-flash-next(-gyro-[a-z0-9._-]+|-unsloth-[a-z0-9._-]+|-(q|iq|tq)\\d[a-z0-9_.]*)?$";
+2 -1
View File
@@ -2,9 +2,10 @@
import net from 'node:net';
/** Why a listen failed, in words a person can act on (Windows keeps whole ranges for Hyper-V, WSL and Docker). */
export function listenProblem(error, port, host = '127.0.0.1') {
export function listenProblem(error, port, host = '127.0.0.1', platform = process.platform) {
const code = error?.code;
if (code === 'EADDRINUSE') return `Port ${port} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).`;
if (code === 'EACCES' && platform !== 'win32') return `This system does not let Strata use port ${port}. On Linux and macOS, ports below 1024 need administrator rights. Start Strata with another port (STRATA_PORT), for example 8801.`;
if (code === 'EACCES') return `Windows or a firewall does not let Strata use port ${port}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see "netsh int ipv4 show excludedportrange protocol=tcp"). Start Strata with another port (STRATA_PORT), for example 8801.`;
if (code === 'EADDRNOTAVAIL') return `${host} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.`;
return `Strata cannot listen on ${host}:${port} (${error?.message ?? code}).`;
+10
View File
@@ -24,3 +24,13 @@ export function makePrivate(path, { platform = process.platform } = {}) {
// The owner too, so a file an elevated run made is not left owned by Administrators (needs the right to; best effort).
try { execFileSync(system32('icacls.exe'), [path, '/setowner', `*${currentUserSid()}`], { stdio: 'ignore', timeout: 15000, windowsHide: true }); } catch { /* the explicit entry above already opens it to the user */ }
}
/**
* Owner-only folder. POSIX: mode 0700. Windows: a protected ACL naming only the current user that the files made inside inherit,
* so a file written later (a chat saved every second) is private without a tool run per write.
*/
export function makePrivateDir(path, { platform = process.platform } = {}) {
if (platform !== 'win32') { chmodSync(path, 0o700); return; }
execFileSync(system32('icacls.exe'), [path, '/grant:r', `*${currentUserSid()}:(OI)(CI)F`, '/inheritance:r'],
{ stdio: 'ignore', timeout: 15000, windowsHide: true });
}
+44
View File
@@ -0,0 +1,44 @@
// End a program this host started together with everything that program started (#4529 #4570).
//
// Windows: `taskkill /T` walks the tree. Linux and macOS have no such call, and signalling the child alone leaves its own children running (they are
// re-parented to init). A child started with `detached: ownGroup()` leads a process group of its own, so a signal to the negative pid reaches the
// whole group and nothing else: the group is the ownership record, no other process (a same-named one included) is ever signalled by name.
import { spawnSync } from 'node:child_process';
/** The spawn option that makes a child lead its own process group on Linux and macOS (Windows needs none: taskkill /T). */
export const ownGroup = () => process.platform !== 'win32';
/** Whether the group the child leads still has a member. */
export function groupAlive(child) {
if (process.platform === 'win32' || !child?.pid) return false;
try { process.kill(-child.pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
}
/**
* Signal the child's whole tree once. A child that does not lead its own group (it was not started with ownGroup()) is signalled alone, which is what
* every caller did before.
*/
export function endTree(child, signal = 'SIGTERM') {
if (!child) return;
try {
if (process.platform === 'win32') {
if (child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 10000 });
return;
}
if (child.pid) {
try { process.kill(-child.pid, signal); return; } catch { /* no such group: it did not lead one, or it is gone */ }
}
// A child that never started has no pid. On macOS, kill() on it right after a failed spawn signals the caller's own process group (pid 0), so
// it is left alone: there is nothing to end.
if (child.pid) child.kill(signal);
} catch { /* already gone */ }
}
/** SIGTERM to the tree, then SIGKILL to whatever of its group is still there after `graceMs`. Returns the timer so a caller may clear it. */
export function endTreeThenKill(child, graceMs = 2000) {
endTree(child, 'SIGTERM');
if (process.platform === 'win32' || !child?.pid) return null;
const timer = setTimeout(() => { if (groupAlive(child)) endTree(child, 'SIGKILL'); }, graceMs);
timer.unref?.();
return timer;
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { NATIVE_FIELDS } from './native-settings.mjs';
/** Every top-level key a run config may carry: what setup writes, what serve/server.py reads, the app's own fields. */
export const KNOWN_KEYS = new Set([
...NATIVE_FIELDS.map(f => f.id),
'exe', 'args', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
'exe', 'args', 'args_written', 'user_args', 'gpus_by', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
'gpu_policy', 'hip_ordinal', 'env', 'env_to_user', 'sampling', 'mcp_servers', 'mcpServers', 'before_load', 'before_load_ignore_failure',
'before_load_strict', 'before_load_timeout_s', 'cors_origins', 'trusted_origins', 'allowed_hosts', 'open_browser', 'prevent_sleep',
'max_queue', 'queue_timeout', 'max_tokens', 'min_tokens', 'answer_room', 'threads', 'replicas', 'replica_cpus', 'replica_load_tokens',
+8 -5
View File
@@ -2,7 +2,7 @@
import { randomBytes } from 'node:crypto';
import os from 'node:os';
import { get as getStore, update } from './store.mjs';
import { nowIso, round, uid } from './util.mjs';
import { nowIso, round, sameSecret, uid } from './util.mjs';
const requests = [];
const MAX_REQUESTS = 60;
@@ -85,10 +85,13 @@ export function patch(body) {
export function authorized(req) {
const key = getStore().serving?.apiKey;
if (!key) return true;
const header = req.headers.authorization ?? '';
const bearer = header.startsWith('Bearer ') ? header.slice(7) : '';
const alt = req.headers['x-api-key'];
return bearer === key || alt === key;
const header = req.headers?.authorization;
const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : '';
const alt = req.headers?.['x-api-key'];
// #4535: constant time over fixed-length digests, and both headers are always compared so the time does not show which one was sent.
const viaBearer = sameSecret(bearer, key);
const viaHeader = sameSecret(alt, key);
return viaBearer || viaHeader;
}
export function open({ client, endpoint, tokensIn = null, model, instanceId, gpuIds, cancel }) {
+36
View File
@@ -0,0 +1,36 @@
/**
* A quiet NDJSON answer stays open (#4878). A chat turn can say nothing for minutes (the model reads a long prompt, a tool
* runs, an approval waits), and the desktop's web view gives up on a request that has been silent for 60 seconds ("Load failed")
* while the turn goes on in the background. A beat frame, the same `{"type":"beat"}` the Time-skip stream uses and the chat
* consumer ignores, goes out every 15 seconds. It changes nothing else: a client that goes away still cancels (or detaches
* from) its turn through the response's own close, exactly as before, and the timer ends on finish, on close and on stop().
*/
export const BEAT_MS = 15_000;
const BEAT_LINE = `${JSON.stringify({ type: 'beat' })}\n`;
/** The interval (a tester may shorten it: STRATA_STREAM_BEAT_MS); anything that is not a sensible number is the default. */
export function beatInterval(env = process.env) {
const n = Number(env.STRATA_STREAM_BEAT_MS);
return Number.isFinite(n) && n >= 20 && n <= 60_000 ? n : BEAT_MS;
}
/** Start beating on `res`. Returns stop(), which is safe to call more than once. */
export function keepStreamAlive(res, { ms = beatInterval() } = {}) {
let timer = setInterval(() => {
if (res.writableEnded || res.destroyed) { stop(); return; }
try { res.write(BEAT_LINE); } catch { stop(); }
}, ms);
timer.unref?.();
function stop() {
if (timer === null) return;
clearInterval(timer);
timer = null;
res.off?.('finish', stop);
res.off?.('close', stop);
res.off?.('error', stop);
}
res.once('finish', stop);
res.once('close', stop);
res.once('error', stop);
return stop;
}
+46 -5
View File
@@ -10,8 +10,11 @@
* macOS: none.
*/
import { readFile, readdir } from 'node:fs/promises';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { createPowerShellLoop, endWithHost } from './windows-poll.mjs';
import { stateRoot } from './platform-paths.mjs';
const CPU_SENSORS = new Set(['coretemp', 'k10temp', 'zenpower', 'cpu_thermal', 'cpu-thermal']);
const DISK_SENSORS = new Set(['nvme', 'drivetemp']);
@@ -82,28 +85,66 @@ while($true){
if($c.Count -gt 0){ $max=($c | Measure-Object Temperature -Maximum).Maximum }
else {
if(-not $compiled){ Add-Type -TypeDefinition $cs; $compiled=$true }
foreach($n in 0..15){ $v=[StrataDiskTemp]::Read($n); if($v -gt $max){ $max=$v } }
# only drives Windows names as NVMe, one at a time: a RAID, SATA, USB or unknown drive is never asked (a driver stack that stalls on this query
# froze whole PCs, #4661)
foreach($d in @(Get-PhysicalDisk | Where-Object { $_.BusType -eq 'NVMe' })){ $v=[StrataDiskTemp]::Read([int]$d.DeviceId); if($v -gt $max){ $max=$v } }
}
if($max -gt 0){ [Console]::Out.WriteLine('ssd '+$max) } else { [Console]::Out.WriteLine('ssd -') }
[Console]::Out.Flush()
Start-Sleep -Seconds 30
}`;
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30 } = {}) {
let latest = null;
/** How long the first pass of the reader may take before it is given up for good (a healthy pass takes a second or two). */
export const FIRST_PASS_MS = 45_000;
/** The marker file the reader leaves before it asks the drives and removes after its first answer. */
export const sentinelPath = (env = process.env, platform = process.platform) => join(stateRoot(env, platform), 'drive-temperature.probe');
/**
* `sentinel`: { exists(), write(), remove() }. The reader leaves the marker before it first asks a drive and removes it when the first answer has come. A host
* that is still there with a marker at its next start died, or the PC did, in the middle of the question: the reader stays off from then on (the line is left
* out of the Monitor; deleting the file brings it back). It never asks again after a first pass that took longer than FIRST_PASS_MS either.
*/
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30, sentinel = null, firstPassMs = FIRST_PASS_MS } = {}) {
let latest = null, off = false, startedAt = null;
const loop = createPowerShellLoop({
script: WINDOWS_SCRIPT, spawnFn, maxStarts,
onLine: line => {
const parsed = parseSsdLine(line);
if (parsed === undefined) return false;
latest = { value: parsed, at: now() };
if (sentinel) { try { sentinel.remove(); } catch { /* best effort */ } }
return true;
},
});
return {
read() { loop.start(); return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null; },
read() {
if (off) return null;
if (startedAt === null) {
if (sentinel) {
try { if (sentinel.exists()) { off = true; return null; } sentinel.write(); } catch { off = true; return null; } // cannot leave the marker: do not ask
}
startedAt = now();
} else if (latest === null && now() - startedAt > firstPassMs) {
off = true; // the first pass is stuck: ended, and the marker stays
loop.stop();
return null;
}
loop.start();
return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null;
},
stop() { loop.stop(); },
get starts() { return loop.starts; },
get off() { return off; },
};
}
/** The real marker file under Strata's state folder. */
export function fileSentinel(path = sentinelPath()) {
return {
exists: () => existsSync(path),
write: () => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, String(Date.now())); },
remove: () => rmSync(path, { force: true }),
};
}
@@ -117,7 +158,7 @@ export async function readTemperatures({ platform = process.platform, now = Date
return linuxCache.value;
}
if (platform === 'win32') {
windowsReader ??= endWithHost(createWindowsSsdReader());
windowsReader ??= endWithHost(createWindowsSsdReader({ sentinel: fileSentinel() }));
return { cpuTempC: null, ssdTempC: windowsReader.read() };
}
return { cpuTempC: null, ssdTempC: null };
+4 -6
View File
@@ -17,7 +17,8 @@
* A run is a job, not a request: it outlives the HTTP call that started it. The endpoint returns the
* run id immediately and the client follows it with `/api/timeskip/status`.
*/
import { spawn, spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { endTreeThenKill, ownGroup } from './process-tree.mjs';
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { timeSkipDir } from './platform-paths.mjs';
@@ -146,10 +147,7 @@ export function timeskipRevisionMessage(request) {
/** End a process and everything it started (the job, the Python it runs, the revision command and its Node). */
function killTree(child) {
try {
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
else child.kill('SIGTERM');
} catch { /* already gone */ }
endTreeThenKill(child); // Windows: taskkill /T. Linux and macOS: the job leads its own process group (startDetached), so the group is ended (#4529 #4570)
}
/** How big a folder is, stopping early once it is clearly too big to copy for a trial. */
@@ -452,7 +450,7 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
'--attempts', String(Math.max(0, Math.min(10, Number(attempts) || 0))), '--output', 'json'];
if (reviseCommand) args.push('--revise-command', reviseCommand);
const child = spawn(python, [...pythonArgs, cli, ...args], {
cwd: repoRoot, windowsHide: true, env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
cwd: repoRoot, windowsHide: true, detached: ownGroup(), env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '', err = '', closed = null;
child.stdout.on('data', b => { out += b.toString(); });
+41 -1
View File
@@ -1,5 +1,5 @@
/** Shared helpers for the Strata host. */
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { whichCommand } from './platform-paths.mjs';
@@ -24,6 +24,46 @@ export function error(res, status, message, extra = {}) {
json(res, status, { error: message, ...extra });
}
/**
* Two secrets compared in constant time (#4535). Both sides are hashed first, so the comparison always runs over two
* 32-byte digests: neither the content nor the length of the given value can end it early. Anything that is not a
* non-empty string never matches (an empty secret is "not set", not "equal to an empty header").
*/
export function sameSecret(given, wanted) {
const ok = typeof given === 'string' && typeof wanted === 'string' && given.length > 0 && wanted.length > 0;
const digest = value => createHash('sha256').update(typeof value === 'string' ? value : '', 'utf8').digest();
return timingSafeEqual(digest(given), digest(wanted)) && ok;
}
/**
* The request's URL, or null when the request cannot be read as one (#4397). The Host header is the caller's: an empty
* value, one with a space, a path, credentials or an unclosed bracket used to make `new URL` throw before the routing
* try/catch and end the whole process. A missing Host (HTTP/1.0) is read as localhost; a good one keeps its port and IPv6 form.
*/
export function requestUrl(req) {
const host = req.headers?.host;
try {
let authority = 'localhost';
if (host !== undefined) {
if (typeof host !== 'string' || !host.trim() || /[\s/?#@\\]/.test(host)) return null;
const base = new URL(`http://${host}`);
if (!base.host || base.username || base.password || base.search || base.hash || base.pathname !== '/') return null;
authority = host;
}
return new URL(req.url ?? '/', `http://${authority}`);
} catch {
return null;
}
}
/** The answer to a request that could not be read as a URL: bounded, and the connection closes. */
export function badRequest(res) {
if (res.headersSent) return res.end();
const payload = '{"error":"bad_host"}';
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload), 'cache-control': 'no-store', connection: 'close' });
res.end(payload);
}
export async function readBody(req, limit = 4 * 1024 * 1024) {
const chunks = [];
let size = 0;
+21 -13
View File
@@ -1,7 +1,7 @@
import { tr } from '../lib/i18n';
/** App shell: routes, setup takeover, palette, toasts. */
import { useNative } from '../lib/native';
import { Component, useEffect, useRef, useState, type ReactNode } from 'react';
import { Component, Suspense, useDeferredValue, useEffect, useRef, useState, type ReactNode } from 'react';
import { useSurfaceFocus } from '../lib/focus';
import { closeSettings, useLocation } from '../lib/router';
import { bootOnce, bootstrap, setNavOpen, setFocus, useStore, } from '../lib/store';
@@ -13,14 +13,16 @@ import { MOD, handleAppShortcut, runCommand } from '../lib/commands';
import { useMedia } from '../lib/responsive';
import { Btn, Dialog, IconBtn, Toasts } from '../components/ui';
import { Brand } from '../components/Brand';
import { SetupScreen } from '../screens/Setup';
import { ChatScreen } from '../screens/Chat';
import { ModelRunScreen } from '../screens/ModelRun';
import { ServingScreen } from '../screens/Serving';
import { MonitorScreen } from '../screens/Monitor';
import { TasksScreen } from '../screens/Tasks';
import { SkillsScreen } from '../screens/Skills';
import { SettingsScreen } from '../screens/Settings';
import { lazyScreen, useLoadedModule } from '../lib/lazy-screen';
// The start screen is Chat. Every other screen loads the first time it is opened (7.1), so the first start reads and runs less code.
const loadSetup = () => import('../screens/Setup');
const loadSettings = () => import('../screens/Settings');
const ModelRunScreen = lazyScreen(() => import('../screens/ModelRun'), (m) => m.ModelRunScreen);
const ServingScreen = lazyScreen(() => import('../screens/Serving'), (m) => m.ServingScreen);
const MonitorScreen = lazyScreen(() => import('../screens/Monitor'), (m) => m.MonitorScreen);
const TasksScreen = lazyScreen(() => import('../screens/Tasks'), (m) => m.TasksScreen);
const SkillsScreen = lazyScreen(() => import('../screens/Skills'), (m) => m.SkillsScreen);
function AppShell() {
useStore(s => s.language);
const location = useLocation();
@@ -49,7 +51,13 @@ function AppShell() {
// demanding five steps before anything appears.
const setupOpen = location.route === 'setup' || (!!setup && !setup.complete && !setup.dismissed) || firstRunOpen;
// Hook runs here, after setupOpen exists — see src/lib/focus.ts.
useSurfaceFocus(setupOpen, setupRef);
// The dialogs open once their code has loaded, so they appear and take focus exactly as before.
const setupCode = useLoadedModule(loadSetup, setupOpen);
const setupShown = setupOpen && !!setupCode;
useSurfaceFocus(setupShown, setupRef);
const settingsWanted = location.query.get('settings') === '1';
const settingsCode = useLoadedModule(loadSettings, settingsWanted);
const routeShown = useDeferredValue(location.route);
return (<div className="app" data-rail={compact ? !navOpen : rail} data-layout={mobile ? 'mobile' : compact ? 'compact' : 'desktop'} data-nav-open={navOpen || undefined} data-focus={focus && location.route === 'chat' || undefined}>
<Sidebar containerRef={navRef}/>
{tr(navOpen && compact ? <button className="nav-scrim" tabIndex={-1} aria-hidden="true" aria-label={tr("Close navigation")} onClick={() => setNavOpen(false)}/> : null)}
@@ -62,16 +70,16 @@ function AppShell() {
</div>
<ConnectionBar />
{tr(focus && location.route === 'chat' ? <FocusBar /> : null)}
{tr(!ready ? <BootSkeleton /> : <RouteView route={location.route}/>)}
{tr(!ready ? <BootSkeleton /> : <Suspense fallback={<BootSkeleton />}><RouteView route={routeShown}/></Suspense>)}
</main>
<CommandPalette open={paletteOpen}/>
<Dialog open={location.query.get('settings') === '1'} title={tr("Settings")} className="settings-dialog" onClose={closeSettings}><SettingsScreen /></Dialog>
<Dialog open={settingsWanted && !!settingsCode} title={tr("Settings")} className="settings-dialog" onClose={closeSettings}>{settingsCode ? <settingsCode.SettingsScreen /> : null}</Dialog>
<ShortcutsDialog />
<FolderPathDialog />
{tr(setupOpen ? (<div className="setupmodal" role="dialog" aria-modal="true" aria-label={tr("Set up Strata")}>
{tr(setupShown ? (<div className="setupmodal" role="dialog" aria-modal="true" aria-label={tr("Set up Strata")}>
<div className="scrim" data-open="true" aria-hidden="true"/>
<div className="setupmodal__surface" ref={setupRef}>
<SetupScreen />
{setupCode ? <setupCode.SetupScreen /> : null}
</div>
</div>) : null)}
<Toasts />
+4 -2
View File
@@ -7,6 +7,7 @@ import { TaskTree } from './TaskTree';
import { useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import { FloatingMenu } from '../components/FloatingMenu';
import { measureScale } from '../lib/overlay-place';
import { QuitStrataDialog } from '../components/QuitStrata';
import { useAppMode } from '../lib/app-mode';
import { href, useLocation, type Route } from '../lib/router';
@@ -162,8 +163,9 @@ function RecentChatLink({ session, active, slot }: {
const rect = anchor.current?.getBoundingClientRect();
if (!rect)
return;
const height = tooltip.current?.getBoundingClientRect().height ?? 80;
setPosition({ left: rect.right + 12, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) });
const scale = measureScale();
const height = tooltip.current?.getBoundingClientRect().height ?? 80 * scale;
setPosition({ left: (rect.right + 12) / scale, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) / scale });
};
place();
window.addEventListener('resize', place);
+1 -1
View File
@@ -7,7 +7,7 @@ Original SVGs supplied by Niko in `modelandweights.zip`, 10 October 2026.
The originals retain their metadata and vector geometry. The app sprite strips
metadata and uses their 24 x 24 viewBox, currentColor, 1.5px strokes, and 12% fills.
`scripts/sync-design-kit.mjs` includes these files when rebuilding the kit sprite.
They are already in the checked-in sprite, `public/icons/sprite.svg`, which is the source: a new icon is added there as a `<symbol id="i-NAME">`.
Also supplied by Niko on 10 October 2026:
+12 -6
View File
@@ -1,15 +1,16 @@
import { tr } from '../lib/i18n';
import { PreviewControls } from './PreviewControls';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Dialog, Icon, IconBtn, Seg, type IconName } from './ui';
import { CopyBtn, CopyMenuItem } from './CopyBtn';
import { Markdown } from './Markdown';
import { PreviewMarkdown } from './PreviewMarkdown';
import { SyntaxCode } from './SyntaxCode';
import { FloatingMenu } from './FloatingMenu';
import { TablePreview } from './TablePreview';
import { artifactFilename, artifactIcon, safePreviewUrl, type PreviewArtifact } from '../lib/artifacts';
import { scrollBehavior } from '../lib/responsive';
import { previewVersion } from '../lib/preview-merge';
const PdfPreview = lazy(() => import('./PdfPreview'));
export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab, inline }: {
items: PreviewArtifact[];
@@ -20,17 +21,22 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
inline: boolean;
}) {
const current = items.find(a => a.id === selected);
// What the text loading depends on: the file's identity and its content version, not the object (the chat hands over new objects often).
const version = useMemo(() => current ? previewVersion(current) : '', [current]);
const [text, setText] = useState(''), [error, setError] = useState(''), [mode, setMode] = useState<'preview' | 'source'>('preview'), [revision, setRevision] = useState(0), [loading, setLoading] = useState(false), [imageSize, setImageSize] = useState('fit');
const [toolbarHost, setToolbarHost] = useState<HTMLElement | null>(null);
const [fileMenu, setFileMenu] = useState(false);
const fileMenuRef = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => { setToolbarHost(inline && current ? document.getElementById('preview-file-actions') : null); }, [inline, current?.id]);
// A different file starts in the reading view; the same file with new content keeps the person's view.
useEffect(() => {
setError('');
setMode('preview');
setLoading(false);
setImageSize('fit');
setFileMenu(false);
}, [current?.id]);
useEffect(() => {
setError('');
setLoading(false);
if (!current || !['markdown', 'code', 'table'].includes(current.kind))
return;
if (current.content !== undefined) {
@@ -61,7 +67,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
setLoading(false);
});
return () => abort.abort();
}, [current?.id, revision]);
}, [current?.id, version, revision]);
// A closed panel leaves with a short slide instead of vanishing (a bare shell; the page itself is already gone).
const hadCurrent = useRef(false);
const [leaving, setLeaving] = useState(false);
@@ -84,7 +90,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
const content = <aside className="artifact-panel" aria-label={tr("{0} preview", [current.kind])}>
{tr(!inline ? <ArtifactTabs onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/> : null)}
<div key={current.id} id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><Markdown text={text}/></article>)}
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}-${version}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}-${version}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}-${version}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><PreviewMarkdown text={text}/></article>)}
</div>
{tr(viewControls ? <div className="artifact-view-controls">{tr(viewControls)}</div> : null)}
</aside>;
+15 -6
View File
@@ -10,6 +10,7 @@ import { Icon } from './ui';
*/
export function FamilySwitch({ families, selected, onPick }: { families: CatalogModel[]; selected?: string; onPick: (family: CatalogModel) => void }) {
const shown = families.filter(f => f.sizes.length);
const missing = families.filter(f => !f.sizes.length && f.available === false); // a model of the update feed this engine does not run: said, never offered
const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
if (!shown.length) return null;
@@ -19,12 +20,20 @@ export function FamilySwitch({ families, selected, onPick }: { families: Catalog
<Icon name="models" size={20}/><strong>{tr(current.title)}</strong><Icon name="chevron" size={16} className="family-trigger__chev"/>
</button>
{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Model family")} width={320} onClose={() => setOpen(false)}>
<div className="family-menu">
{shown.map(f => <button key={f.family} type="button" role="menuitemradio" aria-checked={f.family === current.family} className="family-menu__item" onClick={() => { setOpen(false); if (f.family !== current.family) onPick(f); }}>
<Icon name="models" size={18}/><span><strong>{tr(f.title)}</strong>{f.experimental ? <small>{tr("Experimental")}</small> : null}</span>
{f.family === current.family ? <Icon name="check" size={16} className="family-menu__check"/> : null}
</button>)}
</div>
<FamilyMenuList shown={shown} missing={missing} current={current} onPick={f => { setOpen(false); if (f.family !== current.family) onPick(f); }}/>
</FloatingMenu> : null)}
</>;
}
/** The rows of the family menu: the families to pick, then (as notes, not choices) the models of the update feed this engine cannot use, each with the plain reason. */
export function FamilyMenuList({ shown, missing, current, onPick }: { shown: CatalogModel[]; missing: CatalogModel[]; current: CatalogModel; onPick: (family: CatalogModel) => void }) {
return (
<div className="family-menu">
{shown.map(f => <button key={f.family} type="button" role="menuitemradio" aria-checked={f.family === current.family} className="family-menu__item" onClick={() => onPick(f)}>
<Icon name="models" size={18}/><span><strong>{tr(f.title)}</strong>{f.experimental ? <small>{tr("Experimental")}</small> : null}{f.available === false && f.unavailable_reason ? <small>{f.unavailable_reason}</small> : null}</span>
{f.family === current.family ? <Icon name="check" size={16} className="family-menu__check"/> : null}
</button>)}
{missing.map(f => <div key={f.family} className="family-menu__item" role="note" aria-disabled="true"><Icon name="models" size={18}/><span><strong>{f.title}</strong><small>{f.unavailable_reason}</small></span></div>)}
</div>
);
}
+16 -9
View File
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { measureScale, placeAnchored } from '../lib/overlay-place';
export function FloatingMenu({ anchor, title, onClose, children, width = 232 }: {
anchor: HTMLElement | null;
title: string;
@@ -9,22 +10,28 @@ export function FloatingMenu({ anchor, title, onClose, children, width = 232 }:
width?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const [placed, setPlaced] = useState({ left: 12, top: 12 });
const [placed, setPlaced] = useState<{ left: number; top: number; maxWidth?: number; maxHeight?: number }>({ left: 12, top: 12 });
useLayoutEffect(() => {
const rect = anchor?.getBoundingClientRect();
const height = ref.current?.getBoundingClientRect().height ?? 200;
const left = Math.max(12, Math.min(rect?.left ?? 12, window.innerWidth - Math.min(width, window.innerWidth - 24) - 12));
const below = (rect?.bottom ?? 12) + 8; // a little air between the button and its panel
const top = below + height + 12 <= window.innerHeight ? below : Math.max(12, (rect?.top ?? window.innerHeight) - height - 6);
setPlaced({ left, top });
// Placed in visual pixels and written in the page's own pixels (lib/overlay-place.ts): the page may be scaled by "Interface size" (#4653).
const place = () => {
const el = ref.current;
const scale = measureScale();
const rect = anchor?.getBoundingClientRect();
const natural = el ? Math.max(el.getBoundingClientRect().height, el.scrollHeight * scale) : 200 * scale;
const home = { left: 12, top: 12, right: 12, bottom: 12 };
const p = placeAnchored({ anchor: rect ?? home, size: { width: width * scale, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale });
setPlaced({ left: p.left, top: p.top, maxWidth: p.maxWidth, maxHeight: p.maxHeight });
};
place();
ref.current?.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)')?.focus();
const outside = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node))
onClose(); };
document.addEventListener('pointerdown', outside);
return () => { document.removeEventListener('pointerdown', outside); if (anchor?.isConnected)
window.addEventListener('resize', place);
return () => { document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); if (anchor?.isConnected)
anchor.focus({ preventScroll: true }); };
}, [anchor, width]);
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100dvw - 24px)', maxHeight: `calc(100dvh - ${placed.top + 12}px)`, overflowY: 'auto' }} onKeyDown={e => {
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: placed.maxWidth ?? 'calc(100dvw - 24px)', maxHeight: placed.maxHeight ?? 'calc(100dvh - 24px)', overflowY: 'auto' }} onKeyDown={e => {
const nodes = Array.from(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)') ?? []);
const at = nodes.indexOf(document.activeElement as HTMLElement);
if (['Escape', 'Tab'].includes(e.key)) {
+8 -1
View File
@@ -70,7 +70,9 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
imageSession?: string | null;
}) {
const nodes: ReactNode[] = [];
const lines = text.split('\n');
// A file written on Windows has \r\n: the \r stays at the end of every line, where `.` does not match it, so a list line was never taken and the
// list loop below never moved on (an endless loop that filled the page's memory, found in the sidebar preview). Line ends are read as one.
const lines = text.split(/\r\n|\r|\n/);
for (let i = 0; i < lines.length;) {
const line = lines[i];
if (!line.trim()) {
@@ -111,6 +113,11 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
items.push(<li key={i}>{tr(inline(lines[i].replace(re, '$1'), onOpenLink))}</li>);
i++;
}
if (i === key) { // never stand still: a line the list start accepted but the item pattern did not is shown as text and passed
nodes.push(<p key={key}>{line}</p>);
i++;
continue;
}
nodes.push(ordered ? <ol key={key}>{tr(items)}</ol> : <ul key={key}>{tr(items)}</ul>);
continue;
}
+10 -8
View File
@@ -20,7 +20,7 @@ import { NativeDeleteDialog } from './NativeDelete';
import { plainFailureText } from '../lib/plain-errors';
import { api } from '../lib/client';
import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/store';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { installedListPending, nativeAction, useNative, type NativeJob } from '../lib/native';
import { tuneEffective } from '../lib/native-tune';
import { tunedFields } from '../lib/tune-values';
import { shownVision, steadyNoReader } from '../lib/pictures-choice';
@@ -69,7 +69,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
return () => cancelAnimationFrame(frame);
}, [expert]);
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0], selectedSize = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id);
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id), listPending = installedListPending(native);
// The sizes this model offers for the conversation context; it opens at 128K (or its largest size when that is smaller).
const contextList = selectedSize?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144];
// The model that is running is shown as running, with Unload: not offered for loading again.
@@ -129,14 +129,16 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const cardsGpus: number[] = roleCards.length > 1 ? roleOrder(effectiveRoles, roleCards) : [];
const chosenCards = cardsGpus;
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
// How the chosen version sits on this computer (the same rule as the version list): the page says a word only for "SSD streaming" and "Doesn't fit".
// How the chosen version sits on this computer (the same rule as the version list): the page says a word only for "may run slower".
const fitState = selectedSize ? versionFit(selectedSize, fitHardware, { context, kv }) : 'unknown';
// A layer split written for another number of cards no longer fits: back to automatic.
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
// Drafting (which drafter guesses ahead): a choice exists only for Qwen3.8-Flash-Next (the qwen and gyro families) on a computer whose engine can run DFlash2; every other model has no dropdown.
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
// Control's plan says the all-cards split engine takes an Auto start on this computer (it runs the MTP draft layer); it needs two or more cards, so one card chosen in the roles is not it.
const splitCards = native?.capabilities?.dflash2Split === true && cardsGpus.length !== 1;
// The drafter this run loads (one per run): the fit and the plan use its graphics memory (DFlash2 0.89 GB, MTP 1.08 GB).
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, native?.capabilities?.dflash2Split) : 'mtp';
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, splitCards) : 'mtp';
// DFlash2 with the converted pack (the selector as Q4_0 rows) takes 175 MiB less graphics memory: the fit says the true number (Control's `selq4`, strata_control/drafting.py vram_gb).
const fitDrafter = nowDrafter === 'dflash2' && native?.capabilities?.dflash2Selq4 === true ? 'dflash2_selq4' : nowDrafter;
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
@@ -244,12 +246,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<div className="native-model-layout native-model-layout--fit">
<div className="stack">
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily && !listPending ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
{tr(selectedSize ? <>
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" size="sm" variant="ghost" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={selectedFamily?.sizes ?? []} details={expert} selectedId={selectedSize.id} hardware={fitHardware} cache={{ context, kv, drafter: fitDrafter }} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
{tr(fitState === 'ssd' ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("SSD streaming · will run slower")}</p> : fitState === 'no' ? <p className="small muted native-spill-note"><Icon name="warning" size={16}/>{tr("Doesn't fit")}</p> : null)}
{tr(fitState === 'ssd' ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("may run slower")}</p> : null)}
{tr(selectedSize?.multi_file && deepseekContextNote(context) ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(deepseekContextNote(context))}</p> : null)}
<div className="native-choice__row">
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(contextList, context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => { setContextPicked(true); setContext(Number(n)); }}/></div>
@@ -266,12 +268,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<EngineUpdateCard native={native} busy={!!active}/>
<EngineUpdateDialog open={false} onClose={() => {}}/>
{tr(active ? <NativeProgress job={active} label={jobLabel(active)}/> : null)}
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? "Choosing MTP downloads its drafter: about {0} GB, once." : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? (splitCards && drafting === 'auto' ? "Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once." : "Choosing MTP downloads its drafter: about {0} GB, once.") : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}
{tr(error ? <ErrorNotice message={error}/> : null)}
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
<div className="native-model-actions">{tr(!loadedSelected && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
<div className="native-model-actions">{tr(!loadedSelected && !listPending && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={listPending || !installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || listPending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending || listPending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
</section>
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={KV_CHOICES.map(c => ({ value: c.value, label: c.label, title: c.hint }))} onChange={setKv}/>{tr(KV_CHOICES.find(c => c.value === kv)?.hint ? <small className="muted native-kv-hint">{tr(KV_CHOICES.find(c => c.value === kv)!.hint)}</small> : null)}</label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
+4 -1
View File
@@ -28,6 +28,7 @@ export function NativeSettings({ model }: {
}) {
const native = useNative();
const [revision,setRevision]=useState(0);
const [hints, setHints] = useState<Record<string, string>>({}); // a field's hint worded for this model (the Drafting size)
const [switches, setSwitches] = useState<SpeedSwitch[]>([]); // the speed switches that apply to this model on this PC
const [dsExperts, setDsExperts] = useState(false); // the installed DeepSeek engine knows --experts (0.1.50 or newer)
const [tuned, setTuned] = useState<{ preset: { name: string } | null; changed: TunedChange[] } | null>(null);
@@ -44,6 +45,7 @@ export function NativeSettings({ model }: {
values: Record<string, any>;
mcp: Record<string, any>;
tuned?: { preset: { name: string } | null; changed: TunedChange[] } | null;
hints?: Record<string, string>;
capabilities?: { dsExperts?: boolean };
speedSwitches?: SpeedSwitch[];
}>(`/api/native/models/${model}/settings`).then(input => {
@@ -52,6 +54,7 @@ export function NativeSettings({ model }: {
setDsExperts(!!input.capabilities?.dsExperts);
setSwitches(input.speedSwitches ?? []);
setTuned(input.tuned ?? null);
setHints(input.hints ?? {});
const value = nativeSettingsResponse(input);
setValues(value.values);
setText(JSON.stringify(value.values, null, 2));
@@ -120,7 +123,7 @@ export function NativeSettings({ model }: {
const rows = (native?.fields ?? []).filter(f => (!f.forModel || (model.toLowerCase().includes(f.forModel) && (f.id !== 'ds_experts' || dsExperts))) && (!f.notForModel || !model.toLowerCase().includes(f.notForModel)) && (f.id !== 'drafting' || draftingOffered) && `${tr(f.label)} ${f.id} ${tr(f.group)}`.toLowerCase().includes(query.toLowerCase()));
const switchRows = switches.filter(sw => `${tr(sw.label)} ${sw.id} ${tr('Speed')}`.toLowerCase().includes(query.toLowerCase()));
return <details className="native-settings"><summary>{tr("Native engine settings")}</summary>{tr(tuned?.changed?.length ? <div className="native-tuned"><p className="small"><strong>{tr("Your tuning '{0}' sets", [tuned.preset?.name ?? ''])}</strong></p><ul>{tuned.changed.map(c => { const d = describeChange(c); return <li key={c.flag}><span>{d.name}</span><b>{d.now || tr('off')}</b>{d.was !== null ? <small className="muted">{tr("was {0}", [d.was])}</small> : null}</li>; })}</ul></div> : null)}<p className="small muted">{tr(!original ? 'Reading saved settings… ' : '')}{tr("Settings for this model. Blank values use Strata\u2019s defaults.")}</p><div className="editor-tabs" role="tablist" aria-label={tr("Native settings editor")}>{tr(['fields', 'json', 'tools'].map(t => <button key={t} role="tab" aria-selected={tab === t} onClick={() => chooseTab(t)}>{tr(t === 'fields' ? 'Parameters' : t === 'json' ? 'JSON' : 'MCP tools')}</button>))}</div>
{tr(tab === 'fields' ? <><input className="st-input" type="search" aria-label={tr("Search native parameters")} placeholder={tr("Search parameters…")} value={query} onChange={e => setQuery(e.target.value)}/>{tr(['Responses', 'GPU & memory', 'Serving', 'Image reading', 'Tools', 'Recovery'].map(group => rows.some(f => f.group === group) ? <section className="native-settings-group" key={group}><h3 className="h4">{tr(group)}</h3>{tr(rows.filter(f => f.group === group).map(f => <label className="native-setting-row" key={f.id}><span><strong>{tr(f.label)}</strong>{tr(f.supported === false ? <small>{tr("Requires a newer Strata installation.")}</small> : f.hint ? <small>{tr(f.hint)}</small> : null)}</span><NativeFieldInput field={f} model={model} value={values[f.id]} onChange={v => change(f.id, v)}/></label>))}</section> : null))}{tr(switchRows.length ? <section className="native-settings-group"><h3 className="h4">{tr("Speed")}</h3>{tr(switchRows.map(sw => <label className="native-setting-row" key={sw.id}><span><strong>{tr(sw.label)}</strong><small>{tr(sw.hint)}</small></span><Select ariaLabel={tr(sw.label)} value={switchIsOn(sw, values.env) ? 'on' : 'off'} options={sw.default === 'on' ? [{ value: 'on', label: 'On (recommended)' }, { value: 'off', label: 'Off' }] : [{ value: 'off', label: 'Off (recommended)' }, { value: 'on', label: 'On' }]} onChange={v => changeSwitch(sw, v === 'on')}/></label>))}</section> : null)}<section className="native-settings-group"><h3 className="h4">{tr("Sampling")}</h3>{tr([['temperature', 'Temperature'], ['top_p', 'Top-p'], ['top_k', 'Top-k'], ['min_p', 'Min-p'], ['repetition_penalty', 'Repetition penalty'], ['presence_penalty', 'Presence penalty'], ['frequency_penalty', 'Frequency penalty'], ['penalty_last_n', 'Penalty window'], ['seed', 'Seed']].map(([id, label]) => <label className="native-setting-row" key={id}><span><strong>{tr(label)}</strong></span><DecimalInput ariaLabel={tr("Native {0}", [label])} value={values.sampling?.[id] ?? null} onChange={n => { const sampling = { ...values.sampling }; if (n === null) delete sampling[id]; else sampling[id] = n; change('sampling', sampling); }}/></label>))}</section></> : tab === 'json' ? <><p className="small muted">{tr("Engine arguments and STRATA environment flags are editable here. Program paths and network access remain managed by the host.")}</p><textarea className="st-input native-json" aria-label={tr("Native configuration JSON")} spellCheck={false} value={text} onChange={e => { setText(e.target.value); setNotice(''); }}/></> : <><p className="small muted">{tr("Add local programs or HTTP MCP servers. Connected tools can read or change the resources you give them access to. Enable tools in chat when you want the model to use them.")}</p><NativeMcpEditor text={mcp} onChange={value => { setMcp(value); setNotice(''); }}/><details><summary className="small">{tr("Configuration example")}</summary><pre className="native-example">{'{\n "files": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/your/folder"],\n "disabled": false\n }\n}'}</pre><p className="xs muted">{tr("Saved credentials are masked. Keep the mask to preserve an existing value; type a new value to replace it.")}</p></details></>)}
{tr(tab === 'fields' ? <><input className="st-input" type="search" aria-label={tr("Search native parameters")} placeholder={tr("Search parameters…")} value={query} onChange={e => setQuery(e.target.value)}/>{tr(['Responses', 'GPU & memory', 'Serving', 'Image reading', 'Tools', 'Recovery'].map(group => rows.some(f => f.group === group) ? <section className="native-settings-group" key={group}><h3 className="h4">{tr(group)}</h3>{tr(rows.filter(f => f.group === group).map(f => <label className="native-setting-row" key={f.id}><span><strong>{tr(f.label)}</strong>{tr(f.supported === false ? <small>{tr("Requires a newer Strata installation.")}</small> : f.hint ? <small>{tr(hints[f.id] ?? f.hint)}</small> : null)}</span><NativeFieldInput field={f} model={model} value={values[f.id]} onChange={v => change(f.id, v)}/></label>))}</section> : null))}{tr(switchRows.length ? <section className="native-settings-group"><h3 className="h4">{tr("Speed")}</h3>{tr(switchRows.map(sw => <label className="native-setting-row" key={sw.id}><span><strong>{tr(sw.label)}</strong><small>{tr(sw.hint)}</small></span><Select ariaLabel={tr(sw.label)} value={switchIsOn(sw, values.env) ? 'on' : 'off'} options={sw.default === 'on' ? [{ value: 'on', label: 'On (recommended)' }, { value: 'off', label: 'Off' }] : [{ value: 'off', label: 'Off (recommended)' }, { value: 'on', label: 'On' }]} onChange={v => changeSwitch(sw, v === 'on')}/></label>))}</section> : null)}<section className="native-settings-group"><h3 className="h4">{tr("Sampling")}</h3>{tr([['temperature', 'Temperature'], ['top_p', 'Top-p'], ['top_k', 'Top-k'], ['min_p', 'Min-p'], ['repetition_penalty', 'Repetition penalty'], ['presence_penalty', 'Presence penalty'], ['frequency_penalty', 'Frequency penalty'], ['penalty_last_n', 'Penalty window'], ['seed', 'Seed']].map(([id, label]) => <label className="native-setting-row" key={id}><span><strong>{tr(label)}</strong></span><DecimalInput ariaLabel={tr("Native {0}", [label])} value={values.sampling?.[id] ?? null} onChange={n => { const sampling = { ...values.sampling }; if (n === null) delete sampling[id]; else sampling[id] = n; change('sampling', sampling); }}/></label>))}</section></> : tab === 'json' ? <><p className="small muted">{tr("Engine arguments and STRATA environment flags are editable here. Program paths and network access remain managed by the host.")}</p><textarea className="st-input native-json" aria-label={tr("Native configuration JSON")} spellCheck={false} value={text} onChange={e => { setText(e.target.value); setNotice(''); }}/></> : <><p className="small muted">{tr("Add local programs or HTTP MCP servers. Connected tools can read or change the resources you give them access to. Enable tools in chat when you want the model to use them.")}</p><NativeMcpEditor text={mcp} onChange={value => { setMcp(value); setNotice(''); }}/><details><summary className="small">{tr("Configuration example")}</summary><pre className="native-example">{'{\n "files": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/your/folder"],\n "disabled": false\n }\n}'}</pre><p className="xs muted">{tr("Saved credentials are masked. Keep the mask to preserve an existing value; type a new value to replace it.")}</p></details></>)}
<div className="native-settings-actions"><Btn disabled={pending || !original || !dirty} variant="primary" onClick={() => void save()}>{tr(pending ? 'Saving…' : 'Save settings')}</Btn><Btn disabled={pending} onClick={() => { const saved = JSON.parse(original || '{"values":{},"mcp":{}}'); setValues(saved.values); setText(JSON.stringify(saved.values, null, 2)); setMcp(JSON.stringify(saved.mcp, null, 2)); setError(''); }}>{tr("Restore saved")}</Btn></div>{tr(notice ? <p role="status" className="small">{tr(notice)}</p> : null)}{tr(error ? <ErrorNotice message={error} onRetry={() => setRevision(value => value + 1)}/> : null)}
</details>;
}
+1 -1
View File
@@ -21,7 +21,7 @@ function FreeMeter({ label, total, free }: { label: string; total: number; free:
* "This computer": how much graphics memory and RAM is free right now. While a model is loaded that is all the card shows: the hardware line, the model's
* name, "Loaded", what it takes and the SSD line are gone (owner, 11-01: the page already says what is loaded); only while the loaded model really reads
* from the SSD, how fast it reads right now. Before anything is loaded the card says what the chosen version would take. That a version does not fit in
* memory is said once, under the version picker ("SSD streaming · will run slower"), not again here. (The n-gram table, which always sits on the SSD, is not counted.)
* memory is said once, under the version picker ("may run slower"), not again here. (The n-gram table, which always sits on the SSD, is not counted.)
*/
export function PcFitCard({ gpuName, ramGb, vramGb, fit, inUse = null, diskGb = null, live = null, loaded = [], selectedLoaded = false }: { gpuName?: string | null; ramGb: number | null; vramGb: number; fit: Fit | null; inUse?: { ramGb: number; vramGb: number } | null; diskGb?: number | null; live?: PcLive | null; loaded?: PcLoaded[]; selectedLoaded?: boolean }) {
const showFit = !!fit?.known && !selectedLoaded;
+5 -2
View File
@@ -2,6 +2,7 @@ import { tr } from '../lib/i18n';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { IconBtn } from './ui';
import { measureScale } from '../lib/overlay-place';
/** Anchored, non-modal surface. Focus and dismissal stay with its own trigger. */
export function Popover({ anchor, title, detail, onClose, children, above = false, width = 360 }: {
anchor: HTMLElement | null;
@@ -15,8 +16,10 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
}) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
const [maxWidth, setMaxWidth] = useState<number | string>('calc(100vw - 24px)');
useLayoutEffect(() => {
const place = () => { const r = anchor?.getBoundingClientRect(); const h = ref.current?.offsetHeight ?? 200; const w = Math.min(width, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)), top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) }); };
// The numbers from the anchor are visual pixels and the numbers written on the panel are the page's own: they differ when the page is scaled (#4653).
const place = () => { const scale = measureScale(); const r = anchor?.getBoundingClientRect(); const h = (ref.current?.offsetHeight ?? 200) * scale; const w = Math.min(width * scale, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)) / scale, top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) / scale }); setMaxWidth((window.innerWidth - 24) / scale); };
place();
ref.current?.focus({ preventScroll: true });
const outside = (e: PointerEvent) => {
@@ -31,7 +34,7 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
return () => { resize.disconnect(); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); };
}, [anchor, width, above]);
function close() { onClose(); anchor?.focus({ preventScroll: true }); }
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100vw - 24px)' }} onKeyDown={e => {
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth }} onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
@@ -0,0 +1,36 @@
/**
* The Markdown preview of the sidebar, bounded (#5180). Same elements as <Markdown>, but:
* - memoised by the text, so a chat that renders for every group of streamed tokens does not rebuild an unchanged file;
* - drawn in slices (lib/preview-render.ts) that are memoised one by one, so a file changed in one place re-renders that slice;
* - only a few slices at a time, the rest added by one waiting step that a newer text or leaving the view cancels;
* - a block too large to parse, and a file above the limit, shown as plain text.
*/
import { memo, useEffect, useMemo, useState } from 'react';
import { Markdown } from './Markdown';
import { SyntaxCode } from './SyntaxCode';
import { previewSlices, previewTier, slicesAfter, slicesAtStart, textRevision } from '../lib/preview-render';
const Slice = memo(function Slice({ text, plain }: { text: string; plain: boolean }) {
return plain ? <SyntaxCode text={text}/> : <Markdown text={text}/>;
});
export const PreviewMarkdown = memo(function PreviewMarkdown({ text }: { text: string }) {
if (previewTier(text) === 'plain')
return <SyntaxCode text={text}/>;
return <Sliced text={text}/>;
});
function Sliced({ text }: { text: string }) {
const slices = useMemo(() => previewSlices(text), [text]);
const revision = useMemo(() => textRevision(text), [text]);
const [shown, setShown] = useState(() => slicesAtStart(0, slices.length));
// one step waiting at most; a new revision or leaving the view clears it, so work for an old text never runs
useEffect(() => {
if (shown >= slices.length)
return;
const timer = setTimeout(() => setShown(n => slicesAfter(n, slices.length)), 24);
return () => clearTimeout(timer);
}, [revision, shown, slices.length]);
const count = Math.min(Math.max(shown, slicesAtStart(0, slices.length)), slices.length);
return <>{slices.slice(0, count).map(s => <Slice key={s.key} text={s.text} plain={s.plain}/>)}</>;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { tr } from '../lib/i18n';
/** What Strata collects and where things go: one compact paragraph (the six sentences read together, translated as one in every language). */
const NOTES = 'Strata sends no usage telemetry. Conversations stay in browser storage. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.';
const NOTES = "Strata sends no usage telemetry. Conversations stay on this computer, in Strata's data folder. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.";
export function PrivacyNotes() {
return <div className="privacy-notes" aria-label={tr("Collection and logs")}>
+1 -1
View File
@@ -88,7 +88,7 @@ export function SettingsUpdates() {
</>;
}
/** "Strata 0.2.10 · Engine 0.1.56": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.13 · Engine 0.1.59": the app's own version first, the engine's beside it (About). */
export function AboutVersions() {
const native = useNative();
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
+15 -3
View File
@@ -1,7 +1,19 @@
import { useMemo } from 'react';
import { codeLanguage, highlightedLines } from '../lib/syntax';
import { useEffect, useMemo, useState } from 'react';
import { codeLanguage } from '../lib/code-language';
import type { CodeToken } from '../lib/syntax';
/** The Prism grammars are a separate file (src/lib/syntax.ts), fetched after the first screen is up (App.tsx asks for it a few seconds in) or by the first code block, whichever comes first. Until it is here a block shows its text uncoloured. */
type Highlighter = typeof import('../lib/syntax');
let loaded: Highlighter | null = null;
let loading: Promise<Highlighter | null> | null = null;
export function loadHighlighter(): Promise<Highlighter | null> {
loading ??= import('../lib/syntax').then(module => (loaded = module), () => { loading = null; return null; });
return loading;
}
const plainLines = (text: string): CodeToken[][] => text.split('\n').map(part => (part ? [{ text: part, classes: [] }] : []));
export function SyntaxCode({text,language='',filename,lineNumbers=false,className=''}:{text:string;language?:string;filename?:string;lineNumbers?:boolean;className?:string}){
const resolved=filename?codeLanguage(filename,true):codeLanguage(language);
const lines=useMemo(()=>highlightedLines(text,resolved),[text,resolved]);
const [highlighter,setHighlighter]=useState<Highlighter|null>(loaded);
useEffect(()=>{if(highlighter)return;let live=true;void loadHighlighter().then(module=>{if(live&&module)setHighlighter(module);});return()=>{live=false;};},[highlighter]);
const lines=useMemo(()=>highlighter?highlighter.highlightedLines(text,resolved):plainLines(text),[highlighter,text,resolved]);
return <pre className={`syntax-code ${lineNumbers?'code-preview':''} ${className}`} data-language={resolved}><code>{lines.map((line,i)=><span className="syntax-line" key={i}>{lineNumbers?<span className="syntax-gutter" aria-hidden="true">{i+1}</span>:null}<span className="syntax-text">{line.map((part,j)=>part.classes.length?<span className={part.classes.map(c=>`syntax-${c}`).join(' ')} key={j}>{part.text}</span>:part.text)}{!lineNumbers&&i<lines.length-1?'\n':''}</span></span>)}</code></pre>;
}
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { measureScale } from '../lib/overlay-place';
export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLeave }: {
id: string;
anchor: HTMLElement;
@@ -12,11 +13,12 @@ export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLea
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
useLayoutEffect(() => {
const rect = anchor.getBoundingClientRect(), width = Math.min(260, window.innerWidth - 24);
const height = ref.current?.getBoundingClientRect().height ?? 80;
const scale = measureScale();
const rect = anchor.getBoundingClientRect(), width = Math.min(260 * scale, window.innerWidth - 24);
const height = ref.current?.getBoundingClientRect().height ?? 80 * scale;
const left = Math.max(12, Math.min(rect.right - width, window.innerWidth - width - 12));
const top = rect.bottom + height + 20 <= window.innerHeight ? rect.bottom + 8 : Math.max(12, rect.top - height - 8);
setPosition({ left, top });
setPosition({ left: left / scale, top: top / scale });
}, [anchor, text]);
useEffect(() => {
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') {
+8 -9
View File
@@ -26,9 +26,9 @@ const whole = (n: number) => `${Math.round(n)} GB`;
/**
* Where the versions of the chosen base model are listed, each exactly once. At the top: the versions we recommend (the pick for this PC first; no "Recommended" tag since 11-02, the order says it) and the versions already downloaded (tagged "Downloaded"). Below, folded under "More
* versions": all the others. A row is the name, small tags, a word only where one is needed ("SSD streaming · will run slower" for a version that does
* not fully fit in memory, "Doesn't fit" where it cannot run at all; a version that fits says nothing: see `versionFit`) and the size. The maker, the
* memory it needs and the SSD figure are behind "Show details" (open from the start in Expert mode). The other base models are not in this list: they
* versions": all the others. A row is the name, small tags, a word only where one is needed ("may run slower" for a version that does
* not fit in memory; a version that fits says nothing: see `versionFit`) and the size. The maker and the
* memory it needs are behind "Show details" (open from the start in Expert mode). The other base models are not in this list: they
* appear when picked at the top of the page.
*/
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf, downloaded, showRecommended = true, moreLabel, pick, cache, details: detailsOpen = false }: {
@@ -67,21 +67,20 @@ export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnG
const foldedOpen = moreOpen || !top.length || more.some(v => v.id === selectedId);
const need = (v: CatalogVersion, technical: boolean) => {
const fit = fitOn(v, hardware, true, cache);
if (technical) return v.multi_file && v.disk_gb ? tr("{0} RAM + VRAM, plus about {1} GB of disk", [gb(fit.memoryGb), Math.round(v.disk_gb)]) : tr("{0} RAM + VRAM", [gb(fit.memoryGb)]);
return v.multi_file && v.disk_gb ? tr("Needs {0} of memory and about {1} GB of disk", [whole(fit.memoryGb), Math.round(v.disk_gb)]) : tr("Needs {0} of memory", [whole(fit.memoryGb)]);
const size = typeof v.fit_gb === 'number' && Number.isFinite(v.fit_gb) ? v.fit_gb : fitOn(v, hardware, true, cache).memoryGb; // the files without the lookup table: the figure the label uses
if (technical) return v.multi_file && v.disk_gb ? tr("{0} RAM + VRAM, plus about {1} GB of disk", [gb(size), Math.round(v.disk_gb)]) : tr("{0} RAM + VRAM", [gb(size)]);
return v.multi_file && v.disk_gb ? tr("Needs {0} of memory and about {1} GB of disk", [whole(size), Math.round(v.disk_gb)]) : tr("Needs {0} of memory", [whole(size)]);
};
const row = (v: CatalogVersion) => {
const fit = fitOn(v, hardware, true, cache);
const on = v.id === selectedId, have = !!downloaded?.has(v.id);
const state = versionFit(v, hardware, cache);
const technical = [MAKERS[v.sourceFamily], need(v, true), state === 'ssd' ? tr("Uses SSD {0} GB", [Math.round(fit.spillGb)]) : ''].filter(Boolean).join(' · ');
const technical = [MAKERS[v.sourceFamily], need(v, true)].filter(Boolean).join(' · ');
const note = versionNote(v.model);
return <button key={v.id} role="menuitemradio" aria-checked={on} className={cx('version-row', on && 'is-on')} onClick={() => { onSelect(v.id); setOpen(false); }}>
<span className="version-row__check">{tr(on ? <Icon name="check" size={14}/> : null)}</span>
<span className="version-row__body">
<span className="version-row__title"><strong>{versionName(v)}</strong>{tr(have ? <span className="version-row__tags"><span className="version-tag version-tag--have">{tr("Downloaded")}</span></span> : null)}</span>
{tr(state === 'ssd' || state === 'no' ? <span className="version-row__fit" data-fit={state}>{tr(state === 'ssd' ? 'SSD streaming · will run slower' : "Doesn't fit")}</span> : null)}
{tr(state === 'ssd' ? <span className="version-row__fit" data-fit={state}>{tr('may run slower')}</span> : null)}
{tr(details ? <span className="version-row__tech">{technical}{tr(note ? <><br/>{note}</> : null)}</span> : null)}
</span>
<span className="version-row__size">{have ? gb(v.download_gb) : tr("{0} download", [gb(v.download_gb)])}</span>
+37 -22
View File
@@ -7,12 +7,13 @@ import { tr } from '../lib/i18n';
* the same system. Anything added here is a documented extension that only uses
* kit tokens — see src/styles/app.css.
*/
import { createContext, useContext, useCallback, useEffect, useId, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
import { createContext, useContext, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cx, userError } from '../lib/format';
import { plainFailure } from '../lib/plain-errors';
import { dismissActionError, dismissToast, holdToast, releaseToast, useStore } from '../lib/store';
import { isTopSurface, useSurfaceFocus } from '../lib/focus';
import { measureScale, placeAnchored } from '../lib/overlay-place';
/* -------------------------------------------------------------------- icon */
export type IconName = 'pause' | 'play' | 'activity' | 'plus' | 'shield' | 'computer'
// Authored for the shell: see docs/design-system-notes.md.
@@ -248,7 +249,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
left: number;
top: number;
width: number;
up: boolean;
maxHeight: number;
} | null>(null);
const [cursor, setCursor] = useState(0);
const [typed, setTyped] = useState('');
@@ -260,34 +261,47 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
if (refocus)
triggerRef.current?.focus();
}, []);
const openMenu = useCallback(() => {
/* Where the list goes, in screen pixels, written in the page's own pixels (lib/overlay-place.ts; the page may be scaled by "Interface size", #4653):
below its button when the whole list fits there, else above, else the side with more room; as tall as the list needs, and no taller than
the room (it then scrolls, using all of it). It used to be capped at 320 px whatever the window, and opened upward by a transform that
sent it off the top of a short window. `measured` is the rendered popup, when there is one. */
const placeList = useCallback((measured: HTMLElement | null) => {
const el = triggerRef.current;
if (!el)
return;
return null;
const scale = measureScale();
const r = el.getBoundingClientRect();
const height = Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
const below = window.innerHeight - r.bottom;
// Flip above the trigger when there is more room there than below.
const up = below < height + 12 && r.top > below;
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
const width = Math.min((menuWidth ?? Math.max(r.width / scale, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180))) * scale, window.innerWidth - 24);
const natural = measured
? measured.scrollHeight * scale + (measured.getBoundingClientRect().height - measured.clientHeight * scale)
: (options.length * (menuSize === 'comfortable' ? 52 : 38) + 12) * scale;
const p = placeAnchored({ anchor: r, size: { width, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale, gapBelow: 6, gapAbove: 6 });
return { left: p.left, top: p.top, width: width / scale, maxHeight: p.maxHeight };
}, [options.length, menuSize, menuMinWidth, menuWidth]);
const openMenu = useCallback(() => {
const next = placeList(null);
if (!next)
return;
setPlaced(next);
setCursor(Math.max(0, options.findIndex((o) => o.value === value)));
setOpen(true);
}, [options, value, menuSize, menuMinWidth, menuWidth]);
}, [options, value, placeList]);
// Once the list is on the screen its real height is known: place it again with that.
useLayoutEffect(() => {
if (!open)
return;
const next = placeList(popupRef.current);
if (next)
setPlaced(next);
}, [open, placeList]);
// The popup is fixed-positioned, so it has to follow its trigger.
useEffect(() => {
if (!open)
return undefined;
const reposition = () => {
const el = triggerRef.current;
if (!el)
return;
const r = el.getBoundingClientRect();
const height = popupRef.current?.offsetHeight ?? Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
const below = window.innerHeight - r.bottom;
const up = below < height + 12 && r.top > below;
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
const next = placeList(popupRef.current);
if (next)
setPlaced(next);
};
const onDocDown = (e: PointerEvent) => {
if (!triggerRef.current?.contains(e.target as Node) && !popupRef.current?.contains(e.target as Node))
@@ -301,7 +315,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
window.removeEventListener('resize', reposition);
document.removeEventListener('pointerdown', onDocDown, true);
};
}, [open, options.length, close, menuSize, menuMinWidth, menuWidth]);
}, [open, close, placeList]);
useEffect(() => {
if (open)
document.getElementById(`${listId}-${cursor}`)?.scrollIntoView({ block: 'nearest' });
@@ -368,7 +382,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
<Icon name="chevron" size={16}/>
</button>
{tr(open && placed
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} data-up={placed.up || undefined} style={{ left: placed.left, top: placed.top, width: placed.width, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} style={{ left: placed.left, top: placed.top, width: placed.width, maxHeight: placed.maxHeight, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
{tr(options.map((o, i) => (<div key={o.value} id={`${listId}-${i}`} role="option" aria-selected={o.value === value} aria-disabled={o.disabled || undefined} tabIndex={-1} className="select__opt" data-active={i === cursor || undefined} onMouseEnter={() => setCursor(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => {
if (o.disabled)
return;
@@ -469,6 +483,7 @@ export function Toasts() {
<div style={{ flex: 1, minWidth: 0 }}>
<div className="st-toast__title">{tr(t.title)}</div>
{tr(t.body ? <div className="st-toast__body">{tr(t.body)}</div> : null)}
{tr(t.action ? <button type="button" className="st-btn st-btn--sm" style={{ marginTop: 8 }} onClick={t.action.run}>{tr(t.action.label)}</button> : null)}
</div>
<button type="button" className="st-btn st-btn--icon st-btn--sm" onClick={() => dismissToast(t.id)} aria-label={tr("Dismiss")}>
<Icon name="chevron" size={15} style={{ transform: 'rotate(90deg)' }}/>
+5 -1
View File
@@ -1,4 +1,8 @@
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean; }
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean;
/** The host's version of the file this was read from (modified time and size), for a preview with an address instead of text. */
version?: string;
/** When this copy was read or made (ms): a message's copy never replaces a later one (lib/preview-merge.ts). */
at?: number; }
export const PREVIEW_EXAMPLES: PreviewArtifact[] = [
{ id:'sample-website', kind:'website', title:'Fieldnotes — website', url:'/examples/fieldnotes.html', demo:true },
{ id:'sample-markdown', kind:'markdown', title:'project-brief.md', url:'/examples/project-brief.md', demo:true },
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (de): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/de.json';
import tune from '../locales/tune-de.json';
import tools from '../locales/tools-de.json';
import fix from '../locales/fix-de.json';
import gh from '../locales/gh-de.json';
import ui from '../locales/ui-de.json';
import engine from '../locales/engine-de.json';
import pick from '../locales/pick-de.json';
import df2 from '../locales/df2-de.json';
import upd from '../locales/upd-de.json';
import r3 from '../locales/r3-de.json';
import pic from '../locales/pic-de.json';
import gui from '../locales/gui-de.json';
import sav from '../locales/sav-de.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (es): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/es.json';
import tune from '../locales/tune-es.json';
import tools from '../locales/tools-es.json';
import fix from '../locales/fix-es.json';
import gh from '../locales/gh-es.json';
import ui from '../locales/ui-es.json';
import engine from '../locales/engine-es.json';
import pick from '../locales/pick-es.json';
import df2 from '../locales/df2-es.json';
import upd from '../locales/upd-es.json';
import r3 from '../locales/r3-es.json';
import pic from '../locales/pic-es.json';
import gui from '../locales/gui-es.json';
import sav from '../locales/sav-es.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (fr): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/fr.json';
import tune from '../locales/tune-fr.json';
import tools from '../locales/tools-fr.json';
import fix from '../locales/fix-fr.json';
import gh from '../locales/gh-fr.json';
import ui from '../locales/ui-fr.json';
import engine from '../locales/engine-fr.json';
import pick from '../locales/pick-fr.json';
import df2 from '../locales/df2-fr.json';
import upd from '../locales/upd-fr.json';
import r3 from '../locales/r3-fr.json';
import pic from '../locales/pic-fr.json';
import gui from '../locales/gui-fr.json';
import sav from '../locales/sav-fr.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (ja): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/ja.json';
import tune from '../locales/tune-ja.json';
import tools from '../locales/tools-ja.json';
import fix from '../locales/fix-ja.json';
import gh from '../locales/gh-ja.json';
import ui from '../locales/ui-ja.json';
import engine from '../locales/engine-ja.json';
import pick from '../locales/pick-ja.json';
import df2 from '../locales/df2-ja.json';
import upd from '../locales/upd-ja.json';
import r3 from '../locales/r3-ja.json';
import pic from '../locales/pic-ja.json';
import gui from '../locales/gui-ja.json';
import sav from '../locales/sav-ja.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (ko): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/ko.json';
import tune from '../locales/tune-ko.json';
import tools from '../locales/tools-ko.json';
import fix from '../locales/fix-ko.json';
import gh from '../locales/gh-ko.json';
import ui from '../locales/ui-ko.json';
import engine from '../locales/engine-ko.json';
import pick from '../locales/pick-ko.json';
import df2 from '../locales/df2-ko.json';
import upd from '../locales/upd-ko.json';
import r3 from '../locales/r3-ko.json';
import pic from '../locales/pic-ko.json';
import gui from '../locales/gui-ko.json';
import sav from '../locales/sav-ko.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (pt): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/pt.json';
import tune from '../locales/tune-pt.json';
import tools from '../locales/tools-pt.json';
import fix from '../locales/fix-pt.json';
import gh from '../locales/gh-pt.json';
import ui from '../locales/ui-pt.json';
import engine from '../locales/engine-pt.json';
import pick from '../locales/pick-pt.json';
import df2 from '../locales/df2-pt.json';
import upd from '../locales/upd-pt.json';
import r3 from '../locales/r3-pt.json';
import pic from '../locales/pic-pt.json';
import gui from '../locales/gui-pt.json';
import sav from '../locales/sav-pt.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (ru): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/ru.json';
import tune from '../locales/tune-ru.json';
import tools from '../locales/tools-ru.json';
import fix from '../locales/fix-ru.json';
import gh from '../locales/gh-ru.json';
import ui from '../locales/ui-ru.json';
import engine from '../locales/engine-ru.json';
import pick from '../locales/pick-ru.json';
import df2 from '../locales/df2-ru.json';
import upd from '../locales/upd-ru.json';
import r3 from '../locales/r3-ru.json';
import pic from '../locales/pic-ru.json';
import gui from '../locales/gui-ru.json';
import sav from '../locales/sav-ru.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+17
View File
@@ -0,0 +1,17 @@
/* All the interface strings of one language (zh): the base file and the 13 add-on files, merged in the order the app has always used (a later file wins).
Loaded the first time the language is used (see loadCatalog in i18n.ts); English stays in the main file because the English text is the key. */
import base from '../locales/zh.json';
import tune from '../locales/tune-zh.json';
import tools from '../locales/tools-zh.json';
import fix from '../locales/fix-zh.json';
import gh from '../locales/gh-zh.json';
import ui from '../locales/ui-zh.json';
import engine from '../locales/engine-zh.json';
import pick from '../locales/pick-zh.json';
import df2 from '../locales/df2-zh.json';
import upd from '../locales/upd-zh.json';
import r3 from '../locales/r3-zh.json';
import pic from '../locales/pic-zh.json';
import gui from '../locales/gui-zh.json';
import sav from '../locales/sav-zh.json';
export default {...base,...tune,...tools,...fix,...gh,...ui,...engine,...pick,...df2,...upd,...r3,...pic,...gui,...sav} as Record<string,string>;
+169
View File
@@ -0,0 +1,169 @@
import type { Session } from './types';
import {
HOST_FLAG_KEY, PENDING_KEY, TransportError, readBrowserSessions,
type ChatTransport, type HostChatEntry, type StorageLike,
} from './session-persist';
/**
* The page's side of the chat files on the host (server/chat-store.mjs): the HTTP transport, the first read at start (with the move of
* the browser's old chats to the host), the last-moment copy for a page that goes away, and the merge of what another window saved.
*/
const BASE = () => (globalThis as { __STRATA_HOST__?: string }).__STRATA_HOST__ ?? '';
/** Names this window to the host, so the event of its own save is not mistaken for another window's. */
export const WINDOW_ID = `w_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
export function createHttpTransport(fetchImpl: typeof fetch = (...args) => fetch(...args)): ChatTransport {
const headers = (extra: Record<string, string> = {}) => ({ 'x-strata-client': 'ui', 'x-strata-window': WINDOW_ID, ...extra });
async function call(method: string, path: string, init: { body?: string; headers?: Record<string, string> } = {}): Promise<Response> {
let res: Response;
try { res = await fetchImpl(`${BASE()}${path}`, { method, headers: headers(init.headers), body: init.body }); }
catch (e) { throw new TransportError(e instanceof Error ? e.message : 'The host did not answer.', 0, 'offline', true); }
if (res.ok) return res;
let code: string | undefined, message = `Request failed (${res.status})`;
try { const data = await res.json() as { error?: string; code?: string }; code = data.code; if (data.error) message = data.error; } catch { /* no body */ }
throw new TransportError(message, res.status, code);
}
return {
async list() { return await (await call('GET', '/api/chats')).json() as { chats: HostChatEntry[]; damaged?: string[] }; },
async get(id) {
try {
const data = await (await call('GET', `/api/chats/${encodeURIComponent(id)}`)).json() as { rev: number; session: Session };
return { rev: Number(data.rev) || 0, session: data.session };
} catch (e) { if (isTransportError(e) && (e as TransportError).status === 404) return null; throw e; }
},
async put(id, text, baseRev) {
const res = await call('PUT', `/api/chats/${encodeURIComponent(id)}`, { body: text, headers: { 'content-type': 'application/json', ...(baseRev !== null ? { 'x-strata-base-rev': String(baseRev) } : {}) } });
const data = await res.json() as { rev: number; conflict?: boolean };
return { rev: Number(data.rev) || 0, conflict: data.conflict === true };
},
async remove(id) { await call('DELETE', `/api/chats/${encodeURIComponent(id)}`); },
async importMany(sessions) {
const res = await call('POST', '/api/chats/import', { body: JSON.stringify({ sessions }), headers: { 'content-type': 'application/json' } });
return await res.json() as { imported: string[]; skipped: string[]; failed: { id: string; error: string; code?: string }[] };
},
};
}
export interface HydrateResult {
/** The chats to show: the host's, newest first, with this page's own newer ones kept. */
sessions: Session[];
/** [id, rev] of every chat read from the host. */
revs: Map<string, number>;
/** The host could not be asked: `sessions` is what the page had, nothing was moved. */
offline: boolean;
/** Chats moved from the browser's storage to the host this time. */
moved: number;
/** Chats the host refused during the move (disk full / permission) or could not be read; they stay in the browser. */
failed: { id: string; error: string; code?: string }[];
/** Chats that came back from the last-moment copy because the host's version was older. */
recovered: string[];
}
/** By name, so it holds across two copies of this module (a bundle per test file). */
const isTransportError = (e: unknown): boolean => e instanceof TransportError || (e as { name?: string } | null)?.name === 'TransportError';
const newer = (a?: string | null, b?: string | null) => String(a ?? '') > String(b ?? '');
/** Read a batch of chats from the host, a few at a time. */
async function readAll(transport: ChatTransport, entries: HostChatEntry[], parallel = 4): Promise<Map<string, { rev: number; session: Session }>> {
const out = new Map<string, { rev: number; session: Session }>();
let next = 0;
const worker = async () => {
for (;;) {
const entry = entries[next++];
if (!entry) return;
const got = await transport.get(entry.id);
if (got) out.set(entry.id, got);
}
};
await Promise.all(Array.from({ length: Math.min(parallel, entries.length) }, worker));
return out;
}
/**
* The first read of the host's chats at start. Before the host has ever been given the browser's chats (no flag), they are handed over
* (the host takes the ones it does not have, in one request) and the flag is set; the browser's copy is left untouched. When the host
* cannot be asked the page keeps what it has and the writer stays off.
*/
export async function hydrateChats(opts: {
transport: ChatTransport;
browser: StorageLike;
/** The chats in memory now (the browser's copy at start, or what this window already holds). */
current: Session[];
/** A chat that must not be replaced from the host (a turn is running in it). */
isLive?: (id: string) => boolean;
/** Whether a chat in memory has changes the host does not have yet. */
isDirty?: (session: Session) => boolean;
}): Promise<HydrateResult> {
const { transport, browser } = opts;
const isLive = opts.isLive ?? (() => false);
const isDirty = opts.isDirty ?? (() => false);
let moved = 0;
const failed: HydrateResult['failed'] = [];
try {
let flagged = false;
try { flagged = browser.getItem(HOST_FLAG_KEY) === '1'; } catch { flagged = false; }
let list = await transport.list();
if (!flagged) {
const old = readBrowserSessions(browser).sessions;
if (old.length) {
const result = await transport.importMany(old);
moved = result.imported.length;
failed.push(...result.failed);
list = await transport.list();
}
// Set only when nothing was refused: a disk-full move is tried again at the next start.
if (!failed.length) { try { browser.setItem(HOST_FLAG_KEY, '1'); } catch { /* the flag is a courtesy; the host skips what it has */ } }
}
const fromHost = await readAll(transport, list.chats);
const revs = new Map([...fromHost].map(([id, v]) => [id, v.rev]));
const mine = new Map(opts.current.map(s => [s.id, s]));
const merged: Session[] = [];
for (const entry of list.chats) {
const remote = fromHost.get(entry.id);
const local = mine.get(entry.id);
if (!remote) { if (local) merged.push(local); continue; }
merged.push(local && (isLive(entry.id) || isDirty(local) || newer(local.updatedAt, remote.session.updatedAt)) ? local : remote.session);
mine.delete(entry.id);
}
// Chats only this page has (made while the host was being read): kept, and saved when the writer starts.
const hostIds = new Set(list.chats.map(c => c.id));
const onlyHere = opts.current.filter(s => !hostIds.has(s.id));
const sessions = [...onlyHere, ...merged];
const recovered = takeLastMomentCopy(browser, sessions, revs);
return { sessions: recovered.sessions, revs, offline: false, moved, failed, recovered: recovered.ids };
} catch (e) {
if (isTransportError(e)) return { sessions: opts.current, revs: new Map(), offline: true, moved, failed, recovered: [] };
throw e;
}
}
/* ---------------------------------------------------------- the last-moment copy */
/** The page is going away with chats the host has not confirmed: keep them (best effort, may not fit) for the next start. */
export function keepLastMomentCopy(storage: StorageLike, sessions: Session[]): boolean {
try {
if (!sessions.length) { storage.removeItem(PENDING_KEY); return true; }
storage.setItem(PENDING_KEY, JSON.stringify(sessions));
return true;
} catch { return false; }
}
/** At start: chats from the last-moment copy that are newer than the host's (or the host lacks) replace the host's version in memory, to be saved. */
export function takeLastMomentCopy(storage: StorageLike, sessions: Session[], revs: Map<string, number>): { sessions: Session[]; ids: string[] } {
let kept: Session[] = [];
try { const raw = storage.getItem(PENDING_KEY); if (raw) { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) kept = parsed.filter((s): s is Session => !!s && typeof (s as Session).id === 'string'); } } catch { kept = []; }
if (!kept.length) return { sessions, ids: [] };
const byId = new Map(sessions.map(s => [s.id, s]));
const ids: string[] = [];
for (const copy of kept) {
const have = byId.get(copy.id);
if (!have || newer(copy.updatedAt, have.updatedAt)) { byId.set(copy.id, copy); ids.push(copy.id); }
}
try { storage.removeItem(PENDING_KEY); } catch { /* removed at the next start */ }
if (!ids.length) return { sessions, ids };
const order = sessions.map(s => s.id);
const result = order.map(id => byId.get(id)!).concat([...byId.keys()].filter(id => !order.includes(id)).map(id => byId.get(id)!));
void revs;
return { sessions: result, ids };
}
+4 -3
View File
@@ -14,9 +14,10 @@ export class ApiError extends Error {
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
async function request<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method,
signal,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
@@ -31,7 +32,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
get: <T>(path: string, signal?: AbortSignal) => request<T>('GET', path, undefined, signal),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
@@ -47,7 +48,7 @@ export interface StreamEvent {
export function onEvent(handler: (e: StreamEvent) => void, onStatus?: (s: 'live' | 'offline') => void) {
let source: EventSource;
let seenAt = Date.now();
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue'];
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue', 'chats'];
const connect = () => {
source?.close();
seenAt = Date.now();
Loaded 100 of 1113 files, more files were not shown because too many files have changed in this diff. Show more