Compare commits

..
1011 Commits
Author SHA1 Message Date
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 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 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 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
Niko1221 ffa80d7313 App: a layer split runs DFlash2 alone, one request at a time. chooseParallel kept the app's default of 4 slots on two or more cards, so the Python plan (drafting.blocked_by) refused DFlash2 for slots > 1, the MTP layer was not on the disk (a DFlash2 install leaves it out) and the start ran with no drafter. The one-request rule for DFlash2 no longer asks for one card; a number the person chose still stays, and the MTP keeps its slots. Test: two cards with DFlash2 give one slot and the note, two cards with the MTP keep more than one. 2026-11-02 23:37:27 +01:00
Niko1221 974074c549 0.2.10: the all-cards split engine is Terminal-only; the app's drafting plan and fetch ask without it; the split's lanes are cut to 1..8
App: `strata drafting plan/fetch` gain --no-split, and every drafting call the host makes (control-runtime.mjs draftingPlan, draftingFetch) carries it, so Auto in the app on
2 NVIDIA / 4 R9700 cards is the layer split with DFlash2 alone, no draft layer is planned or fetched for the split, and the Models screen label and fit say DFlash2 again
(capabilities.dflash2Split stays false). The Terminal's plan is unchanged (split + MTP). The gguf_dir fix of installMtpLayer and the explicit-MTP path stay.
Lanes: split_config cuts the lanes to 1..8 (the engine refuses "--batch must be 1..8"): the AMD four-card default PARALLEL 32, a layer config's own --batch (IQ3_S on 4 R9700
gave 32 and fell back to the layer split) and a profile parallel above 8; serve/server.py parallel_args' split branch cuts the same.
Tests: tests/terminal/test_split_parallel.py (AppDoesNotSplit, LaneClamp), apps/host/tests/drafting.test.mjs.
2026-11-02 22:45:11 +01:00
Niko1221 3482b3dff8 Merge f-0210-ds-mac-vision: the Mac DeepSeek picture tower (engine commit f457ed7b9) 2026-11-02 22:04:03 +01:00
Niko1221 4045621eba docs/SPLIT_ENGINE.md describes what 0.2.10 does: Auto starts the all-cards split on 2 RTX A6000 or RTX PRO 6000 cards and on 4 R9700 cards with a UD-Q4_K_XL-type file, the MTP draft layer is fetched for it, --split-engine off turns it off, other AMD cards are opt-in with the self-test line 2026-11-02 22:04:03 +01:00
Niko1221 904b2a723f App: the MTP draft layer fetch names the model's own files folder (--native), so a plain Auto load never downloads the model again
Found live on the AppImage (srv118): after an app install from the person's own GGUF folder, a plain Auto load on two cards (the plan says split + MTP, the layer is missing) ran installMtpLayer, which asked the installer for the model by name without gguf_dir; the installer started downloading the 111 GB model into the app's data folder (stopped after 3.5 GB). installMtpLayer now passes gguf_dir = the folder of the run config's --native. Test: apps/host/tests/drafting.test.mjs.
2026-11-02 21:05:56 +01:00
Niko1221 b1eee01074 test: the Mac vision setup-order window allows the download-total correction between the engine line and step 5 2026-11-02 21:04:16 +01:00
Niko1221 f457ed7b98 Mac DeepSeek vision tower: the GEMM choice is made by the engine at the first picture (Metal 4 present, the tensor-op library compiles, a small self-test equals the plain kernel), logged once; otherwise the plain kernel. mtl::Context::compile_try (compile that returns instead of exiting) 2026-11-02 20:52:11 +01:00
Niko1221 c5ab5d4003 Merge f-0291-rc1-ds-vision-reserve: DeepSeek pictures on a discrete card get --reserve-mib 900 (lazy tower stays) 2026-11-02 20:26:53 +01:00
Niko1221 0659c12582 --reserve-mib 900 with pictures on a discrete card (36 experts, 1.2% of the resident ones, for a worst-case margin of ~10 MiB at the default 600) 2026-11-02 20:24:20 +01:00
Niko1221 e6c0cd41d3 test: the Strix stand-in card has no unified_gb (that is a Mac card) 2026-11-02 20:09:42 +01:00
Niko1221 434c6221b6 Setup: a picture file the person already has is used, not fetched again; the Mac's download total drops the picture file once the engine says no
The Qwen family's picture file is also looked for in the folder above --gguf-dir and in that folder's models/ (pictures.places() already did);
Qwen3.6 links another size's copy of its picture file instead of fetching it again; on a Mac, when the installed DeepSeek engine reads no pictures,
the download total printed before the engine step is corrected (nothing is fetched before that point); the early Coder builds' temporary-repository
picture file (972,207,968 B, the same tensors) is accepted as the shared one (DSV41_ALT_FILES).  test_dsv41_mac_vision's step(5) search starts at
the engine line (it found an earlier family's step 5).
2026-11-02 20:03:42 +01:00
Niko1221 006f0ff258 The Coder's Mac measured line names the memory it was measured with (M5 Max, 128 GB): a 64 GB Mac streams part of the experts from the SSD 2026-11-02 20:01:19 +01:00
Niko1221 45ae55c038 test: the Strix stand-in card carries the uma mark setup gives it 2026-11-02 20:00:50 +01:00
Niko1221 82a028f96e The app and the launcher follow the plan: a plain Auto load fetches the MTP draft layer when the split will engage, the Models screen and the fit say split + MTP, and the summary says what runs after a split fallback
App (apps/host): a plain Auto load (no choice carried, none saved but Auto) fetches the draft layer when `strata drafting plan` says mtp_needed and split,
the same as the Terminal; a Load that carries a choice is as before. probeDrafting reads the plan's split word (capabilities.dflash2Split); the install's drafter
(resolveDrafter) takes it too, so a new install on 2 cards leaves DFlash2 out and gets the MTP layer. drafting.ts / native-settings.mjs
resolveDrafter(choice, family, served, split): Auto is the MTP where the split takes the start, a DFlash2 the person chose stays; NativeModels.tsx passes it, so the
label and the fit use the MTP. `strata drafting plan` gains "split".
Launcher: when the server starts the normal layer split because the all-cards split engine would not come up (metrics engine.split_fallback), the final summary
replaces "Using the all-cards split engine on N cards" with "The all-cards split engine could not start (<the engine's own reason>); running the normal layer split."
Tests: apps/host/tests/drafting.test.mjs; tests/terminal/test_split_parallel.py (PlanTellsTheApp, SummaryTellsWhatRuns).
2026-11-02 19:59:57 +01:00
Niko1221 607d015590 CANDIDATE (not sent): DeepSeek with pictures on a card with memory of its own starts the engine with --reserve-mib 1100 (provisional) so the first picture's hipBLASLt start and the tower's buffers have room; Mac, unified-memory APUs and pictures off unchanged 2026-11-02 19:57:37 +01:00
Niko1221 e483f7835c Mac DeepSeek vision tower: GEMM on the Metal 4 tensor ops (BF16 operands read in place, FP32 accumulation; 798x448 in 93 ms, 1344x1260 in 1.2 s, was 2.6 s and 29 s), DS4_VISION_NO_MPP=1 for the plain kernel, sequential-FMA test GEMM (DS4_VISION_REF_GEMM) 2026-11-02 19:42:56 +01:00
Niko1221 63422c2443 Split engine as the automatic default really works on a fresh install: Auto that picks the split fetches the MTP draft layer, and a missing draft layer is a quiet layer split
The split runs the model's draft layer (--mtp) and exits at start without it ("sfnx: --native and --mtp are required"). The plan and the fetch for Auto
resolved to DFlash2 (drafter 1.2 GB, no draft layer), so on 2 x sm_86/sm_120 or 4 x gfx1201 the split was chosen at start, failed, and the server fell back
to the layer split after "Using the all-cards split engine on 2 cards".

(B) strata_control/split_engine.expected(): the rule apply_to_config uses, asked before anything is on disk (model assumed when its file is not there yet).
Runner.split_expected / resolved_drafting and drafting_inputs: Auto where the split will take the start is the MTP, so download_plan and the fetch include the
draft layer (5.2 GB) and not DFlash2's drafter, setup gets --mtp on without --drafter dflash2, and the plan says "Drafting: MTP (the all-cards split engine uses
the MTP draft layer, 5.2 GB)". The app's `strata drafting plan/fetch` (drafting_cmd._inputs) follows the same rule. A person's own DFlash2 / off, --split-engine
off, one card, other cards, three cards keep what they had.
(A) Runner.apply_split_engine: when the --mtp folder is missing or empty at start the split is not picked (Auto: silently, the layer split with DFlash2 starts as
the plan said; "on": one line saying to add --drafting mtp). _auto_gates() is the Auto-only part of the rule, shared by apply_to_config and expected().
Tests: tests/terminal/test_split_parallel.py (Terminal and app plan paths, AMD 4 x gfx1201 and 2, one card, sm_89, own DFlash2, off, missing draft layer).
2026-11-02 19:41:45 +01:00
Niko1221 742863adf3 Revert "Split engine as the automatic default really works on a fresh install: Auto that picks the split fetches the MTP draft layer, and a missing draft layer is a quiet layer split"
This reverts commit 770a9b8dc8.
2026-11-02 19:41:42 +01:00
Niko1221 0bab1534be test: the old-launcher test pins the product version 0.2.10 (it still said 0.2.9) 2026-11-02 19:39:43 +01:00
Niko1221 45c71cf9c3 Merge f-0210-draftsw-dl: switching the model resets Drafting to Auto (Terminal and app); the Q4_0 selector pack is converted only for an engine that carries it 2026-11-02 19:37:43 +01:00
Niko1221 fca8b48c00 Drafting follows the model: the Terminal goes back to Auto when the model changes to one without a Drafting choice (DeepSeek, Coder, Swift, NVFP4) and a launch writes none into a DeepSeek run config; the app's Models page resets to Auto for a model that is not installed; the Q4_0-selector pack is made only where the installed engine reads it (never for the SYCL engine); tests 2026-11-02 19:37:22 +01:00
Niko1221 2e00d75f99 Merge f-0291-rc1-coder-final: test fix for the Mac vision setup-order test 2026-11-02 19:36:46 +01:00
Niko1221 fbc5c8e1d9 test fix 2026-11-02 19:34:02 +01:00
Niko1221 200f639432 test: the setup-order check looks for the step 5 after the engine line 2026-11-02 19:34:01 +01:00
Niko1221 1f7640a8e5 serve: take SPLIT-HIP's server key fix as amended (cf9406ea9) over the earlier 6174eed4a 2026-11-02 19:31:56 +01:00
Niko1221 379e893f84 Merge f-0291-rc1-split-keys: the server stops sending pen_from to the split engine; logprobs/logit_bias answer 400 naming --split-engine off 2026-11-02 19:24:55 +01:00
Niko1221 6174eed4ac serve: never send the all-cards split engine a GEN key its parser refuses: pen_from is dropped for it (its penalty window is the last penalty_last_n tokens of the whole context, so a continued prompt gives the same window), logprobs and logit_bias get a 400 naming --split-engine off; a test pins the set of keys the server can send against sfnx.cpp's parse_gen 2026-11-02 19:23:52 +01:00
Niko1221 6456e3f242 Merge f-0291-rc1-split-draft: Auto picks the MTP draft layer when the all-cards split engine will engage; quiet fallback when --mtp is missing 2026-11-02 19:21:24 +01:00
Niko1221 770a9b8dc8 Split engine as the automatic default really works on a fresh install: Auto that picks the split fetches the MTP draft layer, and a missing draft layer is a quiet layer split
The split runs the model's draft layer (--mtp) and exits at start without it ("sfnx: --native and --mtp are required"). The plan and the fetch for Auto
resolved to DFlash2 (drafter 1.2 GB, no draft layer), so on 2 x sm_86/sm_120 or 4 x gfx1201 the split was chosen at start, failed, and the server fell back
to the layer split after "Using the all-cards split engine on 2 cards".

(B) strata_control/split_engine.expected(): the rule apply_to_config uses, asked before anything is on disk (model assumed when its file is not there yet).
Runner.split_expected / resolved_drafting and drafting_inputs: Auto where the split will take the start is the MTP, so download_plan and the fetch include the
draft layer (5.2 GB) and not DFlash2's drafter, setup gets --mtp on without --drafter dflash2, and the plan says "Drafting: MTP (the all-cards split engine uses
the MTP draft layer, 5.2 GB)". The app's `strata drafting plan/fetch` (drafting_cmd._inputs) follows the same rule. A person's own DFlash2 / off, --split-engine
off, one card, other cards, three cards keep what they had.
(A) Runner.apply_split_engine: when the --mtp folder is missing or empty at start the split is not picked (Auto: silently, the layer split with DFlash2 starts as
the plan said; "on": one line saying to add --drafting mtp). _auto_gates() is the Auto-only part of the rule, shared by apply_to_config and expected().
Tests: tests/terminal/test_split_parallel.py (Terminal and app plan paths, AMD 4 x gfx1201 and 2, one card, sm_89, own DFlash2, off, missing draft layer).
2026-11-02 19:21:07 +01:00
Niko1221 6ca50487ef Mac DeepSeek: the vision tower on Metal (kernels, BF16 GEMM, host side), capability marker STRATA_DS4_METAL_VISION_V1; credit to antirez/ds4 (MIT, 890898cf) 2026-11-02 19:16:42 +01:00
Niko1221 079d7a6244 Merge f-0291-rc1-mac-vision-marker: the Coder's own picture file accepted beside the shards; DeepSeek's Mac pictures follow the installed engine (STRATA_DS4_METAL_VISION_V1) 2026-11-02 19:13:25 +01:00
Niko1221 541eb00c91 DeepSeek's pictures on a Mac follow the installed engine: on exactly when the strata-dsv41 program carries STRATA_DS4_METAL_VISION_V1 (read, never started, cached per path; no engine yet: on, and setup looks again once it is fetched). Setup settles it after the engine step (no marker: the picture file is not fetched, pack and engine text only, with the plain line), the Mac model rows and the install tool ask the same function, the Coder's Mac line and docs/DEEPSEEK_MAC.md follow. The static DSV41_MAC_VISION is gone; other platforms are unchanged 2026-11-02 19:05:35 +01:00
Niko1221 43bc45f893 Merge f-0291-rc1-split-parallel: a parallel override on the split's run config is its lane count, not a conflict 2026-11-02 19:04:58 +01:00
Niko1221 cd35829bb8 Split engine and parallel: no collision on the first 2-card Auto start; --parallel N and Concurrent requests set the split's lanes
The split's run config always carries --batch N. ensure_profile still wrote the launch profile's parallel=<card fit> (4 on 32 GB or more) beside
it, and strata serve --profile (cli._effective) refuses a parallel override next to --batch: every 2-card NVIDIA start with the split on Auto
stopped with "parallel override conflicts with --batch/--slots in run config args" before the engine ran. Same for the AMD four-card default.

- runner.ensure_profile: no parallel override when the run config already names its slots (run_config_owns_slots)
- runner.apply_split_engine: the person's --parallel N is the split's lane count (cfg["parallel"] for the rule; taken out again on a refusal)
- cli._effective: a parallel override on the split's run config (split_engine_note) is not a conflict (the app's Concurrent requests); other configs keep the check
- server.parallel_args: "parallel" on the split's run config sets its --batch
- tests/terminal/test_split_parallel.py: Terminal path, app path, AMD four-card, lanes from the flag, the check kept for other configs
2026-11-02 19:04:34 +01:00
Niko1221 0e3b06f03f The Coder's own picture file (mmproj-DeepSeek-V4.1-Flash-1017-BF16.gguf, beside the shards or in --model-path) is found as the picture file with its own pin, so setup does not fetch mmproj-BF16.gguf a second time and the plan agrees (DSV41_ALT_NAMES; DF2-STRIX 2026-11-02) 2026-11-02 19:03:29 +01:00
Niko1221 ed619c8341 Merge f-0210-draftsw-split: the layer-split gate lift (DFlash2 alone on several cards) 2026-11-02 18:57:13 +01:00
Niko1221 d040f0b660 Auto and an explicit DFlash2 run DFlash2 alone on a layer split over several cards, with the same arguments as one card: blocked_by no longer counts a layer split (several requests at once and --pipeline-windows still keep the MTP); the all-cards split engine still runs its MTP; comments and help text follow; tests for layer split x CUDA/HIP x choices, split engine, slots 2026-11-02 18:56:47 +01:00
Niko1221 65d0b031d6 Merge f-0291-rc1-coder-hf: Coder HF layout (subdir IQ2_XXS) and the Mac/Strix plan words 2026-11-02 18:30:56 +01:00
Niko1221 50af790125 The Coder's Mac plan words, menu text and docs say what was measured (M5 Max 19-22 tok/s with the published 0.2.9 Metal engine, Ryzen AI Max about 20) instead of 'not run yet' 2026-11-02 18:26:19 +01:00
Niko1221 89515548bd The ISTA-DAS Coder's setup entry names its folder (subdir IQ2_XXS from coder-set.json): --model-path at the Hugging Face layout (IQ2_XXS/ plus the Engram and the picture file at the top) is found by setup's step 5 as the launch plan already found it. Before: the plan said 'used where they are' and step 5 said shard 1 is missing (DF2-STRIX, Strix, 0.2.10 Terminal) 2026-11-02 18:26:19 +01:00
Niko1221 5514024c47 Terminal Drafting step: Auto overrides a choice the app saved in the model's file (written as auto, not dropped); tests lock in one drafter per run for every choice on cuda/hip/metal/sycl, old saved values, and the server and Terminal paths 2026-11-02 18:26:17 +01:00
Niko1221 9bc2a65c0a Merge f-0291-rc1-dl-plan: the launch plan counts the shared DeepSeek files setup still fetches 2026-11-02 18:12:03 +01:00
Niko1221 b1043775d9 DeepSeek with your own shards: the launch plan counts the shared files setup still fetches (the Engram, the DSpark drafter, the picture file), with the same search setup makes (setup.dsv41_shared_missing). Before: the plan said 'Nothing to download' and setup then fetched the 8 GB drafter, even with --dspark off (found on Strix, 2026-11-02; the drafter is part of the model's prepared data, so --dspark off does not skip it) 2026-11-02 18:09:02 +01:00
Niko1221 c75ab76550 HIP package: SPLIT_DIR ships the strata-split-N-amd binaries beside strata 2026-11-02 17:05:08 +01:00
Niko1221 95901fe8a4 Merge f-0291-split-hip e3563ff3d: AMD split final candidate (batched draft layer, RCCL sums, strata-split-2/3/4-amd) 2026-11-02 17:03:21 +01:00
Niko1221 e3563ff3dd split HIP: the RCCL default stays quiet when the peer push is off 2026-11-02 16:46:12 +01:00
Niko1221 d889208a0e split HIP: batched draft layer and RCCL prompt sums on by default (AMD); NVIDIA unchanged 2026-11-02 16:45:58 +01:00
Niko1221 8dd8f11a81 split: FNX_ADMIT_INTERLEAVE default off (no measured gain) 2026-11-02 16:24:46 +01:00
Niko1221 9ecdfa0df7 split: batched draft layer covers sampled lanes and draft penalties (per-lane penalize_row and candidates) 2026-11-02 16:23:05 +01:00
Niko1221 7ccfdb833c split: FNX_BATCH_DRAFT=1 (screen): the draft layer's steps of all the lanes in one window (Engine::draft_lanes, BatchDecoder::draft_batched) 2026-11-02 16:17:59 +01:00
Niko1221 dc54f94ad9 split HIP: FNX_PRECAP=1 and FNX_CAPTURE_AFTER=2 are the defaults on AMD (NVIDIA unchanged) 2026-11-02 16:12:45 +01:00
Niko1221 c3c76f0ad0 Merge f-0291-spill: fit_start for discrete AMD cards (Python only) 2026-11-02 15:54:47 +01:00
Niko1221 076a195b79 fit_start: discrete HIP cards get the window walk, with a base measured on an R9700
fit_start treats a discrete AMD card (backend hip; an APU gives no card memory and is left alone) like CUDA: the KV window walk, the working-cache threshold and
single requests priced from a measured base. The HIP base is 3900 MiB, measured on an R9700 (gfx1201) held by a memory hog: the engine's free VRAM before the expert cache is
the squeeze minus 5045 MiB from 7000 to 5200 MiB (CUDA: minus 6324), so a HIP start holds about 1.28 GiB less. DFlash2 alone at 128K with setup's window of 32768:
7942 MiB 787 slots 864 MiB free 48-52 tok/s; 7000 439 slots; 6400 273 slots (loan keeps 16); 5800 head on the host (31 tok/s); 5200 and 4600 refused with the plain line.
An 8 GB AMD card therefore starts as set up; a 6 GB one walks the window.
2026-11-02 15:54:12 +01:00
Niko1221 06698c6746 DS Coder points at the official repository ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-Coder-GGUF, revision 0b1d5da5, every file's size and LFS sha256 from the Hub: five shards in IQ2_XXS/, the Engram at the root is the shared Engram (same name, size and SHA-256 as the unpruned release's converted one, used where it is, on the SSD), the repository's mmproj is accepted beside the shared one; tests pin the Engram rule 2026-11-02 15:40:48 +01:00
Niko1221 7b57fbaacd split: FNX_CAPTURE_AFTER=N (N >= 2, default 0 = off): a batched window whose layout has no graph runs eagerly (kernels launched one by one, its own lane tables, no capture) until the layout has been seen N times, then it is captured; counters for held, captured and eager windows (by rows too) in FNX_BATCH_STATS 2026-11-02 15:32:14 +01:00
Niko1221 09c76c323d split: FNX_PRECAP=1 keeps the batched window graphs by layout (up to FNX_PRECAP_GRAPHS=64, least recently used out) and pre-captures the uniform layouts at the start, charges the planner the measured capture cost; capture counters in the batch stats (FNX_BATCH_STATS) and FNX_PRECAP_TRACE 2026-11-02 15:32:14 +01:00
Niko1221 9d03dba69b Spill gate: the output head's host fallback (native_head.cpp) passes the Metal build flag too, so it is refused on Metal like the rest of the spill logic (it asked spill_allowed(false, integrated) and the Metal shim reports integrated 0) 2026-11-02 15:31:31 +01:00
Niko1221 3096e0d0af split: FNX_BATCH_STATS window-cycle phases (between windows, verify, draft) 2026-11-02 15:24:02 +01:00
Niko1221 9d1ee9b191 split HIP: auto on AMD only for the UD-Q4 expert family (IQ3_S set stays on the layer split; on still runs it) 2026-11-02 15:22:21 +01:00
Niko1221 7bf446b528 rc1 merge spill (2e7db84bc) 2026-11-02 15:19:15 +01:00
Niko1221 df8c28e879 pin_coder.py reads one folder level of a repository (the official Coder repository keeps its model shards in IQ2_XXS/ and the Engram and picture file at the root): subdir, engram_path, mmproj_path 2026-11-02 15:19:14 +01:00
Niko1221 2e7db84bc0 Spill gated to discrete GPUs; KV window floor 4096 walked by fit_start; output head to pinned host memory as the last step
The draft-layer drop, the 16-slot prompt-path loan, the own-buffer fallback and the head-to-host move run only on a discrete GPU (strata::spill_allowed:
not Metal, not an integrated GPU such as Strix Halo), with a CHECK for the gate in arch_defaults_test. qsa_kv_resident_min is 4096 (20480 on a Metal build;
STRATA_KV_RESIDENT_MIN overrides): windows of 4096 and 8192 gave the same tokens as 20480 with the cache fixed. fit_start walks the window 20480 / 8192 / 4096 only as
far as the card needs. When every step leaves the cache no slot, the output head (Q6_K, 0.41 GiB) moves to mapped pinned host memory (about 3x slower decode).
Measured on the 8 GB mimic at 128K: DFlash2 alone 391 MiB free, 63-70 tok/s, 32K prompt 19.8 s; MTP asked: layer dropped, 555 MiB free, 61-71 tok/s, 32K prompt 13.4 s;
2 requests 277 MiB free, 46 tok/s per user; 4 requests 171 MiB free, 15 tok/s per user. 12 GB cells unchanged.
2026-11-02 15:17:02 +01:00
Niko1221 a4b4e06fa7 split HIP: auto on four cards only (AUTO_SPLIT_AMD_CARD_COUNTS = (4,)); on still runs 2 and 3 2026-11-02 15:12:40 +01:00
Niko1221 f31abf7bd2 Version 0.2.10 / engine 0.1.56 follow-through: test_tui2_023_versions and AGENTS.md follow the bump; the '0.2.9.1' tags that meant this release now read 0.2.10; the test comment names 0.2.9.1 as the unpublished DeepSeek hotfix label 2026-11-02 14:54:37 +01:00
Niko1221 aa471de5d6 rc1 merge version bump: product 0.2.10, engine 0.1.56 (be7d719e0 on 72a0cc1d5) 2026-11-02 14:53:54 +01:00
Niko1221 be7d719e0a Product version 0.2.10 (option B): strata_cli, apps/host, apps/desktop, Cargo, the version test, the bug-report template 2026-11-02 14:46:13 +01:00
Niko1221 72a0cc1d5e Engine component version 0.1.56 (CMake, Metal, packaging defaults, RAM_KEEP_MIN_ENGINE and its test) 2026-11-02 14:45:39 +01:00
Niko1221 984f977d66 split: FNX_ADMIT_INTERLEAVE defaults on for AMD only (NVIDIA split frozen for 0.2.9) 2026-11-02 14:43:58 +01:00
Niko1221 9666d1e760 split HIP: FNX_HC_GRID and FNX_RING_GRID (screen switches) 2026-11-02 14:42:49 +01:00
Niko1221 da20fc301d split: FNX_ADMIT_INTERLEAVE (default 1): one decode window between two admissions; AMD auto card-count constant; tests 2026-11-02 14:41:28 +01:00
Niko1221 b90ee03815 rc1 merge dl-fast (21d53d981) 2026-11-02 14:20:00 +01:00
Niko1221 6527e33c7c rc1 merge metal-selq4 (371a28ed1) 2026-11-02 14:17:01 +01:00
Niko1221 ecf7424b53 8 GB NVIDIA starts at 128K: the prompt-path loan may leave 16 cache slots, the draft layer is dropped when it cannot fit
A card whose expert cache stays below the 128 slots a prompt-path loan keeps behind used to be squeezed (reserve lowered to 100 MiB free, then the first long
prompt aborted in an mmq allocation) or gave the prompt path 600 MiB of its own buffers and stopped. The loan now leaves 16 slots, the auto lend cap is lifted
for such a card, and every 128-slot floor in the lend, request-time lend and VRAM-shrink paths follows it (g_lend_keep). When the draft layer, the head and a
working cache with room left do not fit in the free VRAM before the layer loads, the start runs without the layer and says so (STRATA_MTP_KEEP=1 keeps it).
An out-of-memory in the layer or head load with --batch names --batch and --max-context. Measured on an 8 GB mimic at 128K with --kv-resident 20480: DFlash2
alone 391 MiB free, 69-73 tok/s, a 32K-token prompt in 19.8 s; MTP asked: layer dropped, 557 MiB free, 62-70 tok/s, 32K prompt in 13.4 s. 12 GB unchanged.
2026-11-02 14:13:19 +01:00
Niko1221 21d53d9812 docs: the hf command as the manual way to fetch model files (DETAILS.md)
A short section next to the Linux setup: what Strata's own downloader does, the measured speeds (anonymous, 16-24 MB/s whatever the method), and the manual route
with `hf download` and `--gguf-dir`, which setup already supports (checks the SHA-256, never deletes a file in your own folder). No code change.
2026-11-02 14:05:10 +01:00
Niko1221 d78aa598c6 test_drafting_selector_q4: the corruption test lets the file clock move on (Linux ticks in ~4 ms steps, a same-size rewrite right away kept the marker's mtime_ns) 2026-11-02 14:02:29 +01:00
Niko1221 58169905bb rc1 merge df2-vocab (1063d6fa4) 2026-11-02 13:56:44 +01:00
Niko1221 4a6fd9b0e1 rc1 merge df2-sycl Arc engine-side sizing (42a9c25aa) 2026-11-02 13:51:31 +01:00
Niko1221 42a9c25aa5 SYCL: a start short of VRAM runs slower instead of stopping. Requests at once (--batch N) and a long context no longer end in a refusal on an 8 GB A750: when the expert cache sizing leaves no working cache (or only one sized for a prompt loan that cannot happen), the prompt path's own chunk goes down (to 256), then a reserve that was asked for (to 300 MiB); a context that does not fit in VRAM turns KV streaming on by itself (the largest resident window that fits, 20480 at the least); the person's own --prefill N and --kv-resident N are kept. Only a start with no slot even then is refused, with one plain line saying what is taken and what to lower, and an out-of-memory during the load ends the same way. short_cache.hpp holds the arithmetic, short_cache_test (CPU) the A750 figures from the engine logs 2026-11-02 13:50:34 +01:00
Niko1221 1063d6fa48 DFlash2-only installs use the draft-vocabulary subset the person chose in setup: the choice (en / fr / cyrillic) is copied beside the DFlash2 pack, refreshed when it changes and on update and start, kept by the drafter's fetch; tests for DFlash2-only and MTP-folder installs 2026-11-02 13:37:26 +01:00
Niko1221 6467ec76aa cuda_spill_guard table: gr_down_v3_kernel on sm_120 (8 B, df2-more's register-blocked instance) and DFlash2's rope_table_inv_kernel (32 B) measured on l620 2026-11-02 13:28:35 +01:00
Niko1221 892cdbad7f rc1 merge df2-strix-pf (b1d9ebb4c) 2026-11-02 13:27:17 +01:00
Niko1221 9ed67ba318 split HIP: FNX_PF_RCCL_F32=1 (prompt sums in f32 through RCCL, screen) 2026-11-02 13:22:33 +01:00
Niko1221 7a6e77a625 fit_start never changes --max-context or the requests at once: it moves work off the GPU
The context ladder, the fewer-requests step and the one-request-at-a-time fallback of the earlier fit are gone. A start short of VRAM keeps its
context and its number of requests and is moved off the GPU by the backend's knobs (CUDA: the KV window down to the engine's 20480; Arc: the prompt
chunk, then the reserve, then the window), with one plain note. CUDA single requests are priced from the measured base per drafter (DFlash2 alone,
DFlash2 with the MTP layer, MTP, none). Measured on the 8 GB mimic: DFlash2 alone starts at 128K with --kv-resident 20480 and not with 32768.
2026-11-02 13:21:43 +01:00
Niko1221 418982072b tests: AMD architecture sources pinned (self-test before load, code objects, package script, issue template) 2026-11-02 13:06:04 +01:00
Niko1221 e3ca02ec09 split HIP CMake: STRATA_SPLIT_HIP_ARCHS names the split's code objects (gfx11-generic is not in the main backend's arch list); amd_split_report issue template 2026-11-02 13:02:58 +01:00
Niko1221 a0247cdee8 split HIP: gfx11-generic code object for RDNA3 (self-test passes on a 780M), self-test tolerance for the WMMA accumulate, p2006 self-test scripts 2026-11-02 13:01:04 +01:00
Niko1221 6dd725980e model_pins guard: the credit link to the community DFlash2 GGUF in a comment and a docstring is not a model host the runtime reaches 2026-11-02 12:55:28 +01:00
Niko1221 837909fdd1 test_home_hermetic: raw docstring 2026-11-02 12:49:36 +01:00
Niko1221 f3a43da42a rc1 merge fit8 (fd818c7bd) 2026-11-02 12:49:29 +01:00
Niko1221 e4a68ec6fe Launch tests are hermetic on the per-user settings: Home() sets APPDATA and XDG_CONFIG_HOME to its temp folder and drops a developer's STRATA_SETTINGS_FILE / STRATA_CONNECTION_DIR; test_home_hermetic.py fails if a settings, profile or connection location is outside it 2026-11-02 12:47:04 +01:00
Niko1221 fd818c7bd2 fit_start: CUDA Flash-Next request-slot figures (8 GB mimic), fewer requests before one at a time, plain hint on a slot-session OOM
BASE_SLOTTED_MIB gains (cuda, flash-next) 4990 from the 12 GB and 8 GB CUDA cells; a CUDA start with request slots needs a working cache (263 slots) not the one-slot refusal limit; the one-request fallback keeps the largest context a single request runs (16K on 8 GB); plan() tries fewer requests (down to 2) before one at a time; the draft-layer and head loads name --batch/--max-context when they run out of memory.
2026-11-02 12:45:08 +01:00
Niko1221 6cafbe2c74 Split: ATTN_DMA stays off by default on HIP (FNX_PFX_ATTN_DMA=1 opts in), on by default on NVIDIA as in e074b8e53 2026-11-02 12:41:58 +01:00
Niko1221 e52ae14ab0 rc1 merge split up to e074b8e53 (ATTN_DMA) 2026-11-02 12:41:33 +01:00
Niko1221 5f82379cf6 NVIDIA split engine compiles again after split-hip: FNX_HIP is defined only by the HIP build (prefill.cu used it as an expression) and k_pfr_cmp read host memory with HIP intrinsics (NVIDIA uses __ldcv) 2026-11-02 12:39:13 +01:00
Niko1221 83cadd32fe split HIP: gfx11/gfx12 WMMA paths, one binary with code objects for gfx1100-1103 gfx1200 gfx1201, kernel self-test on cards not run (hip/selftest*), exe lookup strata-split-N-amd, experimental AMD only on explicit on, packaging for N=2,3,4 2026-11-02 12:39:12 +01:00
Niko1221 950038a0a8 NVIDIA split engine compiles again after split-hip: FNX_HIP is defined only by the HIP build (prefill.cu used it as an expression) and k_pfr_cmp read host memory with HIP intrinsics (NVIDIA uses __ldcv) 2026-11-02 12:31:38 +01:00
Niko1221 b0bbba5f8b packaging: build_split_hip.sh builds N = 2, 3, 4 2026-11-02 12:27:12 +01:00
Niko1221 202bb94ae6 split HIP: AMD gfx1201 starts the split by default (AUTO_SPLIT_AMD_ARCHS), off by key, preset or STRATA_SPLIT_ENGINE=0; tests and docs 2026-11-02 12:27:01 +01:00
Niko1221 3227bd2187 rc1 merge split-hip (2c1771dce): AMD split, opt-in 2026-11-02 12:23:11 +01:00
Niko1221 09859d0bfe rc1 merge df2-sycl tip (8cb6e82de: vram_fit calibration) 2026-11-02 12:19:54 +01:00
Niko1221 8cb6e82dea vram_fit: a streamed slot session at 128K is 748 MiB, as DF2-MEM measured on the CUDA engine (0.73 GiB with 32768 resident; 0.56 GiB at 32K): the per-cell extra a streamed session keeps is 1400 bytes, not the 380 derived from the code 2026-11-02 12:19:36 +01:00
Niko1221 4d27740022 rc1 merge df2-sycl vram_fit fix (e4be9e50c) 2026-11-02 12:19:13 +01:00
Niko1221 a47e5634ee Node tests that read source follow the q4sel code: the capability line carries dflash2Selq4, and the version list's cache uses fitDrafter 2026-11-02 12:18:47 +01:00
Niko1221 e4be9e50c1 vram_fit: request slots no longer lower a 12 GB card's 128K. A slot's session counts only the KV the engine keeps in VRAM (--kv-resident N: max(N, 20480) cells, not the whole context: 621 MiB instead of 1930 at 128K with 32768 resident), the threshold is the engine's own minimum (the cache keeps a slot) instead of 128 slots, and a card with no measured base for request slots (CUDA / HIP: only the Arc A750's Flash-Next base is measured) is left to the engine as before 2a1042877. The 8 GB Arc that the engine refused (--batch 2 + MTP, 1024 MiB reserve, 8192) still gets a smaller context. Tests for both 2026-11-02 12:18:45 +01:00
Niko1221 523b78e215 SYCL links again after df2-q4sel: df2_select takes the new cb_q4 argument of the shared header and refuses Q4_0 selector codebooks (SYCL stays on the bf16 pack) 2026-11-02 12:15:05 +01:00
Niko1221 2c1771dce2 Merge f-0291-split (up to ea2aed12c: auto/on/off switch, drafting fix, i-quant sets, PF_JOB) into f-0291-split-hip; AMD opt-in rule (AUTO_SPLIT_AMD_ARCHS), strata-split-N-gfx1201 lookup, packaging/build_split_hip.sh, AMD tests 2026-11-02 12:13:42 +01:00
Niko1221 db4b445ba1 rc1 merge df2-sycl vram_fit slot-session term (2a1042877): serve/server.py engine_args conflicted; kept the announce gate of the drafting line (gui-fb3/q4sel) and added fit_start, whose note prints only when announce is on 2026-11-02 12:05:06 +01:00
Niko1221 3dbd3b0538 rc1 merge df2-q4sel pack-aware VRAM (d4c8ca1f6) 2026-11-02 12:04:40 +01:00
Niko1221 d4c8ca1f65 DFlash2 graphics memory follows the pack in use: 0.72 GB (715 MiB) with the Q4_0 selector, 0.89 GB (890 MiB) otherwise; a SYCL build says it cannot read the Q4_0 selector
drafting.DRAFTER_MIB gains dflash2_selq4 (715); drafting.vram_gb(data_dir, engine) gives the true figure (an engine program that carries 'selector codebooks: Q4_0 rows' and a Q4_0 pack that setup / Load converts), strata drafting plan reports vram_gb and selq4 and its text says the same number, Control passes capabilities.dflash2Selq4 to the page and the fit (apps/host/src/lib/native-fit.ts DFLASH2_SELQ4_VRAM_GB, apps/host/server/native-parallel.mjs) uses it; apps/host/tests/drafting.test.mjs reads DRAFTER_MIB from drafting.py and fails when a copy differs.

Marker per backend: CUDA and HIP builds carry the text (src/core/dflash2.cpp); the SYCL drafter (sycl/src/core/dflash2.cpp, bf16 select kernel) does not read Q4_0 rows, its built binary (P1006 rc1sycl) has no such text so it stays on the bf16 pack, and its loader now refuses a Q4_0 codebook with a plain message instead of tiling it as a matrix. Metal: DF2-METAL's f-0291-metal-selq4. The idea of the Q4_0 selector is nazeshinjite's (nazeshinjite/Qwen3.8-Flash-Next-DFlash2-GGUF).
2026-11-02 12:03:30 +01:00
Niko1221 242549e432 split-hip: line endings back to LF in the files my edits had turned to CRLF (diff against f-0291-split is now the real change) 2026-11-02 12:00:59 +01:00
Niko1221 2a1042877d vram_fit: request slots ("Concurrent requests" 2 or more) take their sessions out of the expert cache's room. Each slot is a session the size of the main one on every backend (the engine's 0.23 GiB at 8K int8); the Flash-Next figures measured on the 8 GB A750 (base 4500 MiB, 2.22 MiB per cache slot) are used for these starts only. A start the engine would refuse gets a smaller context (32K, 16K, 8K, 4K, 2K) for that start, or runs one request at a time, with the fit note; the person's own --max-context is kept, and one-card-less, layer-split, DeepSeek and Metal starts are untouched (fit_start, called from serve/server.py engine_args). Tests for an 8 GB Arc at 8K with 2 requests, a 24 GB CUDA card, an explicit context. verify_refusal_test is registered with add_test 2026-11-02 11:59:43 +01:00
Niko1221 371a28ed11 metal DFlash2 reads the Q4_0 selector codebooks (df2_select_q4; the pack loader keeps them raw rows) and prints 'selector codebooks: Q4_0 rows', the text strata_control/drafting.py looks for in an engine before it uses the converted pack; STRATA_DF2_HEAD=native runs the head over the target's whole head and ignores the draft vocabulary 2026-11-02 11:56:44 +01:00
Niko1221 296e6780fa split HIP: RCCL self-test kernels static (two files include the header) 2026-11-02 11:56:17 +01:00
Niko1221 f93c5019f9 rc1 merge df2-q4sel (ebac5686f, incl. df2-comm select kernel) 2026-11-02 11:56:06 +01:00
Niko1221 e074b8e530 split: the attention partials are copied into device memory by the copy engine by default (FNX_PFX_ATTN_DMA=0 turns it off)
IQ3_S on 2 RTX A6000, 32K and 62.9K prompts, four pairs against the same build without it: 3.3 to 5.6% faster. Only the memory path changes: the merge reads
device memory instead of the mapped host buffer, so the logits are unchanged (checked on the 53-prompt set).
2026-11-02 11:55:39 +01:00
Niko1221 a134650e27 rc1 merge df2-sycl Arc start-crash fix (ab7baa5b1) 2026-11-02 11:52:35 +01:00
Niko1221 71120303b1 split HIP: peer push on by default (probe and host fallback stay); RCCL for the prompt sums behind FNX_PF_RCCL (dlopen, self-test, one-line fallback) with a policy test 2026-11-02 11:51:56 +01:00
Niko1221 ab7baa5b17 SYCL: a start refused for lack of VRAM ends with one plain line and a clean exit. A verifier whose init stopped before it made its own queues holds the process's default queue as cs_, sh_cs_ and copy_, and its destructor destroyed that queue; the next verifier's wait died in urQueueFinish (SIGSEGV on an 8 GB A750 with --batch 2 + MTP, where the expert cache gets 0 slots and the verify window refuses to start). The destructor now leaves the default queue alone. The refusal names what is taken and what to lower (--batch / parallel, --max-context, --vram-reserve-mib). verify_refusal_test: two refused verifiers, then the default queue must still run 2026-11-02 11:48:45 +01:00
Niko1221 ebac5686f9 DFlash2 pack with the Q4_0 selector, made on the person's computer at setup / Load from the checked Q4_0 pack and used when it verifies
The selector's two codebooks as Q4_0 rows take 174 MiB (about 88 expert slots) less graphics memory than bf16, with the same drafts: the committed text was byte-identical to the bf16 pack's in 40 of 40 requests (greedy and sampled, everything resident), draft time and acceptance equal, and ahead 1.7-2.2% where the cache is short (RESULTS-DF2MEM.md section 15).

The idea and the bytes are nazeshinjite's: the community GGUF nazeshinjite/Qwen3.8-Flash-Next-DFlash2-GGUF (Apache-2.0) stores the two tables as Q4_0, and tools/dflash2_pack.py --q4_0-selector / --convert-selector writes exactly those bytes (selector_predecessor sha256 55f70049...ffd28, selector_successor 3b596669...a72d2 over the tensor bytes, equal to the GGUF's).

drafting_fetch.convert_selector (called by setup, strata drafting fetch and Load, never by a start or a serve; no network; about 120 MB of memory) checks the Q4_0 pack against PACK_SHA256, converts it in-process and checks the result against CONVERTED_BYTES / CONVERTED_SHA256 (339694592 bytes, 4a42b982...3ecd); anything else is deleted and the bf16-selector pack keeps running. drafting.pack_path returns the converted pack only when it verifies (a marker beside it keeps the hash check off the start path) and the engine program carries the text 'selector codebooks: Q4_0 rows'; a missing, short or corrupt file, an engine without the text or an engine that is not known gets the Q4_0 pack. Includes f-0291-df2-comm (Q4_0 rows in the select kernel, STRATA_DF2_HEAD=native) merged into rc1.
2026-11-02 11:46:16 +01:00
Niko1221 f32c3c4614 rc1 merge df2-sycl slot-penalty port (e8a6afd5f) 2026-11-02 11:40:57 +01:00
Niko1221 4043138e1e split: FNX_PFX_ATTN_DMA=1 (screen): the other cards' attention partials are copied into device memory by the copy engine as their flags arrive, so the merge kernel reads device memory instead of the mapped host buffer (off by default) 2026-11-02 11:39:22 +01:00
Niko1221 f4561cbe02 Merge f-0291-df2-comm (Q4_0 selector codebooks in the select kernel and the packer, STRATA_DF2_HEAD=native) into rc1; the engine says 'selector codebooks: Q4_0 rows' when it loads such a pack 2026-11-02 11:38:53 +01:00
Niko1221 52e791e097 rc1 merge df2-unsloth (d55138e95) 2026-11-02 11:38:53 +01:00
Niko1221 d55138e95a Auto picks DFlash2 for Unsloth's quantizations of Qwen3.8-Flash-Next
DF2-METAL found that SUPPORTED_FAMILIES and the model-name pattern left Unsloth out, so UD-Q4_K_XL and UD-IQ4_XS (the same Qwen3.8-Flash-Next weights, and the case the
DFlash2 numbers on Strix, R9700 and M5 Max were measured on) started the MTP everywhere.
- strata_control/drafting.py: "unsloth" is a supported family; the name pattern also matches qwen3.8-flash-next-unsloth-<model> (what setup.py and metal/setup_mac.py write:
  f"{family name}-{model.lower()}"). Swift (UkisAI's fine-tune), Coder (ISTA-DASLab's coding version) and NVFP4 stay on the MTP. The stale "Unsloth" in the "another model"
  comment is gone.
- The app's two copies of the rule follow (apps/host/server/native-settings.mjs, src/lib/drafting.ts): the Drafting dropdown and the memory estimate treat Unsloth like Qwen.
- Tests: Unsloth names on cuda, hip, metal and sycl resolve to DFlash2 alone with the vocabulary; Swift, Coder and NVFP4 names stay MTP with their arguments untouched; the names
  are read from setup.py and metal/setup_mac.py; the terminal flow gets the Drafting step for Unsloth; tests/drafting.test.mjs.
2026-11-02 11:28:06 +01:00
Niko1221 a2123a73a0 split-hip: fix the log lines of the peer decision 2026-11-02 11:21:18 +01:00
Niko1221 bbfa7c85cf split-hip: FNX_PEER (AMD): peer push of the window sums and the attention merge into the other cards' device memory, behind a start probe of every directed pair (stores, full read-back compare, flag round trips) with the host exchange as the fallback for the whole run; FNX_PEER=0 forces the host exchange; CPU policy test; NoPeerAccess now allows HIP peer calls in the one probed file only 2026-11-02 11:19:35 +01:00
Niko1221 b1d9ebb4c2 gfx1151 Windows: keep the 16,384-token prompt chunk; do not step it down by the unified pool's "free" figure (STRATA_PF_OWN_CHECK=1 restores the check)
On a Strix Halo the prompt path keeps its own buffers at the 16,384-token chunk (prefill_own_keep_chunk) and the cache sizing already reserved them. The
step-down before the prompt path init read cudaMemGetInfo after the pinned RAM complement (which the unified pool counts twice): 860 MiB "free" in the MTP
engine (no chunk fits, the chunk stays, prompts read at 955 tok/s) but 1.7 GiB in the DFlash2-alone engine (no MTP layer, a slightly larger cache), where
1,536 fit: chunk cut to 1,536, prompts at 303 tok/s (UD-Q4_K_XL, 30K prompt 98.9 s against 31.4 s; 8K 28.3 s against 10.8 s). A real allocation failure still
steps the chunk down (the init loop). Only reached when prefill_own_keep_chunk is set: Windows HIP gfx1151 with a bare --prefill auto.
2026-11-02 11:16:47 +01:00
Niko1221 3342e06444 rc1 merge rc1-metal (d0bc95ec5): draft-vocab resolver 2026-11-02 11:13:22 +01:00
Niko1221 ea2aed12cd split: build option STRATA_SPLIT_PF_JOB (64 default, 128): rows of one expert per prompt-path job, so a weight tile is dequantized once for twice the tokens
Default unchanged (64). The expert prompt kernel's token-tile count follows PF_JOB (it was fixed at four).
2026-11-02 11:13:09 +01:00
Niko1221 568127b7a0 Terminal Overview: the ASCII look has no U+00B7 (a redirected stdout in cp852, cp874, cp932 or ascii raised UnicodeEncodeError); test_speed_average decodes node's output as UTF-8 instead of the locale code page 2026-11-02 11:13:00 +01:00
Niko1221 c64702b724 rc1 merge gui-fb3 (6acd50efe) 2026-11-02 11:07:44 +01:00
Niko1221 33bbd39084 Golden setup test: the vision reader's thread count is mocked (6, the 12-thread baseline PC), so the baseline no longer depends on the runner's CPU count (it failed 10 cases x2 on any Linux runner with more than 12 threads, v0.2.9 included) 2026-11-02 11:07:43 +01:00
Niko1221 d0bc95ec5e DFlash2 alone on Metal starts again without <mtp>/draft_vocab.bin: the packaged default subset (data/draft_vocab.bin) is used, as before the merge; CUDA, HIP and SYCL keep the error; a file named with --mtp-draft-vocab that is missing is still an error (draft_vocab_test) 2026-11-02 11:06:29 +01:00
Niko1221 6acd50efe4 GUI-FB3 item 4: the speed cards show the run average when idle
The Monitor tab (serve/web/app.js), the API monitor page (monitor.js, monitor.html) and the Terminal Overview (strata_cli/overview.py) show the token-weighted
average of the run instead of the last request's speed when the server is idle; live values are unchanged. Ported by hand onto rc1 from the community patch
by keinebenutzer: https://github.com/keinebenutzer/STRATA-average-patch (MIT, Copyright (c) 2026 keinebenutzer), made against v0.1.55 c62d5e3a. Read in full
(no network, telemetry, eval or markup in what it adds); none of its files was run.
Tests: serve/test_speed_average.py (the page code run in node, display-only guard), tests/terminal/test_overview.py (edge cases, fallback).
2026-11-02 11:00:20 +01:00
Niko1221 abd9b4581d rc1 merge split switch (15fb6ffe3): strata_cli/launch/cmd.py conflicted at --drafting help text; df2-primary's help kept, SFNX's --split-engine argument kept 2026-11-02 10:59:45 +01:00
Niko1221 7d4fcefe98 Setup golden baseline re-recorded for the approved 128K default: only --max-context, --kv-resident and --resident-budget-gib moved (12 of 25 configs) 2026-11-02 10:59:20 +01:00
Niko1221 15fb6ffe3f split: Auto drafting on a setup the split accepts runs the split with its MTP; DFlash2 or off chosen by the person keeps the layer split
split_config writes "drafting": "mtp" (what the split engine runs), so serve/server.py's drafter step, which resolves Auto to DFlash2 where the
engine has it, cannot add DFlash2 flags to the split engine's arguments; the fallback keeps the person's own drafting word (or none: Auto), so the
layer split that starts instead gets DFlash2 as usual. The rule refuses (quietly in auto) for an explicit dflash2 or off, and for Auto on a card
where the split has not been measured to decode faster than the layer split with DFlash2. Tests: all four cases, the card rule, the server's
drafter step on a split config.
2026-11-02 10:53:28 +01:00
Niko1221 e8a6afd5f6 SYCL: --batch slots honour the request's repeat / frequency / presence penalties: each penalised slot's reply tail is staged before its window (set_slot_history), sample_rows builds the per-row history with slot_penalty_rows (the CUDA/HIP/Metal logic of 2678c9732), pen_from names where a continued request's reply began; a plain greedy slot still keeps the window's argmax token. logit_bias stays refused on SYCL. slot_history_test (CPU) added to the SYCL targets 2026-11-02 10:48:40 +01:00
Niko1221 4a93c5428c GUI-FB3 items 1 and 2: no Recommended badge in the version list; DFlash2 follows what the installed engine takes
1 The version dropdown no longer shows the "Recommended" tag (the order and the Downloaded tag stay).
2 Gyro-S with Auto sent --mtp-draft-vocab FILE and no --mtp; engine 0.1.55.2 only takes --mtp DIR for the vocabulary and ended with exit code 2.
  (a) strata_control/drafting.py reads the engine program (never starts it) for the start message that says what it takes: the vocabulary file alone, --mtp DIR
      (the MTP layer still not loaded), or no --draft-fallback at all (then no DFlash2 flag is sent). serve/server.py hands the program to the rule.
  (b) An Auto start that picked DFlash2 and ended before it was ready is started once more with the MTP, in one quiet line ("Drafting: MTP (DFlash2 didn't start)",
      nine languages); a DFlash2 the person named fails with the engine's reason.
  (c) tests: tests/terminal/test_drafting.py (new class), serve/test_drafting_retry.py (a fake engine process), version-picker test.
2026-11-02 10:45:18 +01:00
Niko1221 c0380157df Tests that pinned old values follow the merged decisions: native-fit (MTP layer 1.08 GB), tune-outcome (calm running step, GUI feedback 2 item 3), setup tests (128K default context on every card; the picture-file host in strata_control/pictures.py is behind acquisition.require) 2026-11-02 10:45:03 +01:00
Niko1221 ac1965edea split: the all-cards split engine is automatic by default, with an off switch
strata launch --split-engine auto|on|off (settings field, saved in the preset and written to the launch config, so an off survives updates);
STRATA_SPLIT_ENGINE=0/1 for testers. auto (the default) uses the split engine where the rule says yes and says nothing where it says no; on says
why it cannot run; off never. The rule also leaves the layer split for choices the split engine does not honour (DFlash2, custom engine arguments,
replicas, a conversation memory format other than int8). The engine is looked up per card count and architecture (strata-split-N-smCC, then
strata-split-N); sm_120 joins sm_86 as a tested architecture. Tests: modes, auto/on/off, silent refusals, the flag, a saved off.
2026-11-02 10:44:43 +01:00
Niko1221 ec1b3b273b split-hip: FNX_DUMP_FIRST_LOGITS (first-token logits per request, for KL between the engines) 2026-11-02 10:36:45 +01:00
Niko1221 168325418a Metal builds again on the merged DFlash2 line: dflash2.inc follows the drafter header of the ring/mgpu branches (cur_.hist for hist_, bind with head_device, reset/init_ring_log/copy_log_state_from/ring byte counts, plain answers for the batch-slot clone/split calls), and Metal never asks for DFlash2 batch slots 2026-11-02 10:19:23 +01:00
Niko1221 575caededc split: the fallback line carries the engine's own reason and keeps its log; FNX_DUMP_FIRST_LOGITS diagnostic
serve/server.py: when the split engine fails to start, the one line now says why in the engine's words (its 'sfnx: failed:' line) and the
split engine's log is kept beside the normal engine's (it wrote the same file). src/split/src/session.cpp: FNX_DUMP_FIRST_LOGITS=PATH writes
each request's first-token full-vocabulary logits to PATH.<n>, for comparing the prompt path with another engine's.
2026-11-02 10:15:30 +01:00
Niko1221 fe57a0cb52 rc1 merge split (1ba231217) 2026-11-02 09:58:44 +01:00
Niko1221 df642fbf77 rc1 merge pf-small-df2-split (dcad09339): generate.cpp conflicted at the DFlash2 serve on_taps line; kept the layer-split install and added the branch's df2_tap_dump (env-gated diagnostic) to the single-card lambda 2026-11-02 09:58:43 +01:00
Niko1221 ee21447ead rc1 merge df2-mgpu 3e7b8343b + HIP GEMM table (b353ce7d4): generate.cpp conflicted in 2 hunks, both sides kept 2026-11-02 09:58:02 +01:00
Niko1221 3dfb046bf1 rc1 merge df2-metal (a9df88afc): window_rule.hpp and its test (add/add) and generate.cpp (8 hunks) conflicted; mainline draft_fallback/draft_on/lookup_default kept, Metal's defer_df2 call taken 2026-11-02 09:57:22 +01:00
Niko1221 b5abd7792f rc1 merge df2-sycl (903badd90): window_rule.hpp/window_rule_test.cpp add/add (kept the mainline draft_on rule, added SYCL's lookup-skip helpers), drafting.py CONF_STEPS (kept the engine-slot steps, added BACKEND_CONF sycl 0.6), SYCL generate.cpp sets wr.draft_on 2026-11-02 09:56:28 +01:00
Niko1221 f7e24459b7 rc1 merge df2-ring (09ec63b88) 2026-11-02 09:54:45 +01:00
Niko1221 d86c6eeee0 rc1 merge df2-confsteps (6140ce1f3): strata_control/drafting.py and tests/terminal/test_drafting_hardware.py conflicted; confsteps' measured slot arithmetic taken (SLOT_BASE_MIB 3800 pays for DFlash2, no DRAFTER_MIB subtraction, MAX_SLOTS), the gui-fb2 side kept elsewhere 2026-11-02 09:54:45 +01:00
Niko1221 a2f602398f rc1 merge gui-fb2 (35d4781f1): two files conflicted (apps/host/server/native-control.mjs, apps/host/src/components/NativeModels.tsx), resolved at token level; images/visionPrepared take both the DeepSeek tower check and picturesMissing 2026-11-02 09:52:17 +01:00
Niko1221 f81f1326f4 rc1 merge vis-ram (e5de74282) 2026-11-02 09:51:08 +01:00
Niko1221 4fb068a8c1 rc1 merge ds-coder (d6a5a7837) 2026-11-02 09:51:07 +01:00
Niko1221 a1cc285fa1 rc1 merge tui-update (3165c2e8a) 2026-11-02 09:51:07 +01:00
Niko1221 0116b0049a rc1 merge slot-penalty (62e861c99) 2026-11-02 09:51:03 +01:00
Niko1221 299fb68eab rc1 merge arc-uc (512333833) 2026-11-02 09:51:02 +01:00
Niko1221 71e26e68f6 rc1 merge cfg-paths (d0d31a419) 2026-11-02 09:51:02 +01:00
Niko1221 8f9e705c67 rc1 merge ds-rx7000 (d12b0508a) 2026-11-02 09:51:02 +01:00
Niko1221 8394297b26 RAM_KEEP_MIN_ENGINE is 0.1.55.1, the engine version of this update (ram-keep); its test follows 2026-11-02 09:51:02 +01:00
Niko1221 b353ce7d4b DFlash2 Q4_0 GEMM on gfx12: variant 26 (two warps a block, two rows a warp, unroll 1) above 640 rows, 31 (one warp a row, unroll 1) at 640 rows and below; bench variants 36-45; STRATA_DF2_GEMM4=N (N>=10) forces a variant
dflash2_gemm_bench q4 on an R9700 (gfx1201, p620, 8 columns, cold weights, bitwise vs variant 1 in every cell): gate+up 56 -> 44.5 us, down 39 -> 23.4, qkv 26.8 -> 21.4, o 23.6 -> 14.9, fc 61.5 -> 35.2, kv 15.2 -> 10.0, akp/mkp 12.1 -> 6.3, selector 11.1 -> 5.2.
Serve A/B (128K, 8 classes, 3 rounds x 2 reps): drafter 2.5-3.3 -> 2.2-2.8 ms a round, +1.0% tok/s over the old variant (n 6 per class). CLI greedy ids identical across old / new. Other cards keep 17. gfx1151 and gfx1100 compile.
2026-11-02 09:50:36 +01:00
Niko1221 3e7b8343b2 DFlash2 batch slot drafts: on by default only on a layer split (the measured case), one card stays opt-in 2026-11-02 09:50:35 +01:00
Niko1221 80e7c27eb7 rc1 merge kv128 (ee455af7f) 2026-11-02 09:50:22 +01:00
Niko1221 96b29327ff rc1 merge ram-keep (4adc047dc) 2026-11-02 09:50:21 +01:00
Niko1221 79a5ad0429 rc1 merge ple128 (78316c8df) 2026-11-02 09:50:21 +01:00
Niko1221 903badd906 SYCL DFlash2: the drafter's work buffers, K/V rings and rebuild scratch come from one device slab (55 allocations down to 5). On the Arc A-series the host cost of a launch grows with the live allocations; the fresh 4K prompt gap to MTP goes from 4.6 s to about 0.6 s (the prompt context update). Ring log unchanged, ring test: 0 K/V rows differ 2026-11-02 09:48:31 +01:00
Niko1221 1ba2312176 split: refuse a model whose routed experts it has no kernel for at once, in one plain line
Before this, a start with such a file (UD-Q6_K_XL) read 55 GB of embeddings and spent more than 25 minutes loading before it gave up, so the
server's fallback to the normal layer split came very late. The check runs when the file is opened, before anything is read or placed.
2026-11-02 09:29:42 +01:00
Niko1221 db865e9b3d split-hip: the stream flags are one-thread kernels by default (the runtime's native stream memory ops made the prompt path's text vary from start to start with the exchange overlapped: 1 distinct output of 5 fresh starts at 6.5K / 26K / 103K now) 2026-11-02 09:28:01 +01:00
Niko1221 7da32daf97 SYCL, Arc A-series (i915): the ~300 native dense matrices come from first-fit 256 MiB blocks, the emptiest block trimmed to its used size; a launch costs the host time in proportion to the live device allocations there (12 us with none, 131 us with 300). A750, 4K-token prompt 112.6 -> 185.6 tok/s (3 interleaved rounds), decode unchanged, --pcie-frac 0 logits and output identical. STRATA_DENSE_SLAB_MIB overrides, 0 = off; other drivers unchanged 2026-11-02 08:59:43 +01:00
Niko1221 b19ca19ac4 SYCL: STRATA_UC_PROBE also prints the live device allocations, GPU-stamped kernel times and the host launch cost, at nine points of start-up (test only, does nothing when unset) 2026-11-02 08:59:43 +01:00
Niko1221 3f455f9a68 SYCL: test-only in-process kernel timer (STRATA_UC_PROBE) for the uncached-host-memory slowdown on Arc Alchemist 2026-11-02 08:59:42 +01:00
Niko1221 8ec2c8a918 DFlash2: Q4_0 selector codebooks (--q4_0-selector, dtype q4_0 rows in block order, dequantized in the select kernel) and STRATA_DF2_HEAD=native (the draft head runs on the target's own head, no pruned copy) 2026-11-02 08:59:20 +01:00
Niko1221 aaa7a8e494 split: the selection rule accepts the i-quant and Q2_0 expert sets the engine now runs
EXPERT_TYPES gains IQ2_S, IQ3_XXS, IQ3_S, IQ4_XS, IQ4_NL and Q2_0; a set with such experts may have embeddings, shared experts and an output
head in any type the loader converts to Q8_0; the memory estimate counts the Q2_0 to Q4_0 repack twice; the per-layer embedding table type is
checked. Tests for the supported sets (the IQ3_S set, UD-IQ4_XS), the plain refusal lines and the scan.
2026-11-02 08:41:19 +01:00
Niko1221 48d05d0735 split-hip: one-hop decode sum and attention merge for NCARD > 2, per-row batch stats, determinism and handshake test tools 2026-11-02 08:33:10 +01:00
Niko1221 d12b0508a5 Tests for the RX 7000 DeepSeek package: each card gets its family's package, an unnamed card is not guessed, the not-tested line in the Terminal and setup 2026-11-02 08:21:52 +01:00
Niko1221 dcad09339e Merge branch 'f-0291-pf-small-df2' into f-0291-pf-small-df2-split
# Conflicts:
#	src/core/arch_defaults_test.cpp
2026-11-02 08:17:43 +01:00
Niko1221 3eb55adffd A CUDA layer split reads prompts chunk-major by default: the layer-major walk is off unless STRATA_PREFILL_LAYER_MAJOR=1 names it
On 2x RTX A6000 (IQ3_S, MTP, three residency levels) a 32K prompt reads 37-55% sooner chunk-major and an 8K prompt is level (DF2-MGPU, boston); the default had been on for a layer split since 0.2.7 from a 2x A4000 measurement with streamed experts only. Owner's yes 2026-11-02. STRATA_PREFILL_LAYER_MAJOR set by hand still wins both ways; a single card, HIP and the small-card rule (which sets the variable itself) are unchanged.
2026-11-02 08:11:10 +01:00
Niko1221 c14eb72d1c DeepSeek for RX 7000 on Linux: build script for the separate rx7900 DeepSeek package (gfx1100 gfx1101 gfx1102), the not-tested line in the Terminal, the app and setup 2026-11-02 08:11:08 +01:00
Niko1221 62e861c995 A request that continues an earlier leg (prompt + what it generated: solo -> batch slot, slot -> solo) names where its reply began (pen_from), so the penalty window keeps the earlier reply tokens 2026-11-02 08:10:06 +01:00
Niko1221 d0d31a4191 A saved run config follows the update: paths into a removed Control, engine or DeepSeek version folder are pointed at the current one (#4891)
Setup writes absolute paths into the run config (cwd and --expert-profile into the Control bundle that ran it, the picture reader and library folders into
an engine package, the DeepSeek package). An update keeps the new Control/engine and the one before, so the second update after setup removed the folder
the config names: the model showed as not installed and a start failed ("Qwen breaks each time Strata is updated").

strata_control/cfg_paths.py is the one resolver: a string that names a file in components/control/<ver>, components/engine/<ver>-<kind> or
engine-dsv41/<ver>-<kind> whose version folder is gone is mapped to the same file in the active Control / the newest engine of its own kind. Paths the
person typed, packs, models, output paths (--expert-profile-save, log) and folders that still exist are left alone.
- profiles.read_run_config applies it in memory, so the doctor, the model list, the launch plan and the host see the current folders.
- runtime.start writes it once into every saved run config, one line in the host log, keeps a caller's expected source digest valid, and stops with the
  file named when it exists in no current version.
Tests: tests/terminal/test_cfg_paths.py (real update_control twice, real runtime.start for the Terminal and the app, DeepSeek); 9 of 11 fail without the wiring.
2026-11-02 08:08:13 +01:00
Niko1221 b1f4c92961 Arc: DFlash2 alone is the default drafter on the SYCL engine
strata_control/drafting.py: "sycl" joins DFLASH2_BACKENDS, so Auto picks DFlash2 (Q4_0 pack, no MTP layer, --draft-fallback none) on an Intel engine.
BACKEND_CONF = {"sycl": 0.6} replaces the CUDA slot steps for Intel (A750, 298 expert slots, serve sweeps: 0.6 was the best of 0.35 / 0.6 / 0.8); a --draft-conf
of the person's own still wins. An Arc A-series card (A3xx / A5xx / A7xx, told by its Windows adapter name or its Linux PCI id) counts as tested; Arc B-series,
Arc Pro and integrated graphics keep "has not been tested on your graphics card yet".

sycl engine: --mtp-draft-vocab is accepted in place of --mtp for the draft vocabulary (the app passes no --mtp with DFlash2); STRATA_DF2_TIMING also prints the
request-end context update and each prompt context update, STRATA_DF2_NO_PROMPT_CTX (measurement only) skips the prompt's context update.
dflash2_bench: a ctx mode that times the prompt's context update.

Measured on the A750 (IQ3_XXS, --prompt-cache 6, 5 rounds): DFlash2 alone is 2.6% behind MTP in decode tokens per second (95% interval -4.8 to -0.4) and 1.1%
behind over time; a fresh 4K prompt reads 4.6 s slower. Tests: tests/terminal/test_drafting.py, test_drafting_hardware.py, test_issues_022.py (117 pass).
2026-11-02 08:08:04 +01:00
Niko1221 4adc047dc8 Arena under a hard memory limit also needs its headroom: a limit just above the arena no longer kills the engine
On l620 a 49 GiB cgroup limit and the 46.84 GiB arena: the engine's check (arena + blob <= limit) passed, the arena is pinned and never given back, the
engine's own memory (about 3 GiB) came on top, and the cgroup OOM killer ended the engine at the start (journal: "A process of this unit has been
killed by the OOM killer", twice). With the fallback allowed the arena now needs arena + blob + max(RAM to keep, the fallback headroom) under the
hard limit (cgroup, container, job object, --memory-limit-mib), else the RAM copy runs. Without a fallback (hand-set --ple-io ram, shared arena file,
STRATA_ARENA_RAM_FALLBACK=0) the comparison is unchanged. arena_hard_limit_need in ple_residency.hpp, unit cases for a limit just above the arena.
2026-11-02 08:02:49 +01:00
Niko1221 b509b2fef5 Metal: a request's logit bias reached no sampler (set_logit_bias / set_slot_logit_bias returned early because the Metal window leaves the last-stage layer end at -1), and the window's bias buffer stayed at its 16-byte stand-in 2026-11-02 07:47:29 +01:00
Niko1221 78316c8df4 prefill: the batched PLE post-ops default off for an engine built with CUDA 12.8 on a compute capability 12.0 card (one log line); the variable still wins
An engine built with CUDA 12.8.61 faults in the prompt path on an RTX PRO 6000 (compute capability 12.0, driver 580) with the batched PLE post-ops on, which is the
default: "an illegal memory access" at every prompt length from 94 tokens, first error in the native PLE postops launch (CUDA_LAUNCH_BLOCKING), and the isolated
verify_batch_parity test faults on native_ple_postops_batch_snap with 4 tokens. STRATA_PLE_BATCH=0 runs. The same source built with CUDA 13.0 (the released
cuda13 package) runs the default at 94 to 20K tokens, where the batch is ~23% faster prompts, so only the 12.8 toolchain changes: ple_batch_default(cudart_version, hip,
major, minor) is false only for CUDART_VERSION 12080..12089 on cc 12.0 (not HIP, not another card, not another toolkit), ple_batch_resolve lets STRATA_PLE_BATCH win in both
directions, and prefill.cpp says once when it turned the batch off. Known issue #220 / #224; CMake already warned for sm_120 builds with CUDA < 13.0. Unit test in
arch_defaults_test; docs/OLDER_GPUS.md.
2026-11-02 07:39:06 +01:00
Niko1221 d8867ef616 Host: the RAM rule depends on the engine in use: STRATA_RAM_KEEP_GIB for engines that know it, the 0.2.9 rule for older ones
"Leave 20% for my work" reads the version of the engine the Engine tab selected (snapshot engine version, or the folder named in the run config's exe).
An engine of RAM_KEEP_MIN_ENGINE (0.1.56) or newer gets the env STRATA_RAM_KEEP_GIB = clamp(ceil(0.2 x RAM GB), 5, 8). An older engine, or one whose
version cannot be read, gets the rule of 0.2.9 unchanged: --resident-budget-gib from the 80% rule and a STRATA_RESIDENT_HEADROOM_GIB of 20%; neither
is ever given an argument it does not know. Switching engines between loads swaps the rule; full performance removes either. Tests at 24, 32, 64 and 128 GB
for both kinds of engine, with the preference on and off.
2026-11-02 07:34:37 +01:00
Niko1221 35d4781f1c Tests follow the GUI feedback 2 changes: the picture choice rule moved to pictures-choice.ts, the status line is flex, the new catalog is known to plain-errors 2026-11-02 07:32:59 +01:00
Niko1221 53a48dbe5f DeepSeek pack check: the reason is never empty, a pack that no longer fits its files is rebuilt once, and a card the Linux package has no code for is said
HF discussion 30 ("the model's pack is not complete ()", 7900 XTX, files converted by ISTA's script):
- dsv41_pack_info: an engine that prints nothing is reported by how it ended (signal 11, exit code N), never as an empty string.
- The engine's file check is repeated in Python (dsv41_pack_problems); a pack that does not fit the model files (missing or short file, a file replaced
  since the pack was made: pack-source.json size and time, unreadable description) is rebuilt once with one line; when only the engine's own check gave
  no answer the pack is kept, one warning carries the reason and the test question decides. The failure line is one line and names the file (the
  launcher shows only the first [X] line).
- Both layouts whole (what ISTA's converter leaves) use the six-shard files; without --gguf-dir six-shard files in setup's own folder are seen when all five are there.
- The 0.2.9 Linux DeepSeek package holds code for gfx1200/gfx1201 only: hip_archs_from_pci is a third probe for the card; an unnamed card gets one plain line,
  a known card without code is told so (install, install_package), setup warns for its own card pick and reads BUILD.json archs beside a hand-made engine.
Tests: tools/test_setup_dsv41.py (PackCheck, CardsAndLayout, CardGate), tests/terminal/test_dsv41_engine.py (HipCards).
2026-11-02 07:31:16 +01:00
Niko1221 96b009d2ef verify.hpp: set_slot_sampling comment no longer says penalties are skipped in batch windows 2026-11-02 07:26:09 +01:00
Niko1221 541a2fac2d Slot penalties and logit bias in batch windows: shared slot_row_draw for CUDA/HIP/Metal, server accepts logit_bias with --batch, docs updated 2026-11-02 07:15:58 +01:00
Niko1221 e5de74282e AMD: the Qwen on-demand picture reader works on HIP (reader with its CPU reader beside it, one GPU) and releases its VRAM when idle, but is off by default (VISION_ON_DEMAND_AMD_BY_DEFAULT): no shrinking expert cache on HIP, so 7,959 slots against 7,943 for +5.4 s on the first picture; docs 2026-11-02 07:13:18 +01:00
Niko1221 067771d800 Merge branch 'f-0291-metal-sample' into f-0291-slot-penalty 2026-11-02 07:12:54 +01:00
Niko1221 2678c9732e --parallel slots honour the request's repeat / frequency / presence penalties and logit bias (CUDA, HIP): each penalised slot's reply tail is staged before its window (set_slot_history), the verifier builds the per-row history (slot_penalty_rows: the tail plus the slot's tokens in the window up to the row) and draws the row with sample_tokens; the slot's own logit bias array (set_slot_logit_bias); a logit_bias request is no longer refused with --batch; plain greedy slots keep the window's argmax. CPU test slot_history_test 2026-11-02 07:12:53 +01:00
Niko1221 56f93d8ee0 Docs: the choice is now called Leave room for my work 2026-11-02 07:12:20 +01:00
Niko1221 ef7668e596 GUI feedback 2, items 1-8: plain Read pictures, white tick, calm progress card, steady checkbox, ring before the chat name, status line without overlap, compact file list
1 Read pictures is a plain checkbox; no hint, no download button (Load does it); a failed picture download keeps one line under the box.
2 A checkbox tick is white in light mode (dark mode unchanged).
3 The progress card shows the step, the numbers and a thin bar; Stop is a text button; no "buttons wait" sentence; reduced motion honoured.
4 The Read pictures choice is kept per version and no longer re-derived from host reports while a job runs; the reader probe keeps its last answer.
5 No separator under Read pictures.
6 Running chat: an SVG ring before the chat's name.
7 The chat's time and token count are a wrapping flex row without fixed widths.
8 A run with many files is one block: delivered-to folder, working folder, and a count that opens the list.
Also: "Leave 20% for my work" is now "Leave room for my work" (hint: keeps 5-8 GB free), nine languages (gui-<lang>.json).
2026-11-02 07:08:18 +01:00
Niko1221 1bdda21126 Metal --parallel slots honour their request's sampling (temperature, top_p, top_k, min_p, seed; penalties are not applied in batch windows, as on CUDA): each slot's row of a batch window is drawn again from the window's logits with its own params, Philox(seed, position) 2026-11-02 07:04:27 +01:00
Niko1221 d292672844 DFlash2 batch slot drafts on by default (STRATA_BATCH_DF2=0 opts out) where the slots run in one serial group 2026-11-02 06:55:08 +01:00
Niko1221 c683fbf0ef DFlash2: prompt taps in the layer-major prefill (single stage, one prompt); STRATA_DF2_TAP_DUMP research dump
lm_prepare no longer declines for the DFlash2 taps on one stage. lm_run takes a layer's completed output when the next layer starts (the hyper-connection write of its FFN half), puts each sub-chunk's rows at positions >= tap_first_ in the staging, and hands the super-chunk's rows to on_taps in position order when all its layers are done. A layer split and a multi-prompt read still decline. Untested on a GPU at this commit: the equivalence test and the A/B are running.
2026-11-02 06:54:11 +01:00
Niko1221 6140ce1f3b DFlash2 confidence steps from the DFlash2-alone sweep, in the engine's slots (12,000+: 0.35, 7,500+: 0.5, 3,000+: 0.7, below: 0.8)
Measured with no MTP layer loaded against the MTP at equal VRAM (l620 mimic, IQ3_S, chat with a rewind plus four cold prompts, 3 paired rounds): best confidence 0.8 at 1.4K and 3.3K engine slots, 0.65 at 6.6K, 0.5 at 9.2K, 0.5 and 0.35 level at 15.7K; 0.35 loses to the MTP up to 6.6K slots. An explicit --expert-cache N is a budget of largest blobs, about 1.3 N engine slots on IQ3_S, now converted before the steps apply. The slot estimate no longer deducts the drafter from a baseline that already holds the MTP layer, because the DFlash2 is the only drafter.
2026-11-02 06:52:59 +01:00
Niko1221 b49e1ffd6b Host: "Leave 20% for my work" passes the RAM to keep as the env STRATA_RAM_KEEP_GIB, not the flag
An engine older than --ram-keep-gib (0.1.52, 0.1.54, 0.1.55, which a person can choose in the Engine tab) stops with "unknown argument";
it ignores an environment variable. The engine still takes the flag for CLI users (the flag wins over the env). Full performance removes the env;
a STRATA_RAM_KEEP_GIB, --ram-keep-gib or RAM mode of the person wins. New host test: an older engine gets no argument it does not know.
2026-11-02 06:51:33 +01:00
Niko1221 ee108afef5 fused_gr: the shared-memory opt-in of the v3 down kernels is set once per DEVICE, not once per process (a layer split on two sm_120 cards answered 0 tokens)
gr_down_pf_launch and gr_down_rb_launch set cudaFuncAttributeMaxDynamicSharedMemorySize inside a function-local `static const bool once` lambda. The attribute belongs to
the device: only the first card to launch got it, and on the second card of a layer split the launch of a window of 5 or more rows (51-82 KB of dynamic shared memory
against the 48 KB default) was rejected with "invalid argument", which inside the graph capture surfaced as "fused_gr_read_multi v3: operation failed due to a previous error
during capture" and an engine that answered every request with no tokens. Reproduced on 2 x RTX PRO 6000 (CUDA 12.8): the int3 head with the default mask fails at the
6-token window capture; STRATA_GR_V3=0 and one card work. set_attr_once_per_device (one flag per device, under a mutex) replaces both lambdas; no kernel, launch geometry or
arithmetic changes. Every other cudaFuncSetAttribute site of the tree was already per device (listed in phaseA-tests/blackwell/RESULTS-DF2NV.md section 9).
tools/test_func_attr_scope.py (pytest, CPU) fails if cudaFuncSetAttribute / hipFuncSetAttribute appears inside a function-local static lambda or a std::call_once.
2026-11-02 06:47:11 +01:00
Niko1221 b537586e6b One RAM-to-keep-free setting: --ram-keep-gib W covers the arena and the RAM-copy fallback; the app passes it for "Leave 20% for my work"
Engine: --ram-keep-gib W (default 0; STRATA_RAM_KEEP_GIB the same). The expert arena is used only if W GiB stay available after it;
otherwise the RAM copy of the hottest experts leaves max(W, the safety headroom) free, and STRATA_RESIDENT_HEADROOM_GIB still wins when set.
The safety headroom is 8 GiB on Windows (the page trim of the copy needs the room) and 4 GiB elsewhere; with W = 0 nothing else changes.
The decision lives in ple_residency.hpp (ram_keep_plan and its parts) and is covered by ple_residency_test over free RAM x W x arena size.
The explicit modes (--resident-budget-gib, --resident-experts) leave max(W, their headroom).

Host: profile 'work' passes --ram-keep-gib W, W = clamp(ceil(0.2 x RAM GB), 5, 8), instead of the 80% budget and the 20% env headroom; the engine
decides from the RAM free at its start. A flag or env of the person wins; full performance passes nothing and takes the earlier version's settings out.
resource-ram.test.mjs covers 24, 32, 64 and 128 GB with the preference on and off. Docs in DETAILS.md.
2026-11-02 06:44:09 +01:00
Niko1221 fd59c9c39b Small-card prompt path: with one CUDA card, a native pack and a default prompt chunk below 8,192 tokens, prompts read with the unfused experts and, from two chunks, the layer-major walk
The default path on a small expert cache streams every expert again for every 512-5,888-token chunk. On an RTX PRO 5000 held to 1.3K / 1.6K / 1.9K engine slots the unfused experts plus layer-major read a 20K / 32K prompt -44% / -50%, -41% / -42%, -14% / -12% sooner (a real 12 GB 5070: -26% at 20K); from a default chunk of 8,192 (2.3K slots) the default path wins and the change costs +1..+25%, so the rule keys on that chunk (strata::small_card_prompt_path, unit-tested at its boundaries). Output changes at rounding level (first-token KL against the FP16 prompt path 0.012-0.018 for every path including the default, top-1 47 of 48), owner's yes 2026-11-02. STRATA_PF_FUSED / STRATA_PREFILL_LAYER_MAJOR set by hand win, STRATA_PF_SMALL=0 turns the rule off; a layer split, HIP, a non-native pack and STRATA_DEFAULTS=026 keep the default.
2026-11-02 06:40:26 +01:00
Niko1221 4d4a59642b Merge f-0291-df2-primary into f-0291-df2-sycl (primary's side on the duplicated cherry-pick hunks of src/program/generate.cpp) 2026-11-02 06:34:26 +01:00
Niko1221 fc453c3d82 generate.cpp: batch DFlash2 confidence cut: real \0 in the env tests 2026-11-02 06:29:57 +01:00
Niko1221 fd3839172c split: tile exactness test and fnx_run (teacher-forced logits) as optional build targets
split_moe_tile_test runs the prompt kernel's weight tiles on a GPU for one expert of each routed-expert type and tests/split/tile_check.py compares them with gguf-py. split_fnx_run is sFNX's logits tool on the same engine objects.
2026-11-02 06:27:49 +01:00
Niko1221 73a470dafc DFlash2 batch slots: per-slot draft rows cut where the selector's probability product falls below STRATA_BATCH_DF2_CONF (default --draft-conf or 0.35), STRATA_BATCH_DF2_MAXX caps rows 2026-11-02 06:27:03 +01:00
Niko1221 5123338339 SYCL: STRATA_UC_PROBE also prints the live device allocations, GPU-stamped kernel times and the host launch cost, at nine points of start-up (test only, does nothing when unset) 2026-11-02 06:06:07 +01:00
Niko1221 f9e14bdf8e SYCL, Arc A-series (i915): the ~300 native dense matrices come from first-fit 256 MiB blocks, the emptiest block trimmed to its used size; a launch costs the host time in proportion to the live device allocations there (12 us with none, 131 us with 300). A750, 4K-token prompt 112.6 -> 185.6 tok/s (3 interleaved rounds), decode unchanged, --pcie-frac 0 logits and output identical. STRATA_DENSE_SLAB_MIB overrides, 0 = off; other drivers unchanged 2026-11-02 06:06:07 +01:00
Niko1221 90cb0c0f5a DFlash2 serve: the end-of-request context update reads the gathered taps (a layer split's drafter is on another card than stage 0's tap buffer) 2026-11-02 06:03:41 +01:00
Niko1221 09ec63b884 DFlash2: STRATA_DF2_END_UPDATE=0 turns the end-of-request context update off (diagnostics) 2026-11-02 06:03:00 +01:00
Niko1221 7b5d1a38da DF2-SYCL ring test: a conversation that resumes at 0 stashes the old lineage as a branch (the check expected a cold start) 2026-11-02 06:01:41 +01:00
Niko1221 2d5a577037 Merge branch 'f-0291-df2-ring' into f-0291-df2-mgpu 2026-11-02 05:59:43 +01:00
Niko1221 4e7866f91b DFlash2 ring log: off until init_ring_log() has allocated the staging (a clone that skips alloc() copied into a null buffer)
log_on_ defaulted to true and alloc() allocated the pinned staging; a drafter that never ran alloc() (the --parallel slots' clone_alloc) therefore ran the
context update's two D2H copies into a null pointer: an illegal memory access on its first draft. The log is now off by default, init_ring_log() (called
by alloc(), callable by a clone's own allocation on its device) allocates the staging and turns it on, and a drafter without it keeps the pre-log rule.
2026-11-02 05:57:13 +01:00
Niko1221 5a73435898 Metal honours the request's sampling: temperature, top_p, top_k, min_p, repetition / frequency / presence penalties, logit bias and seed on the head rows of every window (Window::sample_rows over lm_, set by Verifier::run through window_set_sampling); greedy requests keep the in-graph argmax 2026-11-02 05:55:39 +01:00
Niko1221 d59ab12fc5 metal: the sampler chain on the GPU (sample.metal samp_part + samp_pick: bias, penalties, top_k, top_p, min_p, temperature, Philox draw at (seed, position)) and sample_chain_parity (greedy == argmax, 1800 rows against the CPU chain with 0 mismatches, determinism, chi-square) 2026-11-02 05:52:27 +01:00
Niko1221 d0dcf65435 generate.cpp: fix two broken string literals in the batch DFlash2 path 2026-11-02 05:42:10 +01:00
Niko1221 29d55bd718 DFlash2 batch slots: deferred context when a round has no spare window row (taps parked on the host, replayed on catch-up), draft graphs of all slots launched then finished together, ring log merged 2026-11-02 05:40:37 +01:00
Niko1221 2f608aac30 Merge f-0291-df2-ring (DFlash2 ring log) into f-0291-df2-mgpu; copy_state_from carries the lineage log 2026-11-02 05:32:32 +01:00
Niko1221 1b25b55444 DF2-SYCL: ring log (port of f-0291-df2-ring): host log of the quantized hidden rows, tag shadow, rebuild and stash, request-end context update, dflash2_ring_test 2026-11-02 05:05:11 +01:00
Niko1221 28db88e56f DF2-SYCL serve: note the batched prompt rows in the drafter's token history (live continuations were taken for a foreign conversation) 2026-11-02 05:00:17 +01:00
Niko1221 e8abcadeef split-hip: chunked GDN prompt kernel in 58 KB of LDS (16 state columns, 128 threads, same sums), DMA receive/gather as an opt-in with its self-check (FNX_PF_DMA=1, FNX_PF_DMA_CHECK=1), batch stats (FNX_BATCH_STATS=1) 2026-11-02 04:57:44 +01:00
Niko1221 a9df88afc9 metal DFlash2: the lineage row log and the ring rebuild for a restored conversation: the half hidden_norm row of every context position is logged (10.6 MiB a lineage, STRATA_DF2_LOG_MIB stashes 4); a request that restores a stashed lineage rebuilds the ring rows of its window with the same projections, bitwise equal to the original update (STRATA_DF2_CHECK_REBUILD=1); the drafter's history is the whole prompt 2026-11-02 04:43:36 +01:00
Niko1221 e04ee998f2 DeepSeek picture tower in RAM between pictures on AMD too (R9700: tower-in-RAM 419 vs 386 experts at a 12 GB-like reserve, same tok/s; lender read the no-room picture right), docs 2026-11-02 04:40:39 +01:00
Niko1221 ac23de4c4d A failed MTP download says one short line; tests 2026-11-02 04:32:18 +01:00
Niko1221 72cf167e3b DFlash2 ring log: update the last window's rows when a request ends, quiet small gaps, copy_log_state_from, stats env
A request that ends on EOS or max_new skips the draft that would have made the last accepted window's context rows, so every turn left a hole of a + 1
positions (5 a turn, accumulating) that a later turn resuming after it found blank and the log could not rebuild. The serve loop now updates those rows
(one synchronous context update, ~0.4 ms) before it finishes the request: a four-turn agent loop plus a parked return now rebuilds with 0 rows missing.
The cold line prints only when 64 or more rows of the window are missing. copy_log_state_from(src) moves a lineage's log, tag shadow and epoch with a request
between two drafters (solo <-> a --parallel slot). STRATA_DF2_RING_STATS=1 prints the live log, the stash and each rebuild per request.
2026-11-02 04:18:30 +01:00
Niko1221 d826b02d5b DeepSeek picture tower: when the card has no free graphics memory for a picture turn, the experts' slots lend the room (the prompt path's own lend: the last slots of device 0's tail region, read back from the model files after the turn), so the picture is read instead of refused. Boston A6000 with 59 MiB free: the picture is answered right, 35 experts lent and handed back in 117 ms (12 GB-like reserve: 31 experts, 79 ms) 2026-11-02 04:11:42 +01:00
Niko1221 b1de678f33 DF2-SYCL: --draft-conf defaults to 0.6 on the Arc build 2026-11-02 04:11:39 +01:00
Niko1221 96f5227763 DFlash2 ring log: rebuild the draft ring's window from logged context rows after a restore, a rewind or a repeated prefix
The draft ring has 2,064 slots (position mod 2064). A request that resumes below the highest position written (a repeated or rewound prefix, a
parked conversation brought back) found the part of its first draft's window [n - 2047, resume) overwritten by later positions or never written,
and the drafter drafted poorly until it refilled: on an RTX PRO 6000 with the prompt cache on, DFlash2 alone was +14% over MTP on cold and live
turns but +3% on restored prompts and -2% on an exact repeat (acceptance 0.63 and 0.59 against 0.79 and 0.70 with the cache off). The repeat case
printed no warning at all, because the token history was equal.

The K/V of the five layers is a function of one row per position, the quantized hidden_norm output (2560 int8 codes and 80 block scales, 2.9 KB;
the K/V is 40 KB and the taps 51 KB). Every context update now copies that row into a chunked, copy-on-write host log of the lineage (a
pinned staging copy inside the captured graph, moved to the log after the stream sync). begin_request rebuilds, with the same kernels on the
same bytes, every slot of the window below `resume` whose tag does not match (a host shadow of the tags says which): the rebuilt K/V is bitwise
the original update's (dflash2_ring_test: rewind, parked, branch, evicted; 578 rows in 4.0 ms, a whole window of 2042 rows in 12.7 ms).
A lineage replaced by another is moved, not copied, into a stash (STRATA_DF2_LOG_MIB, default 256, oldest evicted); a request whose prefix matches a
stashed lineage swaps it back in; a rewind or branch keeps the old branch as a copy-on-write stash entry. STRATA_DF2_RING_LOG=0 restores the old
behaviour. Rows never logged are reported (cold) instead of silently blank.
2026-11-02 03:55:35 +01:00
Niko1221 5074caee06 DF2-SYCL: defaults to the SIMD16 pair GEMM (v41, v40 for small matrices) and the 256-work-item attention at 64-key chunks 2026-11-02 03:40:14 +01:00
Niko1221 6de7c8e315 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-02 03:36:50 +01:00
Niko1221 1acdf93401 DF2-SYCL: attention chunk 64 (33 chunks), SIMD16 pair Q4_0 GEMM variants 40-43 2026-11-02 03:32:30 +01:00
Niko1221 f3face9402 DF2-SYCL: lookup-first rule (c2b9218c7's window_rule.hpp, CLI and serve loops hand-ported), STRATA_DF2_LOOKUP_SKIP default 0 2026-11-02 03:27:11 +01:00
Niko1221 ee455af7fe Setup streams the KV cache from 64K up on a PC with room for the experts plus 10 GB, not only for the model's all-in-RAM need (KV-128)
The rule asked ram_gb + KV + 1 (64.8 GB for IQ3_S, a 64 GB PC reads 63.1), which left a small card with the KV in VRAM and 720 expert slots at 128K. It is now the smaller of that and arena_gb + LOW_RAM_HEADROOM_GB + KV (62.1 GB for IQ3_S; the KV's RAM comes out of the engine's RAM copy or file cache, not on top). It never asks more than before; --kv-streaming on|off and WSL are unchanged; the size stays 32768.

Measured (phaseA-tests/gh-sweep-1031/KV-128.md): IQ3_S, 12 GB mimic on l620 under a 45 GiB cap, 756 -> 1,267 slots, 32K prompt 21-34 s -> 12-17 s, 118K prompt 76-122 s -> 45-63 s, decode flat to +10%, engine RAM unchanged; on the owner's RTX 5070 (single runs) 924 -> 1,585 slots, 32K prompt 130 -> 79 s, RAM copy 30.8 -> 28.9 GiB, no paging. Decode there is within noise.

Tests: need numbers, never-more-than-before over every model/context/KV, the rule at the edges and for WSL, setup writing --kv-resident on a 63.1 GB PC with a 12 GB card and not at 61.9 GB or at 32K. The golden baseline gets --kv-resident 32768 for the 64 GB / 32 GB card IQ3_S and recommended entries. Setup and tuning tests on boston: same 24 failures as f-0291-int3 (not touched), 7 more pass.
2026-11-02 03:01:20 +01:00
Niko1221 f47df12624 metal DFlash2: the attention floor follows the conversation (kept for a request that goes on from the same history, lowered when a request restarts earlier, set to the resume point for a restarted lineage); it stayed at the last restore's resume point and starved every shorter prompt of context rows 2026-11-02 02:52:21 +01:00
Niko1221 17c6c4cd80 split: the prompt ring's receive and gather steps move data to and from device memory by DMA on the copy engines (the add and output kernels read device memory, not host memory over PCIe) 2026-11-02 02:36:08 +01:00
Niko1221 77b4bc42dc split-hip: direct all-gather (every card stores its finished part into all peers' gather buffers), answers bit-identical to the ring; run scripts 2026-11-02 02:36:07 +01:00
Niko1221 cc2dc69184 DF2-SYCL: STRATA_DUMP_WINDOW_TOP2 debug hook 2026-11-02 02:30:50 +01:00
Niko1221 07fb0e7113 Arena RAM fallback: budget form with an 8 GiB headroom, and the Windows copy trims the mapped pages
The fallback's RAM copy (previous commit) now uses the --resident-budget-gib form: the budget is what is available minus the headroom
(default 8 GiB, STRATA_RESIDENT_HEADROOM_GIB changes it), so the experts outside the copy are read unbuffered instead of through the mapped
view whose pages grow the working set until available RAM is near zero.  On Windows the complement copy releases the mapped pages to the
standby list every 4 layers (the VirtualUnlock call that already ran once after the copy): available RAM fell to 0.9 GiB for ~10 s during the copy
on a 64 GB PC with 44.6 GiB available.  No data changes.
Measured on the RTX 5070 PC (128K, 44.6 GiB available): lowest available RAM 4.05 GiB, 2,000-token answer 25.2 tok/s (9.4-10.1 without the fix).
2026-11-02 02:15:46 +01:00
Niko1221 9a35255008 split-hip: side-stream sync copies for HIP graph capture, row-by-row GDN default on HIP, mlock warning 2026-11-02 02:01:42 +01:00
Niko1221 e47ad761fd Docs: Qwen picture reader on demand (DETAILS vision table, vision_reader note) 2026-11-02 02:01:12 +01:00
Niko1221 79dc463760 Picture reader in RAM: Qwen on-demand reader is the NVIDIA one-GPU default (vision.elastic false keeps it on the card; setup's 700 MiB reserve becomes 2048 for it), no repeated VRAM asks to an engine that cannot shrink, 1,536 MiB room check before the card is tried; DeepSeek tower: a failed take no longer leaves a sticky CUDA error, AMD keeps the tower on the card (--vision-lazy asks); docs 2026-11-02 02:00:38 +01:00
Niko1221 86cbd533a4 metal DFlash2: the producers write the half GEMM operand themselves (df2_rms_h, df2_conv_h, df2_swiglu_h, df2_attn_merge_h; STRATA_DF2_FUSE_H=0 restores the 37 df2_f16 dispatches): drafter 2.12 -> 2.08 ms GPU, output ids identical; STRATA_DF2_STATS prints a periodic drafter line (serve leaves through exit()) 2026-11-02 02:00:01 +01:00
Niko1221 3f3b6895e1 Doctor: the MTP folder is optional when the drafter is DFlash2; ui-r3 follows the drafter-aware wording 2026-11-02 01:48:35 +01:00
Niko1221 13edeb7f89 One drafter per run: DFlash2 is the primary drafter where the engine has it (CUDA, HIP, Metal), alone and with --draft-fallback none; the MTP is chosen alone with no DFlash2 option
Auto and the default pick DFlash2 with the Q4_0 pack for Qwen3.8-Flash-Next and Gyro; Intel SYCL stays MTP (one line, DFLASH2_BACKENDS). The GUI dropdown, TUI, strata launch, setup (--drafter), serve/server.py, the host's Load and the drafting command agree. A new install fetches only the selected drafter, a switch fetches the other once (never at start), the draft vocabulary comes with the DFlash2 pack. A layer split or several requests load only the MTP and say so in one line; no drafter on disk says so too. Fit, plan and slot estimates use the chosen drafter (0.89 GB DFlash2, 1.08 GB MTP).
2026-11-02 01:31:26 +01:00
Niko1221 3d08a095ff split: routed expert kernels for the IQ3_S set (first version, to be gated)
Decode: ggml's vec_dot for IQ4_XS, IQ3_S, IQ3_XXS and IQ2_S added to the mmvq expert launchers beside IQ4_NL and Q4_0.
Prompt: weight tiles for Q4_0, IQ4_NL, IQ4_XS, IQ3_S, IQ3_XXS and IQ2_S in moe_mma_iq.cuh (ggml's dequantization rounded once to f16).
Loader: Strata's Q2_0 down experts are repacked to Q4_0 blocks (lossless, twice the bytes).
2026-11-02 01:30:07 +01:00
Niko1221 43543e9b07 Arena RAM fallback keeps a RAM copy of the hottest experts (--resident-experts form)
When the expert arena does not fit the available RAM (#4846), the run read every expert through the plain file source with no RAM copy:
9-10 tok/s on the owner's 5070 PC (44.6 GiB available for a 46.84 GiB arena). The same engine with a RAM copy of the hottest experts
(--resident-budget-gib 40 / --resident-experts) gave 24-31 tok/s at the same RAM. The fallback now sets the --resident-experts flags
(resident_cpu_experts, resident_pin, resident_soft, 4 GiB headroom) when a static expert profile is set; if the file cannot be opened the
flags are cleared with the rest of the fallback. STRATA_ARENA_FALLBACK_RESIDENT=0 keeps the plain file source.
Not built, not run: the patch is untested.
2026-11-02 01:28:07 +01:00
Niko1221 058f65322b split-hip: an mlock that the user's RLIMIT_MEMLOCK refuses is a warning on HIP (the table stays in the page cache), not a failed start 2026-11-02 01:23:48 +01:00
Niko1221 84f16f7a27 split: load-time conversion of the dense and small tensors of the IQ3_S set
Adds the IQ2_S, IQ3_XXS, IQ3_S, IQ4_XS and Q2_0 types to the GGUF type table and a
loader step that, only when a file has routed experts in IQ2_S, IQ3_XXS, IQ3_S or Q2_0,
dequantizes its dense matrices, shared experts, projections and embeddings (K-quants,
IQ4_XS, IQ4_NL, BF16, F16) and writes them again as Q8_0, and the routers, injections
and SSM gates as F32. Other files are not touched. The routed IQ expert kernels are not
there yet, so the set still falls back.

Checked against gguf-py on the ISTA IQ3_S file: 762 tensors converted, the sampled ones
all within the Q8_0 bound (F32 targets exact), every source type covered.
2026-11-02 01:23:25 +01:00
Niko1221 a6fa0fc869 DF2-SYCL: DFlash2 in the serve loop (taps, begin_request, window rule, draft after commit) 2026-11-02 01:18:15 +01:00
Niko1221 46f84d4646 split-hip: native stream memory ops by default (13 us cross-card trip on R9700), the rule accepts the F32 router gate beside the Q8_0 shared expert 2026-11-02 01:14:25 +01:00
Niko1221 c2b9218c76 DFlash2: the lookup first when the suffix match is long (spec/window_rule.hpp skip_draft_for_lookup, STRATA_DF2_LOOKUP_SKIP=N, default 8 on Metal, 0 elsewhere): the round's DFlash2 draft is deferred to the start of the next round; the policy takes the lookup (the committed rows only update the drafter's ring, no draft round) or the draft is made then from the same taps. CLI and serve loops. Based on 34c0ab47b 2026-11-02 01:13:55 +01:00
Niko1221 2fb096b938 Gate check: a Qwen serve engine must answer requests that make prompt lookup propose long windows (the Metal 12-row refusal went unseen because no gate request repeated text) 2026-11-02 01:12:02 +01:00
Niko1221 2f13eb5224 Merge branch 'f-0291-df2-mem' into f-0291-df2-primary 2026-11-02 01:09:39 +01:00
Niko1221 cd9c7e7b57 Merge branch 'f-0291-df2-mem' into f-0291-df2-mgpu 2026-11-02 01:09:03 +01:00
Niko1221 96f0307aa0 --draft dflash2 without the MTP layer: refuse a missing draft vocabulary file instead of running the drafter's head over the whole vocabulary 2026-11-02 01:06:15 +01:00
Niko1221 7e25613634 split-hip: stream memory ops as one-thread kernels by default on HIP (FNX_HIP_MEMOP=native for the runtime's) 2026-11-02 01:03:36 +01:00
Niko1221 f70368b996 DFlash2 on a layer split and in batch slots: stage-wise taps gathered onto the drafter's card (cross-device events), per-stage prompt taps, per-slot drafters sharing the pack and head, spare-row slot windows, solo<->slot ring handoff 2026-11-02 00:59:54 +01:00
Niko1221 415b43f95e metal DFlash2: with no MTP loaded the draft vocabulary falls back to the repository's draft_vocab.bin like the MTP loader (it bound the whole 248,320-row head: +372 MiB and +0.7 ms per round) 2026-11-02 00:47:43 +01:00
Niko1221 285fa48b94 lookup_default.hpp is included on every backend (it sat inside the HIP-only include block) 2026-11-02 00:47:32 +01:00
Niko1221 5a258235c6 split-hip: the launcher rule knows two makes (NVIDIA sm_86 x2, AMD gfx1201): per-make tested sets, KFD-topology card probe for AMD 2026-11-02 00:46:15 +01:00
Niko1221 cf73cc4cb5 DF2-SYCL engine integration, first cut: SYCL Dflash2Drafter, verifier and prompt taps, --draft dflash2 in the CLI loop (no MTP loaded) 2026-11-02 00:45:06 +01:00
Niko1221 fea8a3e633 Metal keeps --lookup-max-t off by default: its windows hold 8 rows and a 12-row lookup window ended the first Qwen serve request (metal window: window size out of range); the default rule is a header with a test 2026-11-02 00:43:09 +01:00
Niko1221 12c2d740c6 split: the prompt ring's receive and gather steps move data to and from device memory by DMA on the copy engines (the add and output kernels read device memory, not host memory over PCIe) 2026-11-02 00:40:54 +01:00
Niko1221 909ab78af8 Metal serve: no default 12-row lookup window (the Metal window holds 8 rows; with the MTP the first suffix-lookup window ended the request with 'metal window: window size out of range') 2026-11-02 00:40:48 +01:00
Niko1221 857d0e3fe9 split-hip: CMake HIP target (strata-split-N for gfx1201 with -DSTRATA_ENABLE_HIP=ON), card check by gfx name 2026-11-02 00:40:32 +01:00
Niko1221 ea2b0d9c63 split-hip: first HIP gfx12 compile of the split engine: gfx12 WMMA expert prompt kernel (replaces mma.m8n8k4), nvcuda::wmma shim for attention and the QSA scores, driver stream-memory-op shim, HIP stubs for the shared-memory holder 2026-11-02 00:37:55 +01:00
Niko1221 857e516621 DFlash2: --draft-fallback none (no MTP layer loaded, DFlash2 the only drafter, an error at start if it cannot run) and the DFlash2 draft head shared with the MTP's byte-identical subset (STRATA_DF2_SHARE_HEAD_OFF=1 keeps its own copy)
Per-run source choice, no mid-run fallback. With the MTP loaded the DFlash2 gathered a second copy of the 106299-row draft head (212.9 MiB on Flash-Next); it now uses the MTP's when the type, the vocabulary ids and the layout match, and the expert cache is sized without the copy. The cache sizing also prices the DFlash2 head from the vocabulary file when there is no MTP.
2026-11-02 00:35:05 +01:00
Niko1221 a6fd5cfe42 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-02 00:34:36 +01:00
Niko1221 dc9b2a407c DF2-SYCL: attention variant 2 (256 work-items), DF-04 prefix test, replay script 2026-11-02 00:34:36 +01:00
Niko1221 9d642b9eb9 split: the expert prompt kernel on Ampere's m16n8k16 tensor-core instruction for sm_80 and later (the Volta m8n8k4 version stays for older cards); first version, to be gated for exactness and speed 2026-11-02 00:33:26 +01:00
Niko1221 1c2aba90fa Merge branch 'f-0291-df2-mem' into f-0291-df2-primary 2026-11-02 00:32:35 +01:00
Niko1221 962816b409 Merge branch 'f-0291-df2-mem' into f-0291-df2-mgpu 2026-11-02 00:32:35 +01:00
Niko1221 5a048c6c09 --draft dflash2 loads no MTP layer unless --draft-fallback mtp is given (the DFlash2 and the MTP layer are never loaded together by default); --draft auto still loads both 2026-11-02 00:32:01 +01:00
Niko1221 626f9ef009 DFlash2: --draft-fallback none (no MTP layer loaded, DFlash2 the only drafter, an error at start if it cannot run) and the DFlash2 draft head shared with the MTP's byte-identical subset (STRATA_DF2_SHARE_HEAD_OFF=1 keeps its own copy)
Per-run source choice, no mid-run fallback. With the MTP loaded the DFlash2 gathered a second copy of the 106299-row draft head (212.9 MiB on Flash-Next); it now uses the MTP's when the type, the vocabulary ids and the layout match, and the expert cache is sized without the copy. The cache sizing also prices the DFlash2 head from the vocabulary file when there is no MTP.
2026-11-02 00:31:09 +01:00
Niko1221 681e509516 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-02 00:30:13 +01:00
Niko1221 7dea06f75d Merge f-0291-df2-nv-a (DFlash2 source ownership) into f-0291-df2-mgpu; the window rule reads draft_on for min-p so DFlash2 alone keeps --spec-min-p 2026-11-02 00:30:10 +01:00
Niko1221 0c87b96f8c Merge f-0291-df2-mem (--draft-fallback none, DFlash2 head sharing) into the DFlash2-primary branch 2026-11-02 00:30:07 +01:00
Niko1221 5eb33fe4f3 Merge branch 'f-0291-df2-conf' into f-0291-df2-primary 2026-11-02 00:29:38 +01:00
Niko1221 166dcf0b11 Qwen picture reader on demand by default on an NVIDIA card with one GPU: the first picture starts it (the expert cache gives its room), 120 s without one stops it; it needs no config key (vision.elastic false or vram_elastic false keeps it on the card). The on-demand reader has the same program without --gpu as its CPU reader, so a picture is read whatever the card has left (found on boston: reserve 700 and no CPU reader gave a 503 on every picture) 2026-11-02 00:18:52 +01:00
Niko1221 bb2649ee8b SYCL: test-only in-process kernel timer (STRATA_UC_PROBE) for the uncached-host-memory slowdown on Arc Alchemist 2026-11-01 23:57:37 +01:00
Niko1221 1ada0f6202 SYCL DFlash2 Q4_0 drafter kernels (active-column GEMM, host rope tables) and a drafter-only bench for Intel Arc 2026-11-01 23:54:08 +01:00
Niko1221 09c407d8f4 DFlash2: --draft-fallback none (no MTP layer loaded, DFlash2 the only drafter, an error at start if it cannot run) and the DFlash2 draft head shared with the MTP's byte-identical subset (STRATA_DF2_SHARE_HEAD_OFF=1 keeps its own copy)
Per-run source choice, no mid-run fallback. With the MTP loaded the DFlash2 gathered a second copy of the 106299-row draft head (212.9 MiB on Flash-Next); it now uses the MTP's when the type, the vocabulary ids and the layout match, and the expert cache is sized without the copy. The cache sizing also prices the DFlash2 head from the vocabulary file when there is no MTP.
2026-11-01 23:51:17 +01:00
Niko1221 8bccfe281e docs: SPLIT_ENGINE.md with the copy-engine ring's numbers, the parallel table and the suite result 2026-11-01 23:46:34 +01:00
Niko1221 d4ab9edc3f split: the prompt path's chunk sums use the copy engines through host memory by default (suite 56/61 against 55/61 on the GPU-thread ring, parallel loads equal or better, prompts 17-20% faster); FNX_PF_RING_SM=1 keeps the GPU-thread ring 2026-11-01 23:44:53 +01:00
Niko1221 34c0ab47b2 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-01 23:41:43 +01:00
Niko1221 2ad5867efb DeepSeek serve: test-only STRATA_TEST_NAN_STOP_AT=<n> takes the NaN stop once on the n-th request (does nothing when unset), to run the post-NaN path on a real engine 2026-11-01 23:32:18 +01:00
Niko1221 0941f1fe83 metal: DFlash2 Q4_0 drafter on the Metal window: fragment-order Q4_0 repack at load, df2_mma4s matrix-unit GEMMs, Q8_0 draft head on the matrix units (df2_mma8), DF-04 candidate cap (set_max_drafts), begin_request/note_rows/prepare, attention floor for a restarted lineage; builds on f-0291-df2-q4 2026-11-01 23:17:17 +01:00
Niko1221 d6a5a7837e README: the ISTA-DAS Coder row 2026-11-01 23:15:09 +01:00
Niko1221 37e7b738f5 metal: DFlash2 Q8_0 GEMM kernel over a repacked pack (df2_mm: 12.6 ms a round against 36), Q8_0 draft head, STRATA_DF2_DUMP and tools/dflash2/check_metal_dump.py (final rows cos 0.9999 against the torch port)
# Conflicts:
#	metal/src/engine/msl/dflash2.metal
2026-11-01 23:12:16 +01:00
Niko1221 1d36e65a14 metal: DFlash2 drafter on the Metal window (taps in the decode window and the prompt chunks, Q8_0 pack through the engine's GEMVs, windowed attention, top-16 selector); same Dflash2Drafter API as the CUDA build 2026-11-01 23:12:07 +01:00
Niko1221 5bb21e170a metal: df2_mma8 (draft head Q8_0 rows on the matrix units), stubs_df2.cpp from the first Metal port 2026-11-01 23:11:58 +01:00
Niko1221 488b67a1a9 metal: df2_mma4 / df2_mma4s, Q4_0 in fragment order on the simdgroup matrix units (split-K variant), df2_bench modes for it, soak and head cap; drafter round 3.0 ms GPU / 3.3 ms wall (was 7.1 / 7.4) 2026-11-01 23:08:27 +01:00
Niko1221 29e8a47ac9 DFlash2 confidence follows the expert slots the card holds after the drafter (9000+: 0.35, 3000+: 0.7, below: 0.8; the person's own --expert-cache N or --draft-conf wins); gfx1201 is a tested card (+7.2% short, +1.35% long), not a slower one 2026-11-01 22:55:53 +01:00
Niko1221 9f0794e993 Qwen picture reader on demand without the shrinking cache: a GPU reader that does not start (no room, card busy) is the CPU reader for that start and the next start tries the card again; a cache that cannot shrink and under elastic_mib free VRAM goes straight to the CPU reader; a CPU reader makes the cache grow back at once; pictures already encoded start no reader; --vram-elastic on a driver without virtual memory management starts as one block with a log line 2026-11-01 22:34:36 +01:00
Niko1221 21df2aa30d DeepSeek picture tower: in RAM between pictures. With --serve on a discrete GPU the tower is read into pinned host memory at the load and takes its ~320 MiB of graphics memory only for a picture turn (out of what --reserve-mib keeps free; kept on the card when the reserve is below its size + 192 MiB, on a GPU that shares the PC's memory, on Metal, or with --vision-resident). The expert split then counts the tower's MiB as experts' room. A card that has no room at the turn gets 3 retries, then the request's ERR names --vision-resident 2026-11-01 22:34:35 +01:00
Niko1221 11e02ea876 ISTA-DAS Coder: measured numbers in the menu text and the docs (KL, perplexity, speed on R9700 and RTX PRO 6000) 2026-11-01 22:29:19 +01:00
Niko1221 4938fcc9f0 metal: df2_bench, the cost of one DFlash2 drafter round on Metal with the Q4_0 pack's shapes (small kernels from the first Metal port, df2_mm4 Q4_0 matrix kernel, engine gemv_mma over native Q4_0 blocks); micro-benchmark only, not in the engine 2026-11-01 22:22:47 +01:00
Niko1221 05e276aa8c ISTA-DAS Coder: dense weights and RAM classes from measured runs on r9700 (placement lines, memory-capped one-card decode), words and menu row of its own 2026-11-01 21:58:06 +01:00
Niko1221 505dd93ed6 Load pictures: one line instead of two, a toast when only the files were fetched, the page driver counts new dialogs only
- With a loaded model that runs without pictures (no internet, no room), the existing "Picture reading needs a few more files" offer says the one short line
  instead of a second warning beside it; the offer is hidden while a download or load runs.
- "Download picture-reading files" ends with a toast; a model that is loaded without pictures is told to load again.
- Nine-language catalog: three more sentences. UI preview fixture: context 128K with its marker; the Chrome driver reports dialogs that are new after the click.
2026-11-01 21:51:15 +01:00
Niko1221 9e10513ad2 Load with Read pictures on and no picture files: fetch only those files, keep the model's pack, draft layer and data folder, no installer run
A model that is downloaded, in its own GGUF folder, with Read pictures on (the default) and no picture file started an install job on Load:
`--data-dir <app models folder>\Strata-data --gguf-dir <own folder> --vision yes`. That re-checked the model files (finish marks written in the
person's own folder), prepared a second pack and downloaded the draft layer into the wrong data folder, and rewrote data_dir in app-settings.json.

- strata_control/pictures.py (new): finds the pinned picture file (size and SHA-256; beside the model, one folder up, in the model's data folder) or
  downloads it once into <data folder>/models, shared; the data folder is the one the model's run config names (packs/<tag>, mtp/rt). Works out the run
  config's vision section for the engine in use with the same rule as setup (CPU reader under 24 GB, GPU reader from 24 GB, AMD/Intel Arc/Mac readers).
  No internet is one plain sentence after a short probe; a start never downloads (acquisition guard).
- native-control.mjs: Load (kind load, vision on) fetches the files through Control, writes the vision section, shows plain-word progress with a bar and
  loads. No internet, no room on the disk or a failed download: the model loads without pictures, nothing saved about pictures, one short line on the
  page. A restart or a tuning run never downloads. kind pictures does the same without a load (the "Download picture-reading files" button).
  A model whose only gap is a deleted picture file is no longer "missing files". installArgs: an installed model is prepared again in the folder its
  own run config names, never the app's models folder, and that folder is never written to the settings; the Drafting plan looks there too.
- Models page: the Load shortcut no longer needs the picture files prepared for the families Load can fetch for; the button and the line use the new kind.
- Tests: tests/terminal/test_pictures.py, apps/host/tests/load-pictures.test.mjs, UI preview fixture scenario, Chrome driver script, nine-language catalog.
2026-11-01 21:29:12 +01:00
Niko1221 f74bceeb6a split: the prompt path's finished part goes to the next card's gather buffer by a kernel, not a host-to-host cudaMemcpyAsync (which blocks the one thread that enqueues the cards in turn: the cause of the copy-engine ring stalling in the first read) 2026-11-01 21:25:01 +01:00
Niko1221 43c40ecb19 split: the launcher side: apply_to_config probes the cards (nvidia-smi) and RAM, reads the GGUF, and turns the run config into the split engine's with the normal one kept as fallback; strata launch calls it only when the config says split_engine true (or STRATA_SPLIT_ENGINE=1 for testers); tests 2026-11-01 21:05:45 +01:00
Niko1221 fcba411c41 split: packaging/build_split_cuda.sh (fails if the engine can call a peer function) and docs/SPLIT_ENGINE.md 2026-11-01 21:01:47 +01:00
Niko1221 ff913c6bf0 serve: a run config for the all-cards split engine starts the normal layer split, with one plain line, if the split engine refuses or fails to come up (start_engine_or_fallback, tests) 2026-11-01 20:58:08 +01:00
Niko1221 28f94d6890 split: the rule for when the all-cards split engine may run (off unless asked; Linux, 2-4 identical cards of a tested kind, UD-Q4_K_XL-type GGUF, every expert fits the cards, RAM free), its run config with the normal config kept as fallback, tests incl. no peer API in src/split; defaults: SM-ring prompt path, Strata's draft-layer files 2026-11-01 20:51:40 +01:00
Niko1221 9aea047eea Fix answers arriving one request late after an aborted request: no DONE after the NaN-stop ERR, server drops a trailing DONE, abort-sync test 2026-11-01 20:47:13 +01:00
Niko1221 333e113d53 split: host-memory exchange between the cards, no peer access anywhere: the ring, attention-exchange and prompt-path buffers live in page-locked mapped host memory; the peer probe, peer enabling, peer copies and the IOMMU/BAR1 start checks are gone; the 27B files are dropped; CMake option STRATA_BUILD_SPLIT (off by default) builds strata-split-N for N cards 2026-11-01 20:46:46 +01:00
Niko1221 b8668d0385 split: sFNX (johnfink8/sfnx f9ba1e9, MIT) as published: engine, engine process, CPU tests, two data files, LICENSE and NOTICE 2026-11-01 20:42:54 +01:00
Niko1221 c021f48d76 ISTA-DAS Coder: its own expert profile (519,422 prompt tokens of the calibration texts) and tests 2026-11-01 20:36:28 +01:00
Niko1221 93970e0a27 DFlash2 Q4_0 GEMM default: two warps a block, two rows a warp (variant 17); one warp a row (28) is best on a 188-SM card but 10-35% slower than 17 on l620's 110 SMs 2026-11-01 20:29:47 +01:00
Niko1221 16f513ad78 HIP: the PcieAuto start share from the pool size (06ebf2e4d, BW-MEASURE E26) is opt-in on AMD (STRATA_PCIE_START=new); CUDA keeps it by default. It flipped near-tie tokens in the first ~50 windows after a start on the R9700 for +-1%, the only default that made merged-with-the-other-defaults-off differ from v0.2.9 there (p620, G2: 21/45 -> 45/45 identical serve answers with the old start) 2026-11-01 19:49:00 +01:00
Niko1221 93e43af9e1 App: DeepSeek reads pictures by default (catalog images true, install keeps vision on, no reader device choice, a pack without the tower offers the missing shared file); load applies the --vision pair 2026-11-01 19:42:20 +01:00
Niko1221 adf7e54127 ISTA-DAS Coder: Strix and Spark note (sizes exact, speed not measured), app label test 2026-11-01 19:33:47 +01:00
Niko1221 4019121f24 DFlash2 Q4_0 GEMM: more rows-a-warp variants; default one warp a row (bench: down 18.7->10.3 us, fc 29.3->16.4, akp 8.2->4.1, o 12.4->8.2, qkv 11.9->9.5; 184/184 bitwise) 2026-11-01 19:28:37 +01:00
Niko1221 f5e2e7a86e comment 2026-11-01 19:24:07 +01:00
Niko1221 0c09456693 DFlash2 Q4_0 GEMM: default rows-a-warp table (bitwise: gemm bench 128/128 on srv118; down 18.7->12.2 us, o 12.4->8.2, fc 29.4->18.4, akp 8.2->4.1) 2026-11-01 19:24:02 +01:00
Niko1221 8dbdcf1b20 DeepSeek engine: an expert profile of another model's size is not read; the router tests run with 192 experts and poisoned padding (CUDA, Metal) 2026-11-01 19:18:48 +01:00
Niko1221 85dd085241 Add the ISTA-DAS Coder set (192 experts a layer): one JSON for the repository, setup recipe, fit numbers, menu rows, Mac note, tests 2026-11-01 19:18:48 +01:00
Niko1221 8752fc37ed Merge f-0291-int3 into f-0291-ds-coder (engine and DeepSeek plan numbers) 2026-11-01 18:49:22 +01:00
Niko1221 909aca3806 Merge branch 'f-0291-ui-final' into f-0291-ui-final2 2026-11-01 18:34:50 +01:00
Niko1221 f88fd15e8b Merge branch 'f-0291-agent-lost' into f-0291-ui-final2 2026-11-01 18:34:50 +01:00
Niko1221 d7d2ffab97 Take the rewritten agent-lost files (the cut sentence says the write did not happen and what the assistant is asked; the e2e script) 2026-11-01 18:34:50 +01:00
Niko1221 68baeabc54 Terminal and MCP agree with 128K: DeepSeek's setup default is 128K (a Mac keeps 8K), setup no longer calls the default untested, the pinned tests follow 2026-11-01 18:20:54 +01:00
Niko1221 601906b4de Merge f-0291-tui-update 3165c2e8a (its own wait for the stopped engine replaces the one found here) 2026-11-01 18:15:24 +01:00
Niko1221 d42341d797 A reply the length limit cuts inside a file write is no longer lost without a word: requests may produce half the window (compaction's reserve is unchanged), a cut reply is announced and the agent is asked to look at what is on disk and go on in smaller parts, and the file being written streams to the page (name, size, lines, tail) 2026-11-01 18:04:34 +01:00
Niko1221 de19caff76 The folder window no longer fails with an error when the Desktop folder is missing (it starts at This PC), and any failure of it now asks for the path instead of leaving no way to choose a folder (found in the review copy, whose profile has no Desktop folder) 2026-11-01 18:04:34 +01:00
Niko1221 6dbf88c784 Line endings of the files the agent-lost branch changed go back to LF (it saved whole files as CRLF; ui-do and anything that reads them by line broke); only its real changes remain 2026-11-01 17:59:32 +01:00
Niko1221 d97d130902 DFlash2 Q4_0 GEMM: rows-a-warp variants (gemm4_rows_kernel, bitwise the same results), STRATA_DF2_GEMM4, gemm bench q4 mode (auto table still the old kernel until measured) 2026-11-01 17:54:08 +01:00
Niko1221 31f0e8647c generate.cpp: fix the merged ERR line 2026-11-01 17:49:41 +01:00
Niko1221 32c37513e7 Merge f-0291-df2-option (Q4_0 drafter pack as shipped) into f-0291-df2-more 2026-11-01 17:47:30 +01:00
Niko1221 d29a6640e4 Chat: a file or command the model is writing right now is drawn from message.drafts through the open activity line: the file and its growing size, the last three lines when they read well, in a box that never changes height; drawn about three times a second, nothing animates 2026-11-01 17:44:39 +01:00
Niko1221 776fb9dbf5 Merge branch 'f-0291-agent-lost' into f-0291-ui-final2 2026-11-01 17:38:30 +01:00
Niko1221 944f06edba Merge branch 'f-0291-df2-option' into f-0291-ui-final2 2026-11-01 17:38:28 +01:00
Niko1221 bd7e44c90d 128K on every card, first-run setup and DeepSeek included: one rule (strata_control/default_context.py) for the app, the Terminal launch default, setup and the MCP install default; the tight-card fallback and DeepSeek's fixed 8K are gone, the inline warning stays 2026-11-01 17:35:17 +01:00
Niko1221 3165c2e8a5 After a model is stopped the update waits for its engine to let go; the start-again question follows what was changed 2026-11-01 17:15:20 +01:00
Niko1221 8550019663 A context picked after this update sticks, 32K included: saved with a marker (ui_context_chosen) by a load, an install or the first-run step; only an old unmarked 32K moves to 128K. The Tune dialog also saves no picture off when the engine has no reader 2026-11-01 17:13:27 +01:00
Niko1221 5ab569aa47 Tests that pinned the old words and places follow the new ones; a DeepSeek card keeps its own SSD line; the status line keeps its 24ch column 2026-11-01 17:02:14 +01:00
Niko1221 b541d7ad66 A reply the length limit cuts inside a file write is no longer lost without a word: requests may produce half the window (compaction's reserve is unchanged), a cut reply is announced and the agent is asked to go on in smaller parts, and the file being written streams to the page (name, size, lines, tail) 2026-11-01 16:56:39 +01:00
Niko1221 0038dbfd44 The folder window no longer fails with an error when the Desktop folder is missing (it starts at This PC), and any failure of it now asks for the path instead of leaving no way to choose a folder (found in the review copy, whose profile has no Desktop folder) 2026-11-01 16:56:39 +01:00
Niko1221 18aacf5dd0 Test: a load without a picture reader runs without pictures and saves no off 2026-11-01 16:55:06 +01:00
Niko1221 5d4183edfa Polish found in the preview: the SSD warning is said once (under the version picker), notes under controls keep their icon beside the words, the empty Subagents row is centred 2026-11-01 16:53:06 +01:00
Niko1221 01dd40aa8f Working ring: its turn is a loop in seconds, as the other loops (the audit wants tokens for transitions) 2026-11-01 16:51:14 +01:00
Niko1221 20c617a8ca Chat while it works: Stop and Send are one height so the box stops moving, one live cue per tool step, the caret follows text only, a slot for the host's live step and output on an open activity line, and the sidebar preview opens and closes in 460 ms 2026-11-01 16:48:58 +01:00
Niko1221 c738d5bf34 A chat that is working shows it on its sidebar row (a slow ring where the unread dot sits), and the model chip names the working chat while you are elsewhere 2026-11-01 16:48:53 +01:00
Niko1221 fbaff162bc DFlash2: graphics memory of the Q4_0 drafter is 0.8 GB (engine: 799 MiB) 2026-11-01 16:38:02 +01:00
Niko1221 8d6351f55f Load model: no pop-up. The press starts it; the plan is read first and a disk that is too small is said in the page and stops it; the download size sits beside the button and the DFlash2 drafter download is said in the page while it runs 2026-11-01 16:36:17 +01:00
Niko1221 04a007771c Tasks: the Board and Schedule help lines move into the header after the Tasks title, smaller, wrapping at narrow widths 2026-11-01 16:34:18 +01:00
Niko1221 e22a48d866 Subagents dropdown: with none, the title says No subagents recorded on the row with the X; no icon, no explanation, no empty body 2026-11-01 16:32:28 +01:00
Niko1221 e60e03762e Model files: the sentence is gone and Other local models... sits inside the component beside Storage settings 2026-11-01 16:32:05 +01:00
Niko1221 53f85fa815 Conversation context opens at 128K for every configuration (the model's largest size when under 128K); a tight card gets an inline warning, nothing is refused or lowered; DeepSeek keeps its checked 8K 2026-11-01 16:31:16 +01:00
Niko1221 943f29853a Version list: our recommended versions first and tagged Recommended, no Fits word anywhere, SSD streaming - will run slower for versions that do not fully fit; NVFP4 no longer says Doesn't fit (the fit follows graphics memory, RAM and SSD; Doesn't fit is left for no graphics card and under 16 GB) 2026-11-01 16:27:47 +01:00
Niko1221 0627b1ec63 This computer with a model loaded: only the free memory is left (no hardware line, name, Loaded, It takes about, numbers or SSD line); a version that fits says nothing 2026-11-01 16:25:17 +01:00
Niko1221 eb87544a58 Read pictures on by default for every model that can read them; a missing picture reader no longer saves an off, and the row has no sentence (tooltip only) 2026-11-01 16:24:20 +01:00
Niko1221 2b9c2980e2 DFlash2 drafter: the Q4_0 pack is made on the person's computer (522,458,112 bytes, pinned size and SHA-256), an existing Q8 pack keeps working 2026-11-01 16:22:06 +01:00
Niko1221 b2365cecf2 Models: no DFlash2 paragraph, no per-card line, no picture-reader sentence; the Drafting dropdown stays 2026-11-01 16:20:02 +01:00
Niko1221 811267e19f Terminal tests do not depend on the box's graphics card (the Drafting step changed the step count on Nvidia/AMD boxes); after a model is stopped for an engine update, wait briefly for its runtime host to release the engine lease (found on a real host) 2026-11-01 16:14:02 +01:00
Niko1221 27a3e056ab Int3 merge f-0291-df2-more (a84eebb51): bit-exact HC down kernel, default mask 133 on sm_120
# Conflicts:
#	src/program/generate.cpp
2026-11-01 16:11:56 +01:00
Niko1221 f7ada7119a Starting a model again waits at most what the runtime accepts (found by the real DeepSeek flow); flow fixes 2026-11-01 15:56:56 +01:00
Niko1221 3a3fd4391b Int3 merge f-0291-mac-q (bef28201f): FP16 default on resident IQ3_S and Q2_0 on Metal through setup's config, Coder on spec 3 2026-11-01 15:52:51 +01:00
Niko1221 bef28201ff Mac config: the fp16 decode (STRATA_METAL_F16=1) is the default of a resident IQ3_S or Q2_0 pack; the Coder joins --spec 3 --spec-min-p 0.5
Coder: expert-pruned 256-expert Coder, M5 Max, 5 interleaved pairs, identical greedy tokens in all 40 runs per class, over --spec 2: story -2.5%
(0/5), code +7.1%, JSON +9.3%, agent turn +12.5% (5/5 each). It has no Mac setup entry yet; the name is what a config for it would pass.

fp16 decode (PR 4807): setup writes STRATA_METAL_F16=1 into the config's env for a resident IQ3_S or Q2_0 pack (STRATA_METAL_F16=0 there turns
it off; streaming and the other sets are untouched). The owner's rule was an option unless it passes the 61-task quality suite: off / on / off
again on IQ3_S 54/61 each, on Q2_0 54/61 each, the same 7 tasks failing in all arms, no family drop, no new hallucination flag, 0 pass/fail
changes between two runs of the same arm; decode +14% (IQ3_S) and +15% (Q2_0) in the suite runs, top-1 96-97% and mean KL 0.009-0.016 against fp32
(phaseA-tests/mac-q/RESULTS.md sections 20, 22, 30).
2026-11-01 15:52:11 +01:00
Niko1221 95597c2dae Windows gfx1151: launch the verify window's graph from a helper thread (fixes the Q4_K_XL first-window start stall)
On a Strix Halo under Windows the first cudaGraphLaunch of a doorbell window sometimes never returned: the GPU had already rung layer 0
("GPU rang 1, flags served 0") while the main thread, which serves the doorbells, was still inside the launch call. HostGraphLaunch (the
CUDA instrumented-launch helper, STRATA_VERIFY_ASYNC_LAUNCH=1) is now compiled for the Windows HIP build too, and arch_defaults sets the
switch for Windows gfx1151 (launch_default_env; STRATA_GFX1151_DEFAULTS=0 or STRATA_VERIFY_ASYNC_LAUNCH=0 turns it off, a user setting is kept).

Measured on the strix box, UD-Q4_K_XL, default queues, 19,853 slots, interleaved within one job: old launch 14 hangs in 75 starts
(t2 4/20, t5 3/25, release build 7/30), helper-thread launch 0 in 75 (+ 8/8 on the final build); decode 48.2 vs 48.4 tok/s; output tokens
identical; IQ3_S 8K prompt 1,413-1,427 vs 1,419-1,423 tok/s with identical output; quality quick subset PASS, agent loops 2/2.

Also STRATA_LAUNCH_PROBE=1 (diagnostic): launch-call time, GPU doorbell state during the call, window graph node count.

(cherry picked from commit 93b9264e30054996d1cb9e89d27d211ed1156b15)
2026-11-01 15:38:54 +01:00
Niko1221 e394c48572 Engine update dialog: space above the progress bar; the failed-update offer shows the reason only under Details 2026-11-01 15:05:56 +01:00
Niko1221 a84eebb513 Remove STRATA_DF2_COUPLED (coupled sampled picks): at a fixed 8-row window it does not raise acceptance (tokens per round -1.7% short / -0.4% long, first-draft match 0.616 vs 0.629; draft temperature 0.6 / 1.5 / 2.5: 2.885 / 2.728 / 2.559 vs 2.868 tokens per round plain), the selector's argmax is the better draft 2026-11-01 15:02:51 +01:00
Niko1221 5f24b96dbd STRATA_GR_V3_PF default on a compute capability 12.0 card: 5 -> 133 (adds the bitwise down-kernel register blocking for windows of 5+ rows; serve A/B on 8 classes, 12 launches per arm: spec 6 +1.21%, spec 8 +1.4%, ids identical 96/96 each) 2026-11-01 15:01:30 +01:00
Niko1221 4deb89ef72 Revert "HC read v3 up kernel, ANOTHER SUMMATION ORDER (STRATA_GR_V3_PF bit 9, off by default, owner-approved item 10): a row per quarter warp, four rows side by side; gr_v3_screen reports its difference from the plain read"
This reverts commit ddd88c9f85.
2026-11-01 15:00:47 +01:00
Niko1221 a000bec3ce Update engine with a model loaded: warn naming it, stop it with the person's yes through strata update --stop-model, progress in words, offer to start it again with the same settings (also on the old engine after a failed update); nine languages 2026-11-01 14:57:08 +01:00
Niko1221 0ffb1d567c DFlash2 falling back to the MTP: the MTP runs its own window (--draft-mtp-spec), not DFlash2's 6, unless the person typed --spec 2026-11-01 14:50:42 +01:00
Niko1221 c70e049954 Default reading size 12 px and interface size 90%: a size the person chose is kept, a save that holds the old defaults moves once; Reset returns to the new ones 2026-11-01 14:50:42 +01:00
Niko1221 052c092856 Merge f-0291-tui-update (engine update beside a loaded model) into the app branch 2026-11-01 14:44:21 +01:00
Niko1221 ddd88c9f85 HC read v3 up kernel, ANOTHER SUMMATION ORDER (STRATA_GR_V3_PF bit 9, off by default, owner-approved item 10): a row per quarter warp, four rows side by side; gr_v3_screen reports its difference from the plain read 2026-11-01 14:29:22 +01:00
Niko1221 e5354698f9 gfx12: the 'matrix-core QSA scorer is on past 28,672 tokens' start line is not printed when STRATA_SELECT_WMMA=0 (the scorer is off then; the other variable of the pair was still being defaulted, which set the flag) (found by G2 on p620) 2026-11-01 14:27:48 +01:00
Niko1221 0e8e4d4c61 HC read v3: drop the up-kernel register blocking (slower: up 9.2 -> 10.6 us at T1, 18.8 -> 23.9 us at T8); the down kernel's register blocking stays: bit 7 = 2 rows a warp for windows of 5+ rows, bit 8 = 4 rows (bitwise; down 16.1/16.2/17.6/19.5 -> 13.3/14.1/14.7/15.6 us at T5-8) 2026-11-01 14:26:53 +01:00
Niko1221 f19d364637 locale-gaps test reads the df2 catalog 2026-11-01 14:22:22 +01:00
Niko1221 f8f4da07df gr_v3_screen: room for 16 variants 2026-11-01 14:20:52 +01:00
Niko1221 beb98760be Int3 merge f-0291-arc-a (a29977bd2): STRATA_SELECT_GEMM default on Battlemage only 2026-11-01 14:12:57 +01:00
Niko1221 26dc2a81c4 Docs: an engine is never updated by itself; flow script takes a prepared data folder 2026-11-01 14:11:12 +01:00
Niko1221 4ab81c7968 HC read v3 down kernel: register-blocked dot phase for 2 / 4 rows a warp (STRATA_GR_V3_PF bits 7 / 8, any window size; same dots, same order) 2026-11-01 14:07:07 +01:00
Niko1221 692b2573ea Int3 merge f-0291-ram-fallback (067b74338): #4846 falls back to --mmap-experts instead of refusing 2026-11-01 14:05:49 +01:00
Niko1221 686c0d42c7 HC read v3 up kernel: register-blocked dot phase (STRATA_GR_V3_PF bit 6, token loop outside the row loop, same dots in the same order); gr_v3_screen compares it bitwise 2026-11-01 14:05:41 +01:00
Niko1221 611ff41d41 DFlash2 option: reachability check through the download's own opener, one-request rule only when the drafter file is there, tests independent of the box's card 2026-11-01 14:00:01 +01:00
Niko1221 a29977bd2a SYCL: STRATA_SELECT_GEMM default (GEMM block scores from 2,048 blocks) only on a Battlemage card; =1 and =0 still decide on every card
The default changes the answers and gained +0.2% to +0.6% on an Arc A750 at 16K, 32K, 64K and 128K (3 runs per arm up to 64K, one pair at 128K; first-token KL 1e-9, same top-1 and tokens): its prompts are bound by expert streaming over PCIe. Under the 5% bar, so Alchemist and other devices keep the warp kernel unless STRATA_SELECT_GEMM=1. Battlemage unchanged (+19% to +28% at 100K to 128K on 2x Arc Pro B65).
2026-11-01 13:52:23 +01:00
Niko1221 e4d2bccd77 Flow scripts: a DeepSeek flow, the python for the throwaway feed 2026-11-01 13:44:52 +01:00
Niko1221 1ebae96167 DFlash2 chosen on purpose starts with one request at a time (request slots keep the MTP), Terminal Drafting step, plan line 2026-11-01 13:43:37 +01:00
Niko1221 067b743385 #4846: when RAM is still short at the expert arena, read the experts from the file like --mmap-experts
After the 20 s wait and the give-back of the PLE lock, a shortage no longer registers the arena anyway (a 60 GiB host stalled in reclaim until it was power-cycled). The run uses the --mmap-experts expert source, says so in one line with one next step, and the start never refuses. A hand-set --ple-io ram and --shared-expert-arena are kept as asked (warn only); a hard cgroup limit below the arena falls back instead of refusing; STRATA_ARENA_RAM_FALLBACK=0 is the old behaviour. If the file cannot be opened either, the arena loads the old way. Decision rule and tests in ple_residency.hpp / ple_residency_test.
2026-11-01 13:43:36 +01:00
Niko1221 e6ef7eed63 Tests follow the new rules: a model beside an update is refused before any download; the launch no longer upgrades DeepSeek; the monitor line 2026-11-01 13:36:10 +01:00
Niko1221 d615a9d643 DFlash2 as a real option: the dropdown only where there is a choice (not DeepSeek), --spec 6 --draft-conf 0.35 with it, only on CUDA and HIP engines, one plain line per card, no-internet message, verified files reused, SSD line label 2026-11-01 13:34:24 +01:00
Niko1221 3b1d9ae360 DF2-MORE: serve STRATA_SPEC_TRACE (per round window, accepted, selector probabilities, drafts, lookup draft, committed tokens; bench only) and STRATA_DF2_COUPLED, the selector's picks drawn with the target's Philox uniform for sampled requests (opt-in, verify unchanged) 2026-11-01 13:32:20 +01:00
Niko1221 687527d7a3 Int3 merge f-0291-ds-left final (ea2e791d8): screens say 5 experts on 3+ RDNA4 cards 2026-11-01 13:15:20 +01:00
Niko1221 ea2e791d8b Screens and app say 5 experts on several RDNA4 cards that hold half of the experts, as the engine does
The engine gives 5 experts per word on two or more RDNA4 cards that together hold at least half of the experts (4 x R9700), but strata launch --plan, the Speed screen and the app's Speed label called Full quality the default there.
dsv41_fit.rdna4_faster() applies the engine's rule with the plan's own numbers: one card the run uses (on Windows one AMD card): yes; several: yes when the plan puts at least half of the set's experts on them (3 or more R9700 for every official set; two hold 37-47%, so they keep 6). strata launch uses it with the chosen model; the model catalog carries rdna4_faster per DeepSeek size for the app (NativeSize, Speed label); --speed help and docs/MODELS.md say it. Other makes and older Radeons are unchanged.
2026-11-01 13:14:36 +01:00
Niko1221 dc96ad7033 Int3 merge f-0291-bw-draft (75d742778): DFlash2 drafter levers (bit-exact), Q4_0 pack path
# Conflicts:
#	src/kernels/cuda/dflash2.cu
2026-11-01 13:10:36 +01:00
Niko1221 89aa30476f Engine update beside a loaded model: never by itself, the person's yes first, stop cleanly, start again with the same settings; a quiet notice and a once-a-day check in the launch monitor 2026-11-01 13:09:21 +01:00
Niko1221 88a79a7efd FP16 weight cache (cda6463e8) OFF by default again, as in v0.2.9: gemm_dequant_side_parity fails with it on sm_86, sm_120 and sm_121 (the cached full-N GEMM rounds differently from the sliced form), so it is not bit-exact and has no 5%+/KL/owner yes. STRATA_PF_W16_CACHE_MIB=N still turns it on by hand. Flip back: set w16_budget_ = 16ll << 30 in the no-env branch of Gemm::native (gemm.cu) 2026-11-01 13:07:37 +01:00
Niko1221 0f7159ca35 Int3 merge f-0291-ds-left (386d2e441): #4677 stop bound, 5 experts by default on one RDNA4 card 2026-11-01 13:07:20 +01:00
Niko1221 4faa0cf0ee Models > This computer: free graphics memory and RAM from real readings, a loaded model changes the card, SSD only when it does not fit 2026-11-01 13:05:58 +01:00
Niko1221 d2c11867c0 Version list: only the chosen base model, recommendation and downloaded versions on top, the rest folded below, a Fits / Fits with SSD streaming word per row 2026-11-01 13:04:58 +01:00
Niko1221 386d2e4416 DeepSeek: 5 experts per word by default on a single RDNA4 card (R9700, RX 9000); --experts N still wins
One R9700 holds under a fifth of a 2.25 bpw set, so the CPU lane and the SSD set the speed, as on the 16 GiB cards and the multi-card RDNA4 rule that already take 5.
1 x R9700 on p620 (125 GB RAM), 2.25 bpw, frozen expert profile, 5 interleaved pairs, 5 against 6 experts: decode 15.24 against 13.08 tok/s (+16%, 5 of 5 pairs), a scripted agent loop -24% wall time (5 of 5), the long-read loop -12% (5 of 5). KL against the 6-expert run 0.030 (6 against 6: 0.008); against the original model 0.1106 against 0.1067-0.1071. quality027 quick: 18 of 19 with 5 experts and with 6, the same task flagged in both.
ds4_experts_rule.hpp: experts_per_word() takes single_rdna4 (the engine passes rdna4() with one device); DS4_DEFAULTS=026 gives the model's 6 back. The Terminal's Speed words (strata launch --plan and screens), the app's Speed label (arch added to RunCard) and docs/MODELS.md say the same. Several RDNA4 cards keep the existing rule (they must hold half of the experts); the screens still do not mirror that one.
2026-11-01 13:04:39 +01:00
Niko1221 142d5c6195 strata tune / tune recheck: the scratch folder the command makes for itself (strata-tune-*, strata-recheck-* in the temp folder) is removed when the run ends; --work-dir keeps its logs (G4 clean-machine finding: it was left behind) 2026-11-01 13:00:42 +01:00
Niko1221 c25a2de200 Sidebar preview: previewable pages open there first, and it comes in and goes out with motion 2026-11-01 12:57:56 +01:00
Niko1221 211ca210fb Code block preview is an icon button left of Copy, with an open and a close state (HTML and Markdown blocks) 2026-11-01 12:57:56 +01:00
Niko1221 b63a82e011 Code blocks: room before and after, a lighter 34px bar and tighter padding (12px floor kept) 2026-11-01 12:57:39 +01:00
Niko1221 e47848a319 Reasoning opens and closes smoothly: grows and fades, hover and press on the line, the rule belongs to the body 2026-11-01 12:57:39 +01:00
Niko1221 0a8499b0a3 Motion system: one press feedback and the reduced-motion gate for it (tokens in liquid.css section 17) 2026-11-01 12:57:39 +01:00
Niko1221 a44bcd4eb5 Copy: no Copied popover at the corner, the icon's check is the feedback (failures still say so) 2026-11-01 12:51:36 +01:00
Niko1221 a8e07c1b52 Version list: tags stay together on one line, a downloaded version shows its size without the word download 2026-11-01 12:51:20 +01:00
Niko1221 6f686536e5 Model versions list: each version once, plain names, small tags, details behind a toggle 2026-11-01 12:50:14 +01:00
Niko1221 f009913c6d Launch: the progress lines name a DeepSeek set as the screen does (ISTA-DAS 2.25 bpw, not 'deepseek41 IST-DAS-2.25bpw'), and the DeepSeek engine package is said to be about 430 MB, not 70 MB (G4 clean-machine findings) 2026-11-01 12:43:37 +01:00
Niko1221 b3dbb2c67c Light mode New chat: the shared primary look, green text on the tint (5.48:1) 2026-11-01 12:41:06 +01:00
Niko1221 63ae1eb3cd Settings: Privacy is one compact paragraph 2026-11-01 12:38:13 +01:00
Niko1221 87610346d6 Skills, Monitor and the other screens share the chat title size (one rule, tested) 2026-11-01 12:37:13 +01:00
Niko1221 7f36ac9b2e Tasks: header is just the title, at the chat's title size 2026-11-01 12:37:08 +01:00
Niko1221 ea6fcea1d8 Window title is Strata on every page 2026-11-01 12:34:39 +01:00
Niko1221 63a9b7d2ee Int3 fix (ds-left 67eb5f1f5 beside fixes 27060866e): the own-files disk verdict reads DeepSeek's memory words, so it only applies to the deepseek41 family; other families keep 27060866e's rule (no disk warning for files used where they are) 2026-11-01 12:34:18 +01:00
Niko1221 13658e773a Engine flow script: the server pid survives (no subshell) 2026-11-01 12:27:09 +01:00
hanchengme 243fdb213c serve: a request commits only the rows it can emit, so the parked snapshot matches what the client got (#4677)
In the single-request speculative paths the accepted rows of a verify window were committed first and the output loop cut to max_new or EOS afterwards, so a request's last window could park a snapshot with tokens the client never received. conversation_prefix() needs the whole snapshot to be a prefix of the next prompt, so the snapshot was refused and the older checkpoint was used (about 6% of a 12K-token prompt read again).
serve_stop_bound() caps the accepted rows at the rows the request can still emit and ends at the first EOS, before pl_commit_async. Merged prompt windows keep committing every row. HIP only (STRATA_USE_HIP), where it was measured.
Patch and policy test (2,736 cases against the old loop) by hanchengme, from the issue's attachment. Two changes on our side: it is on by default, STRATA_SERVE_STOP_BOUND=0 turns it off; the test is registered in CMake.
Measured on 4 x R9700, Qwen3.8-Flash-Next IQ3_S, 2 replicas x 2 cards, --batch 8 --batch-groups 2, four 11-15K-token conversations with 768-token answers, conversations opened one at a time: 4 of 16 continuations fell back to the older checkpoint without it, 0 of 16 with it (reuse 98.36% against 99.74%). Opened together as batch rows both builds miss the same continuations (slot overwritten before it is parked); not changed here.
2026-11-01 12:21:56 +01:00
Niko1221 58aeca07da Int3 merge f-0291-ds-left (67eb5f1f5): DeepSeek plan numbers, false disk warning. dsv41_fit.py: ds-left's accounting replaces the older AMD fit of 896818a3d (AMD_DENSE_ON_GPU_GB / AMD_RESERVE_GB); that commit's test (2,366 / 12,147 within 0.5%) is superseded by ds-left's MeasuredCardAccounting (within 3%, engine startup lines of d93547aed) and is removed 2026-11-01 12:20:52 +01:00
Niko1221 d92b6350ed Engine flow script: release files can be given from a folder (a box without internet) 2026-11-01 12:01:51 +01:00
Niko1221 75d7427789 Revert the T=5 HC down variant choice: in the engine the T5 step stays (hc down 0.77-0.79 ms either way, wall 16.17 against 16.14 ms), the cold screen's 0.7 us did not carry over 2026-11-01 12:01:48 +01:00
Niko1221 098154961d plain-errors test reads the engine catalogs too 2026-11-01 12:01:33 +01:00
Niko1221 295953de95 Engine tab host: a folder name must start with a letter or digit (never an option) before Control is asked 2026-11-01 12:01:33 +01:00
Niko1221 e6bb877df7 Engine tab: real-package flow script, update notes, update-cache test follows the split 2026-11-01 12:01:33 +01:00
Niko1221 5dea0d2abd Settings > Engine lists the installed engines (switch, delete, sizes); About only updates. Updates keep every older engine; strata components engines/use/remove share the app's code; DeepSeek has its own choice that launch, setup and the app follow 2026-11-01 12:01:33 +01:00
Niko1221 b5c237678a First-run model step starts on an installed ready model, else the catalog's recommendation, never the first size
GuidedSetup filled its family and size once at mount. When the catalog had not arrived yet it fell back to the first size in the list (Q2_0, a 66 GB download) even with an installed IQ3_S on the computer. Only the person's own choice is state now; the start is worked out from the catalog on every render (startingChoice in native-catalog.ts): a ready installed version first (the recommended one if it is among them), else the recommendation. No design change.
2026-11-01 12:00:32 +01:00
Niko1221 334e11b794 Int3: test_a_mac_is_stopped_before_anything_else follows the ISTA-DAS spelling of setup's stop message (my change in fbdcd5cbe) 2026-11-01 11:41:51 +01:00
Niko1221 ab583b6ad5 dflash2 GEMM kernels: pick the matrix of a warp's rows with constant indices (the by-value Df2Segs no longer lives in local memory: STACK 104 -> 0 in cuobjdump for every gemm4/gemm8/gemm_rows instantiation) 2026-11-01 11:40:39 +01:00
Niko1221 7d4feb2e8a HC read at T=5: the down kernel takes two rows a warp instead of the batched-staging variant (screen: 15.5 against 16.2 us, bitwise the same dots); mmvq_il_parity gets the 106,299-row draft head case 2026-11-01 11:38:10 +01:00
Niko1221 02996cf733 Int3 fix (test fixture; belongs to f-0291-term-small): StrixHaloCache's fake Strix Halo had vram_gb 2.0 beside shared_gb 112, which setup's detection never produces (vram_gb is the carve-out plus the shared pool); the new uma_cache_guard read the 2 GB as the whole pool and lowered the budget to 4 GiB, so test_cache_is_a_number_that_leaves_room failed. The fixture now says 114 2026-11-01 11:31:43 +01:00
Niko1221 54ef8615bb Int3 merge f-0291-term-small (da8c7f4a4): Terminal fixes and tests 2026-11-01 11:19:09 +01:00
Niko1221 da8c7f4a4f Tests: five terminal tests no longer depend on the box they run on (a system cloudflared, no llama.cpp checkout, Linux library path, the default data folder equal to the typed one) 2026-11-01 11:01:18 +01:00
Niko1221 ce3e621e34 UI merge fixup: five approved-item style blocks brought inside the owner's audit (npm run audit: 6 failures -> 0); behaviour unchanged
The owner's audit now scans every app sheet, and f-0291-ui's appended blocks broke
its rules (flat, tokens only, shadow only on surfaces that overlap content):
- .native-progress (#4802): gradient + --st-sh-pop shadow -> flat --st-accent-tint
  fill, the accent border stays (it still stands out; no shadow).
- .approvaldock > .askcard/.toolcard (#4725) and .approvaldock > .askq (#4244): the
  --st-sh-bar shadow dropped; the accent border that marks them stays.
- .st-code .st-btn--icon[data-copied] (#4748): the literal rgba(255,255,255,.08)
  is now color-mix(--st-code-ink 8%, transparent), the same recipe liquid.css uses
  for the code block edge.
- .quote-selection (#4785) floats beside selected words, so it is added to the
  audit's ELEVATION_ALLOWED list instead of losing its shadow.
2026-11-01 10:59:17 +01:00
Niko1221 fbdcd5cbea ISTA-DAS: the setup message that names the two sets and the DeepSeek doc's setup commands use the on-screen spelling (--model ISTA-DAS-2.25bpw is accepted by model_alias; IST-DAS-... and ist-das-... keep working) 2026-11-01 10:57:29 +01:00
Niko1221 2ff9783ac6 UI merge: the owner's 92 UI passes (f-gui-look working tree, taken read-only) onto f-0291-int3 with the approved UI list
Merged f-gui-look-snap (682c21d64): the owner's uncommitted wt-gui-look work as one
commit on 4cf0d8cd3, 32 files +343/-216 plus the two untracked files
apps/host/src/styles/liquid.css and apps/host/scripts/browser.mjs. Line endings were
normalised from CRLF to LF when the snapshot was taken (the raw working-tree diff of
~14,800 lines is the CRLF flip; nothing of it is carried here). The approved UI list
(f-0291-ui, 14 items) is already in f-0291-int3.

Three-way merge result: no textual conflicts. Files both sides changed, and how each
resolved:
- components/NativeModels.tsx (int3: progress card, context memory + KV hint; owner:
  Manage models ghost/sm + compact dialog, "Other local models" class, Expert scroll
  waits for layout): disjoint hunks, both kept.
- lib/plain-errors.ts (int3: out-of-VRAM rule; owner: unwrap {"error":..} / HTTP 400
  bodies before the rules run): both kept; the unwrap runs first, so a wrapped
  out-of-VRAM message still reaches its rule.
- screens/Serving.tsx (int3: two addresses, CopyBtn; owner: empty-state "Connect an
  app" is the primary button): disjoint, both kept.
- screens/Settings.tsx (int3: While-an-answer-is-written row, Select import; owner:
  Engine panel gets the "Engine" heading): disjoint, both kept.
- styles/app.css (int3: appended blocks + the reading-comfort rule; owner: edits in
  place): auto-merged, both kept.
- styles/onboarding.css (int3: language Select rules appended; owner: welcome
  headline, badge ink, glow removed): disjoint, both kept.
- components/TuneRunView.tsx also auto-merged (int3 +1 line, owner: progress bar uses
  transform).
2026-11-01 10:56:47 +01:00
Niko1221 682c21d641 Snapshot of the owner's UI passes 1-92 (wt-gui-look working tree, uncommitted, taken read-only; line endings normalised to LF; includes untracked liquid.css and scripts/browser.mjs) 2026-11-01 10:54:55 +01:00
Niko1221 8c6c0a8a9f Int3 (test only): cuda_spill_guard's ALLOWED table brought to the measured state. On v0.2.9 the guard already failed on 11 kernel families; now it passes on the merged tree on Windows MSVC sm_120, z440 sm_86+sm_120, boston sm_86, l620 sm_120 and Spark sm_121 and fails again on a real increase. New in 0.2.9: dflash2 gemm4_kernel (STACK 104, Df2Segs struct on the stack: BW-DRAFT) and prefill rms_rope_rows_kernel (STACK 32, RopeTab struct on the stack: STRIX-PF). No kernel changed 2026-11-01 10:52:27 +01:00
Niko1221 424a2af019 Int3 fix (belongs to f-0291-amd-q b9e3e2b86): prefill.cpp's no-fused stub block lacks fused::gfx12_iq2s_default, so a HIP build without STRATA_PREFILL_MMQ (every Windows HIP build that does not pass it) fails to link; release builds pass MMQ=ON and were not affected 2026-11-01 10:48:56 +01:00
Niko1221 7a87440629 DFlash2: drop the 128-thread QK tile (bitwise but 18% slower than the 64-thread tile in the bench) 2026-11-01 10:45:26 +01:00
Niko1221 2b32d0ff0b Int3 merge f-042-maya-rc (690f8b234): DS4_BORROW off by default on Metal 2026-11-01 10:44:03 +01:00
Niko1221 690f8b234a dsv41 Metal: the prompt path does not lend the experts' room by default (DS4_BORROW defaults off on Metal)
M5 Max, DeepSeek 2.25 bpw, 3 interleaved pairs: with the lent room a 639-token prompt took 6.2-7.6 s against 4.1 s with DS4_BORROW=0 (same bytes
read, the compute waited for reads equally long), 8K prompts -3.9%, 32K -20% (borrow also selects the 32,768 chunk); and the engine died
("mtl: wrap 7763574784 failed", exit 4) in 8 of 9 runs of a mixed 165/600-token/8K sequence that the build with DS4_BORROW=0 finished 3 of 3.
Only Metal changes (one_gpu_or is under STRATA_METAL); CUDA and HIP keep devs.size() == 1. DS4_BORROW=1 turns it back on.
2026-11-01 10:42:29 +01:00
Niko1221 9df71d5980 DFlash2: opt-in Q4_0 drafter pack (from 15165e170): dtype q4_0, tools/dflash2_pack.py --q4_0, nibble-packed tiled weights, gemm4_kernel, df2_q4_tile at load, CPU parity test dflash2_q4_parity 2026-11-01 10:20:59 +01:00
Niko1221 d7e50a515b DFlash2 drafter (bitwise): draft head through the interleaved Q5_K kernel, drafts only to the widest window - 1, R-split Q8 GEMM by matrix size, one rope table launch with precomputed inverse frequencies, selector projection beside the head, 128-thread QK tile variant (bench) and bench harnesses that check bitwise equality 2026-11-01 10:20:18 +01:00
Niko1221 0d2ee8defe Int3 fix (test programs only; four were already broken on MSVC in v0.2.9, gr_v3_screen is new): setenv / unsetenv / __builtin_popcount shims so a Windows build with tests ON compiles 2026-11-01 10:05:00 +01:00
Niko1221 4bc7e35e41 Int3 fix (belongs to f-042-strix-pf and f-0291 b89bf5947): Metal links again. stubs_0291.cpp (qsa_prompt_attn_batch_gated answers not taken, rms_rope_rows stops with its name), io_priority.cpp in the Metal host sources (strata_ds4 calls restore_normal_io_priority) 2026-11-01 09:58:02 +01:00
Niko1221 af8b47d45f Int3 fix (belongs to v0.2.9, f-0291 NVFP4 port): mmq_nvfp4_parity uses x86 F16C intrinsics; only built on x86 so an aarch64 CUDA build with tests ON no longer fails on -mf16c 2026-11-01 09:58:02 +01:00
Niko1221 67eb5f1f57 DeepSeek plan: experts on the cards from the engine's own accounting; own files need no free disk
The plan said 2,947 experts for one R9700 and 13,180 for four; the engine held 2,366-2,566 and 12,147-12,192. It took 4.5 GB of dense weights and 1.15 GB of reserve per card and forgot the driver context, the prompt buffers and the dense weights every card repeats. Constants now come from the engine's startup lines (f-042-maya-rc d93547aed): 2.25 bpw one R9700 2,543, four 12,260; boston 2 x A6000 within 1%, A4000 and RTX 5070 within 5%.
strata launch --model-path on files that stay where they are printed 'tight: needs more disk space' with nothing to download: the menu row counted the whole download against Strata's folder. With own files the verdict now comes from the memory words alone.
2026-11-01 09:56:34 +01:00
Niko1221 34c0f7dae7 Launch: a remembered card choice is said when the PC has more cards (NVIDIA DeepSeek after --gpu 0 stayed on one card without a word); Strix Halo: a fixed small expert cache lowers setup's own RAM budget (the person's cache and budget are only warned about) and a silent exit code 1 gets a plain next step 2026-11-01 09:51:26 +01:00
Niko1221 14c15d67a0 Tests: every shared DeepSeek file has a pin, so a copy placed by hand is checked locally and an offline PC with all files can start 2026-11-01 09:51:26 +01:00
Niko1221 ab6dfa03d6 Setup on a Mac: only this user's engine stops a model preparation (pgrep asks for this user's uid), so another person's server on a shared Mac no longer sends setup away with 'stop the server' 2026-11-01 09:51:21 +01:00
Niko1221 3410d5520d Int3 merge f-042-maya-rc (8e8e3cafa): Metal DeepSeek router NaN guard, dense Q4_K dequantizer
# Conflicts:
#	src/dsv41/ds4_gguf.cu
2026-11-01 09:50:55 +01:00
Niko1221 8e8e3cafad dsv41 Metal: router NaN guard in the MSL route kernels, and a dense Q4_K dequantizer; tests for both
Router: ds4_route and ds4_route_publish left an all-NaN gate row's pick at 0x7fffffff and read sc[0x7fffffff] (threadgroup memory) and
vram[0x7fffffff]; the test process dies with SIGBUS on the old kernel. ds4_route_fallback_pick takes the lowest expert not picked yet
(the CUDA/HIP guard's rule) and raises a flag in a buffer of the Metal host; router_nonfinite_take synchronises and reads it
(metal_host::router_bad_take), so ds4_main ends the request with the error it already has. The warning names the GPU on Metal; the
CUDA/HIP text is unchanged. Test: metal/tests/ds4_route_guard_parity.cpp (finite, all NaN, +-inf, half NaN, fewer than k finite).

Dense Q4_K: ds4_gguf.cu rows() sends a dense matrix of a type metal_host::dequant_rows does not know to iq::dequant_rows, whose CUDA
launch has no Metal dispatch and aborts the engine. ds4_dq_q4k (ggml's dequantize_row_q4_K, the Q5_K kernel without the fifth bit) is
added; any other dense type now fails with a plain message naming it. Test: metal/tests/ds4_dq_parity.cpp (Q4_K, Q5_K, Q6_K, Q8_0, BF16
against ref_quant, padded row stride).
2026-11-01 09:49:17 +01:00
Niko1221 02c367d5ce gfx12: fused IQ2_S / IQ4_XS gate/up kernels ON by default (STRATA_PF_FUSED_IQ2S=0 or STRATA_PF_FUSED=0 turn them off); the CPU share limit on gfx12 follows to 1,536 (owner 11-01) 2026-11-01 09:48:15 +01:00
Niko1221 cc703b5e48 #4040 --lookup-max-t 12 by default (serve, 8-row CUDA build, one GPU, no batch/DFlash2/layer split/chain); --lookup-max-t 0 or STRATA_LOOKUP_MAX_T=0 turns it off (owner 11-01) 2026-11-01 09:48:05 +01:00
Niko1221 daed630ed5 ISTA-DAS in the last user-facing lines: the setup --check list and the own-files message print the set's label (ids unchanged); comments 2026-11-01 09:38:03 +01:00
Niko1221 c24a6836aa Int3 merge f-0291-pr-amd (c31f1fa31) 2026-11-01 09:37:11 +01:00
Niko1221 6360274c8d Int3 merge f-0291-df2-spark (15165e170) 2026-11-01 09:37:10 +01:00
Niko1221 de856c7b8c Int3 merge f-0291-bw-next (4c5a55491); generate.cpp: req_df2 and draft_long both kept 2026-11-01 09:37:10 +01:00
Niko1221 ea9a58d8a9 Int3 merge f-0291-bw-df2serve (eeb21d0e4); generate.cpp: draft_df2 and draft_long both kept 2026-11-01 09:36:57 +01:00
Niko1221 0e2f24b6ad Int3 merge f-042-strix-pf (2970fe74a) 2026-11-01 09:36:39 +01:00
Niko1221 34f90e919c Int3 merge f-0291-gh2-speed (899935fae) 2026-11-01 09:36:38 +01:00
Niko1221 9e75dc6cf5 Int3 merge f-0291-pr-nv2 (e20842779) 2026-11-01 09:36:38 +01:00
Niko1221 af1cfe99cb Int3 merge f-0291-pr-nv (5e46c3415) 2026-11-01 09:36:38 +01:00
Niko1221 e208427797 Revert "serve: --lookup-long-match N, a lookup window past 8 rows only where the matched context is N tokens or more (opt-in, with --lookup-max-t)"
This reverts commit 394af4cd0f.
2026-11-01 06:51:24 +01:00
Niko1221 d10d67b005 docs: DETAILS.md, --lookup-max-t row now says 12 and carries the cap and agent-loop measurements 2026-11-01 06:51:24 +01:00
Niko1221 394af4cd0f serve: --lookup-long-match N, a lookup window past 8 rows only where the matched context is N tokens or more (opt-in, with --lookup-max-t)
Keeps the wide lookup sizes for copy-heavy text (a rewrite matches long) and leaves lists and prose, which match short, at the 8-row cap. Off by default. Tried and reverted in the next-but-one commit: it did not remove the prose cost of 16 rows and cost rewrite gain.
2026-11-01 06:51:24 +01:00
Niko1221 5e46c34159 DGX Spark: STRATA_SIDE_MIXER=2 is one of the sm_121 defaults (docs: five speed defaults)
Port of pull request 4737 by InB4DevOps (commit c14ee15d9 on v0.2.8). The mixer's input-only QSA/GDN work runs on graph branches; scheduling only, no arithmetic change.
STRATA_SM121_PERF=0, STRATA_DEFAULTS=026 or STRATA_SIDE_MIXER=0 turn it off. Measured here on a Spark, IQ3_S, greedy: ids identical 48 of 48; alone +1.0..+2.9% per class, with mode 8 +3.6% pooled.
Not yet measured at parallel 4.
2026-11-01 06:06:57 +01:00
Niko1221 f94a27da60 CUDA: grouped native experts use mode 8 on DGX Spark (sm_121) with the sm_121 defaults
Port of pull request 4699 by InB4DevOps (commit 76c0aa601 on v0.2.8). The existing mode 8 kernels are selected for compute capability 12.1 when the sm_121 table is on;
STRATA_SM121_PERF=0, STRATA_DEFAULTS=026 and an explicit STRATA_EXP_MODE keep the old mode 0.
Measured here on a Spark, Qwen IQ3_S, serve, greedy: bit-exact (ids identical 48 of 48), with the side mixer +3.6% decode pooled over 8 prompt classes (4 rounds), mode 8 alone +0.3..+2.4%.
2026-11-01 06:06:56 +01:00
Niko1221 2970fe74a3 QSA prompt attention (matrix-core kernel): less LDS a block and the next chunk's ids read ahead, same output bits
prompt_attn_wmma_kernel was limited by its LDS (21,648 B a block, 5 waves a SIMD), not by VGPRs (amdgpu_waves_per_eu
and amdgpu_num_vgpr change nothing here). Two exact changes, on by default:
- STRATA_PA_TRIM (default 2; 0 off, 1 first level): the score and probability buffers hold the 12 real query rows
  instead of 16 (rows 12..15 of the WMMA are zero queries nothing reads; the 4th wave skips the softmax of them),
  and at level 2 the staged V rows are 72 bytes instead of 80. LDS 21,648 -> 19,008 -> 17,984 B, 7 waves a SIMD.
- STRATA_PA_RPF (default on; 0 off): chunk ci + 1's selected-cell id and page-table reads are issued before chunk
  ci's compute.
The arithmetic and its order do not change. hip_prompt_attn_wmma checksums are identical across all variants (32K
context 8f3a85a9cb885e7e, 8K context de4684fb3f370000), and engine first-token logits with every exact lever of this
branch on against off are byte-identical on a 4.5K and an 8K prompt (IQ3_S pack). Kernel time, gfx1151, int8 KV,
STRATA_PA_FAST: 32K context / 2,048 queries 11.05 -> 9.95 ms, 8K context / 4,096 queries 21.76 -> 18.90 ms.

The idea of raising the kernel's occupancy is the qsa3 change of halo-box/strix-llama.cpp PR 213 (MIT, The ggml authors);
the means here differ and the code is ours.
2026-11-01 05:44:44 +01:00
Niko1221 c31f1fa31b gfx12: matrix-core QSA scorer (STRATA_SELECT_WMMA) past 28,672 tokens by default (changes the answers there; separate, droppable commit)
Needs the owner's yes: the existing opt-in STRATA_SELECT_WMMA=1 with STRATA_SELECT_ALT_MIN_TOKENS=28672 becomes the gfx12 default in
gfx12_extra.hpp, with its own log line.  A chunk whose context is below the floor keeps the warp kernel, so a prompt that stays under about
28K tokens is bit-identical (first-token KL 0.00000 on twelve 8K-24K prompts).  Off: STRATA_SELECT_WMMA=0, STRATA_GFX12_DEFAULTS=0 or
STRATA_DEFAULTS=026; a variable the user sets is kept.  Chosen over PR 3073's simt2 (author Vop3p) on the data.
R9700 (p620, IQ3_S, serve, interleaved pairs, prompt tok/s against unset, 3 pairs): 32K +1.83% (3/3), 98K +5.74% (3/3); first-token KL on seven
30K-96K prompts mean 0.0018, max 0.0055, top-1 7/7; needle recall 25/25 (8K to 96K, 5 depths).  Without a floor: 24K +3.34%, 32K +4.5..6.0%,
98K +6.9..7.3% but values change from 8K up (KL 0.0056); simt2 is slower below 32K.  arch_defaults_test now clears the variables this
default sets before its integrated-GPU checks; gfx12_extra_test covers the default and its off switches.
2026-11-01 04:50:11 +01:00
Niko1221 98d4f2033e Trial fix (belongs to f-0291-pr-intel 8fffe81d4): sycl_queue.hpp line 164 holds a raw NUL byte inside a character constant (the backslash-zero lost), tools/test_source_nul_bytes fails 2026-11-01 04:25:33 +01:00
Niko1221 899935faed Expert arena give-back message: the line ends with a newline escape again (a literal line break had split the string) 2026-11-01 04:03:37 +01:00
Niko1221 10ce6ae508 Trial fix (belongs to f-0291-gh2-speed eb8917247): expert_source.cpp #4846 message has a raw newline inside the string literal (the backslash-n was lost), so the file does not compile on any backend 2026-11-01 04:02:39 +01:00
Niko1221 b25c0c323d Trial2 merge f-0291-dq-test (e6d81d7a2) 2026-11-01 03:46:14 +01:00
Niko1221 0b244c0346 Trial2 merge f-0291-engine-card (27de07cef): NativeModels.tsx imports both EngineUpdateCard and duration; onboarding.css keeps both rule sets 2026-11-01 03:46:14 +01:00
Niko1221 2e8fb221cf Trial2 merge f-0291-gh2-app (0dfa2d9b1) 2026-11-01 03:46:05 +01:00
Niko1221 a17b79a7e8 Trial2 merge f-0291-keepcurrent (94edf723c): ds4_iq.cu keeps dq_old_ok<T>() (already on the line from 9d58393db), which covers keepcurrent's staging-fit condition 2026-11-01 03:46:05 +01:00
Niko1221 01f804ea18 Trial2 merge f-0291-mac-q (8e7ed6818) 2026-11-01 03:45:53 +01:00
Niko1221 9d642d1f71 Trial2 merge f-0291-amd-q (b9e3e2b86): share_rules/prefill.cpp take amd-q's gfx12 share limit; native_bf16.cu keeps bw-dense's per-device mmvf_fast and adds amd-q's STRATA_MMVF_CHECK mode on top (mmvf_fast_bit + mmvf_fast) 2026-11-01 03:45:53 +01:00
Niko1221 3fe5edd315 Trial2 merge f-042-strix-pf (66545a497) 2026-11-01 03:45:21 +01:00
Niko1221 87b2a61251 Trial2 merge f-0291-gh2-speed (eb8917247) 2026-11-01 03:45:21 +01:00
Niko1221 62db0bf10f Trial2 merge f-0291-pr-amd (82eaeb422): verify.cpp head keeps both: pr-nv2's chunked head (h0/HT, up to 8 columns a launch) and pr-amd's fused q8_1 image (q8_head, image at the chunk's own xq_ offset) 2026-11-01 03:45:21 +01:00
Niko1221 5438023a6a Trial2 merge f-0291-pr-intel (2b4d43bac) 2026-11-01 03:44:34 +01:00
Niko1221 2716defb83 Trial2 merge f-0291-pr-nv2 (64bf69080) 2026-11-01 03:44:33 +01:00
Niko1221 1306424beb Trial2 merge f-0291-pr-nv (97b3d614f) 2026-11-01 03:44:30 +01:00
Niko1221 4c5a554912 IQ4_XS 5-column interleaved MMVQ runs as 6 columns with 5 stored (STRATA_IL_PAD, bitwise); --draft auto: MTP requests keep the MTP's prompt-lookup width 2026-11-01 03:28:12 +01:00
Niko1221 d93547aed5 dsv41: no warm_flow on Metal (it aborted the engine at start)
warm_flow() runs cublasSgemm / cublasGemmEx on tiny scratch inputs at load. The Metal compatibility layer (metal/include/metal_compat/cublas_v2.h) only has the dispatches the real flow uses: its sgemm_tn found no host view for the scratch buffers and the call ended in strata_metal_dense_gemm_missing(), an abort (exit code -6) on every start, restarted in a loop by the server. start_warm() now returns at once under STRATA_METAL. Found on an M5 Max with the 2.25 bpw set: the v0.2.9 engine answered the four checks; this tree aborted until DS4_WARM=0 was set.
2026-11-01 03:13:37 +01:00
Niko1221 82eaeb4221 gfx12: STRATA_HEAD_MIX_MULTI=1 by default (exact; own function, droppable alone)
The final hyper-connection mixer of the verify window and of the draft layer as one fused_gr_read_multi instead of one gr_read per
token (PR 3264, author bsorensen110).  Kept apart from the main gfx12 table in gfx12_extra.hpp so it merges with the table on
another branch without touching it; STRATA_GFX12_DEFAULTS=0, STRATA_DEFAULTS=026 and a variable the user sets decide first.
R9700 (p620, IQ3_S, serve, static residency, 8 interleaved pairs with an A/A arm): json_extract +0.82% (6/8), agent +0.63% (6/8),
code_py -0.96% (3/8, that prompt's A/A moves 3.5% a pair); greedy ids identical in 16/16 and 15/16 request sets.
2026-11-01 03:10:59 +01:00
Niko1221 66545a497a QSA norm + rope fusion: keep the square out of the first butterfly add so the sum of squares rounds as rms_rows_kernel's does
On gfx1151 the compiler folded x * x into the first add of block_sum (fma of this lane's x with the other lane's
square), a different rounding than the sum of two rounded squares, so about 4% of rows came out with a scale one ulp
off. An empty asm on the square stops the fold (__fmul_rn compiles to the same fma). Checked in the assembly
(v_mul then v_add) and on the device: hip_qsa_nr_parity 44 of 44 OK, every output element bit for bit, T 1..8191;
engine first-token logits with STRATA_QSA_FUSE_NR / STRATA_QSA_FUSE_GATE on and off are byte-identical on a 4.5K and
an 8K prompt (IQ3_S pack).
2026-11-01 03:06:40 +01:00
Niko1221 64bf690800 window limit: STRATA_WINDOW_MAX_T=12 as a third value (8, 12, 16)
The exact-column instantiations 13..16 of the wide kernels (native MMVQ, the wave variant, the packed Q8_0 and Q6_K launches,
the BF16 multi GEMV) are built only above 12, MAX_NCOLS follows the cap, and kVerifyLongT follows a wide cap (a cap of 12 keeps
--lookup-max-t at 12). Measured for the narrow/wide comparison in phaseA-tests/pr-priorities/RESULTS-NV2.md.
2026-11-01 03:02:28 +01:00
Niko1221 55ab701e1b QSA selector: STRATA_SELECT_ALT_MIN_TOKENS keeps the warp kernel for short reads; the community simt2 default of gfx103x starts at 28,672 tokens 2026-11-01 02:57:52 +01:00
Niko1221 5206a299f8 docs: DETAILS.md rows for --lookup-max-t, STRATA_PF_GROUP_SCRATCH / STRATA_PF_HC_REGION, STRATA_PW_WITH_BATCH, STRATA_MTP_ROOM_MIN, with the numbers measured on f-0291-int-trial
The four switches are ports of PRs 4040 (merbanan), 4039 (merbanan), 3095 (Vop3p) and 3307 (gopinath87607); all stay off by default.
2026-11-01 02:57:12 +01:00
Niko1221 b130965ca6 AMD community tuning: the simt2 QSA selector (PR 3073, author Vop3p) is the default on gfx103x only
Mode 2 of STRATA_SELECT_SIMT was added for HIP in the previous commit as an opt-in.  For the RX 6000 cards its contributor measured it
on (gfx103x) it now joins the one community switch: on by default with STRATA_AMD_COMMUNITY_TUNING on, any STRATA_SELECT_SIMT value
the user sets wins, and no other card changes.  Values change in the last bits (another summation order); the contributor's numbers are
labelled as theirs in docs/AMD_HIP.md and have not been reproduced (no gfx103x card here).  amd_community_test checks the default and
that the switch turns it off.
2026-11-01 02:15:38 +01:00
Niko1221 b3e73dbfb6 Quality ids files: a token id outside the model's vocabulary fails loudly (the engine's --score / --profile-run / --spec-eval, and quant_quality.py run)
data/quant_quality/v1/set.ids.json is tokenized for Qwen (ids up to 248,069); fed to the DeepSeek engine it gave nonsense text and, for the
runs that dump logits, a late "token out of range".  read_score_ids now takes the model's vocabulary and refuses the file at once, naming the
segment, the id, its position and the fix; quant_quality.py run does the same against its config's tokenizer folder (or the vocab_size that
`ids` now records in the ids file it writes).
2026-11-01 01:54:24 +01:00
Niko1221 2b4d43bac2 sycl/CMakeLists.txt: drop the duplicate native_expert_bench target the #4185 port added (this tree has it already) 2026-11-01 01:46:33 +01:00
Niko1221 161eb97fc6 docs/INTEL.md: the #4185 decode kernels, the build option and the controls, with what is on by default 2026-11-01 01:30:29 +01:00
Niko1221 eb8917247a Expert arena: when the RAM still falls short after the wait, the PLE table lock the residency policy took is given back before the arena is registered (#4846) 2026-11-01 01:29:51 +01:00
Niko1221 4a7bb1d06d SYCL: only the six #4185 controls that measured a gain are on by default on Battlemage (MMVQ_SHAPE_RPW, IQ2S/IQ3XXS/IQ3S_GU_ROW_REUSE, GR_LO_REUSE, ATTN_MERGE_ACTIVE); the other eight stay opt-in (=1) 2026-11-01 01:28:51 +01:00
Niko1221 b9e3e2b86a gfx12: opt-in switch STRATA_PF_FUSED_IQ2S=1 for the native fused prompt kernels on the IQ2_S and IQ4_XS gate/up layers (Linux; the IQ3_S pack's 21 layers that keep MMQ), and the CPU share limit drops from 3,072 to 1,536 tokens only when it is on (share_rules::gfx12_share_limit), so a chunk from 1,536 up streams all and runs them. OFF by default: nothing changes without the variable. R9700 (p620, IQ3_S, 54% resident), on against the share-only default: prompt 2K +10.8%, 4K +12.2%, 8K +13.2%, 32K +10.0% (5 pairs each, 5/5), 1K and 1.5K unchanged; values change (first-token KL against the FP16 prompt path 0.0150 vs MMQ 0.0263). The quick quality suite lost 2 of 19 tasks with it on (summ-M-d14, code-S-d04) where off and off again agree, so it stays opt-in until the full 61-task suite has run. Separate commit: drop it alone to go back to the share-only default 2026-11-01 01:16:23 +01:00
Niko1221 8e7ed68181 Metal: STRATA_METAL_NFCHECK=1 counts non-finite logits and the largest |logit| in every head window (test tooling)
The engine's #879 check only sees logits that come back to the host; the Metal engine picks tokens on the GPU, so a half-precision overflow (STRATA_METAL_F16) would show as wrong text, not as an event. With the variable set, each head window's logits are scanned: a line for the first five windows that held a non-finite value, a count every 500 head windows. Off by default, no cost unset.
2026-11-01 01:07:41 +01:00
Niko1221 8fffe81d4b SYCL: the #4185 decode controls default on for Battlemage (STRATA_<name>=0 turns each off), STRATA_SYCL_IQ_DECODE_FAST default ON in CMake and passed by the SYCL build scripts; STRATA_SELECT_GEMM_MIN sets the block threshold of the #3248 GEMM block scores
Controls of glorysdj's pull request #4185: unset means on for a Battlemage device and off for every other device (strata::bm_default_on); a set value still decides. Measured results are in phaseA-tests/pr-priorities/RESULTS-INTEL.md.
2026-11-01 00:50:05 +01:00
Niko1221 3277bfcd71 Metal: opt-in fp16 GEMVs and Q2_0 experts in decode windows (STRATA_METAL_F16=1), from PR 4807 by rasa04
Unset, the output is unchanged (the build with the change, unset, gives the same greedy ids as the build without it in 12 of 12 runs, gemv_parity / gr_parity / expert_parity pass). With STRATA_METAL_F16=1 the decode windows of a resident model use half-precision GEMVs (Q2_0, Q3_K-Q6_K, IQ4_XS) and the Q2_0 routed experts.
M5 Max, spec 3 + min-p 0.5, 3 interleaved pairs, decode tok/s unset -> on: Q2_0 story 105.0 -> 114.9 (+9.4%), code 125.6 -> 143.7 (+15.5%), json 130.8 -> 135.1 (+3.3%, one noisy pair), agent 116.1 -> 131.6 (+13.4%); IQ3_S (dense GEMVs only) story 93.1 -> 104.3 (+8.8%), code 103.6 -> 118.5 (+12.5%), json 122.5 -> 135.4 (+10.6%), agent 103.8 -> 117.8 (+12.3%).
It changes arithmetic: teacher-forced over 4,995 positions against the same build unset, Q2_0 top-1 97.12%, mean KL 0.0094 (p99 0.119), perplexity 4.456 against 4.442; IQ3_S top-1 96.16%, mean KL 0.0160 (p99 0.211), perplexity 4.119 against 4.135. The half range is not guarded (see the PR). Off by default.
2026-11-01 00:39:53 +01:00
Niko1221 7737bca3eb Prompt path: one start-up line says whether the QSA norm + rope and attention-gate fusions are on 2026-11-01 00:34:24 +01:00
Niko1221 27de07ceff App: one card tells a person with an older engine that a newer one is available, with one button (Update engine); #4848
After an app or Control update the engine is never touched, and the app said nothing. Strata Control keeps what the last update or check found newer
(update_available of 'strata components engine', Qwen engine and DeepSeek engine; read without the network); the host passes it to the app as
engine.updateAvailable and the Models screen shows one card in plain words with the existing Update engine job as its button (disabled while a job
runs or a model is loaded). A DeepSeek engine update is reported as an engine update, not as 'Strata is updated'. JavaScript only; the Control side is
on f-0291-keepcurrent.
2026-11-01 00:34:13 +01:00
Niko1221 3304347bde dsv41 (gfx11 dequantizer): dq_old_ok is __host__ __device__, so the CUDA build can call it from dq_rows_kernel
c9e0d6688 uses it in a __global__ function; nvcc refuses a constexpr host function there (error: calling a constexpr __host__ function from a __global__ function is not allowed). HIP accepted it, CUDA did not: the f-042-maya-dq line did not build for NVIDIA.
2026-11-01 00:32:39 +01:00
Niko1221 9d58393dbd dsv41 (gfx11): the dense dequantizer defaults to dq_rows_kernel2 on every type; the old row-per-lane kernel is DS4_DQ_OLD=1 only, and only for types its staging serves
The old kernel (the gfx11 default since 0.2.7, picked for speed) gave different dense weights on every run on a Strix Halo: layer 0 of a
200-token prompt differed by 27% between two runs of the same engine on the 2.25 pack (Q5_K) and by 26% on UD-IQ1_S, while dq_rows_kernel2
is bit-identical run to run and 1.2-1.3% from the window path (GEMV, no dequantizer).  A gfx11 device now defaults to kernel2 whatever
DS4_DEFAULTS says; DS4_DQ_OLD=1 forces the old kernel there and elsewhere, but only for a type whose staging fits (Q4_K, Q5_K, Q2_K:
dq_old_ok); Q6_K (840 bytes against the 704-byte buffer) and the rest always use kernel2.  2.25 prompt speed on the Strix Halo: 4K 166.1 -> 164.9
tok/s, 8K 297.9 -> 294.2 (3 runs each, -0.7% / -1.2%).  UD-IQ1_S's long prompts and agent loop work.
2026-11-01 00:22:11 +01:00
Niko1221 db1bfaaae1 mtp: the draft layer's fused q8_1 images follow STRATA_QFUSE_HC and gfx12 only, not STRATA_QFUSE (gfx1151 keeps what it ran) 2026-10-31 23:58:11 +01:00
Niko1221 606f6272dc serve: STRATA_MTP_ROOM_MIN, a long prompt takes the MTP drafter's 512 decode-only experts as ring (opt-in)
Port of PR 3307 by gopinath87607 (rebased onto f-0291-int-trial, three commits squashed; the mtp.cpp signatures keep the
gated-proposal DraftResult argument and the prompt-helper arming keeps the failed-allocation cudaGetLastError).

STRATA_MTP_ROOM_MIN=TOKENS (--serve with --mtp): a request that reads at least TOKENS prompt tokens points the prompt
path's ring at the drafter's 512 routed experts while it is read. The block is lent in place: never freed, never allocated
again, never moves, so no prompt path is laid out again and the decode's captured graphs stay valid; the decode's own bytes
come back (a DMA from a pinned copy) before the first window. Unset, nothing of it runs.
2026-10-31 23:51:16 +01:00
Niko1221 f7bd10209f ds4_route_guard_test: the non-finite flag 2026-10-31 23:42:48 +01:00
Niko1221 97b3d614fc hc_q8_stream_test: spin on the clock where __nanosleep does not exist (sm_60 / sm_61), so the test target builds with tests on for Pascal
Follow-up to the port of pull request 4715 by cedrik-fuoco: the test failed to compile on sm_60 (identifier __nanosleep is undefined).
2026-10-31 23:42:10 +01:00
Niko1221 c940106485 prefill: say when STRATA_PF_GROUP_SCRATCH / STRATA_PF_HC_REGION took effect, and why GROUP_SCRATCH is ignored where the layout is not all MMQ
Follow-up to the port of PR 4039 by merbanan. The switches stay opt-in and the prompt path is unchanged when they are off;
the lines are the activation evidence the test plan asks for (chunk, rows held).
2026-10-31 23:41:31 +01:00
Niko1221 03627326dd serve: --lookup-max-t N, prompt-lookup windows up to 16 tokens while the MTP keeps --spec (opt-in)
Port of PR 4040 by merbanan (rebased onto f-0291-int-trial, bitwise by the author's checks).

--lookup-max-t N (up to 16) lifts only the prompt-lookup windows to N tokens; the MTP drafts keep --spec. A window
past kVerifyMaxT tokens runs as two token groups of at most kVerifyMaxT (the --spec-split layout), the head runs in
column chunks, the batched GDN commit takes up to 16 kept rows, MtpDrafter::draft_long catches the drafter up past its
window, and every lookup size is its own window graph (--lookup-sizes A,B picks them; one that finds no VRAM is dropped).
A build whose windows already hold 16 rows (STRATA_WINDOW_MAX_T=16, the HIP default) runs the 16 as one group.
Batch, pipelined and layer-split windows stay at kVerifyMaxT. Without the flag nothing changes.

Port notes: kVerifyLongT = 16 for both window widths, the per-window-size graph arrays keep the trunk's
[sel_long_][T] layout, the head chunk loop keeps fused_gr_read_multi's own slicing, the penalty history is sized for
the widest window only when --lookup-max-t is past 8, and draft_long forwards the gated-proposal view.
2026-10-31 23:38:42 +01:00
Niko1221 3b3f3b6bd0 prefill: STRATA_PREFILL_CPU_SHARE_BATCH=1 (opt-in) gives the prompt path the CPU pool beside batch slots, for a read with no slot decoding
Port of pull request 3382 by Vop3p (commit 2b65710f9 on v0.2.0-beta), rebased by hand on the 0.2.9 trial tree.
Conflict in generate.cpp: the share gate goes first in run(), before the co-read branch, and the kva_floors / set_cell_floor bookkeeping after the read is
kept as it is. Addition to the original: a read run on its own thread beside decoding slots (STRATA_BATCH_CONCURRENT_READ=1, run_conc) keeps the share
blocked for the whole read and does not touch the slots' state from the reader thread. Off unless the variable is set.
2026-10-31 23:36:21 +01:00
Niko1221 1f8ca8de1e RDNA4 routed-expert, draft, shared-expert and head kernels (PR 3259 and PR 3264, author bsorensen110)
Ports the two RDNA4 pull requests onto this tree by hand (three-way merge of the rejected hunks).  Where the contributor made a
default depend on a device query, the default is the gfx12 table or the env variable instead, so STRATA_GFX12_DEFAULTS=0 still
gives the old behaviour; the LDS-only barriers and the S2 and head changes that were only run on gfx1201 apply to gfx12 only,
every other target keeps what it ran before.

- iq_kernels.cu (3259): LDS-only barriers in the grid / activation staging, first-pass barrier skipped, vectorised ed_stage_entry,
  expdec_gu_kernel launch bounds in waves per EU on HIP, EXPDEC down read at first use; expdec_bench builds on HIP.
- s2_expert_grouped.cu (3264): single-entry gu/down specialisation for the draft layer, fused swiglu + q8_0 on the hit paths,
  LDS-only barrier on gfx12.
- verify_kernels.cu, quantize_act.cu, elementwise.cu, shared_expert.cu, fused_gr.cu, mtp.cpp, verify.cpp (3264): multi-block argmax
  and top-prob on gfx12, warp-per-block quantize_q8_0_scaled, mapped copies in one launch on HIP, shared expert reuses the q8_1
  image, counter-free q8_mixed in the hyper-connection read, the draft layer and the head write the q8_1 image themselves.
- parity tests of both pull requests (verify_parity on HIP, s2/shared/gr parity cases).
2026-10-31 23:35:50 +01:00
Niko1221 15165e170b DFlash2: opt-in Q4_0 drafter pack (dtype q4_0, tools/dflash2_pack.py --q4_0): nibble-packed tiled weights, gemm4_kernel on the existing dp4a activations path, df2_q4_tile at load, CPU parity test dflash2_q4_parity (40 of 40 cases within 2.4e-6 of 19) 2026-10-31 23:33:22 +01:00
Niko1221 55ae7814d8 HIP SIMT2 selector (PR 3073, author Vop3p) 2026-10-31 23:33:11 +01:00
Niko1221 1db96cda5f serve --draft auto: --draft-rule learn picks per kind of request (first prompt tokens) the drafter with the better measured decode speed, with exploring 2026-10-31 23:32:24 +01:00
Niko1221 3eb91676df AMD community tuning: gfx1010 SDWA dot4 and two gfx1030 rocBLAS tables under one switch
Ports three community changes that we have no card to run (no gfx1010, no gfx1030 here), on by default under the one
setting STRATA_AMD_COMMUNITY_TUNING (=0 turns the tables off; cmake -DSTRATA_AMD_COMMUNITY_TUNING=OFF compiles the portable
dot for gfx1010 again).  Each is limited to the card or exact library build it was made for.

- gfx1010 (RX 5700 / 5700 XT) signed dot4 takes the SDWA byte-select sequence gfx1012 already used: PR 4855, author Keoian.
  Same arithmetic, four signed byte products accumulated modulo 2^32.
- rocBLAS table gfx1030 / 5.4.0.ad84daa81c (TheRock gfx103X-all 7.13 wheels): PR 3343, author elightcap.
- rocBLAS table gfx1030 / 5.2.0.dabb6df2b98-dirty (Arch Linux ROCm 7.2.4): PR 3924, author schmittcn.

include/strata/core/amd_community.hpp holds the switch, the list of community tables and the engine log line (printed only
on gfx1010 and gfx1030); gemm.cu refuses a community table with the switch off, the existing loader still refuses any table
whose architecture or full rocBLAS version string differs.  amd_community_test checks that the switch turns each one off.
The contributors' numbers are labelled as theirs in docs/AMD_HIP.md and have not been reproduced.
2026-10-31 23:30:43 +01:00
Niko1221 7a498a05b4 serve --draft auto: the idle drafter's upkeep is queued on its own stream and waited for before the next window (DFlash2 context_async / context_wait, MTP prefill without sync) 2026-10-31 23:29:51 +01:00
Niko1221 3cc47f0297 prefill: STRATA_PF_HC_REGION=1, the hyper-connection read's scratch in the shared region (opt-in, bitwise)
Port of PR 4039 by merbanan (rebased onto f-0291-int-trial; same bits).

The hyper-connection read's scratch (xn16, lo, lo16, gated and the BF16x2 low halves) is taken as a fourth set of the
region the attention half and the MoE half share: a half's read is done with it before that half's set is written, and
the fused write at a half's end (gr_write_norm_rs: the next half's xn16) runs after the half's last region reader on the
same stream.  The same kernels on the same values: the same bits, ~63 KB a token less outside the region, so --prefill
auto finds a bigger chunk.  Off by default, and off with the unfused hyper-connection path.

bytes_needed also counts STRATA_PF_GROUP_SCRATCH's larger bounds table (bounds_ints), as carve takes it.
2026-10-31 23:29:01 +01:00
Niko1221 fbf6e1c753 prefill: STRATA_PF_GROUP_SCRATCH=1, the MMQ path's GU / H / Hq hold one expert group's rows (opt-in, bitwise)
Port of PR 4039 by merbanan (rebased onto f-0291-int-trial; the group-scratch path stays off for weight-stationary layers, whose launches write the whole chunk's rows into GU; the WS offsets follow bounds_ints).

A streamed prompt chunk sends every non-resident expert of every layer over PCIe once, so the chunk size sets
how often an expert crosses the link per token.  The chunk is capped by its buffers, which live in the cache
slots the prompt path borrows; on an 8 GB card (RTX 2060 SUPER, IQ3_XXS, 1453 slots) a 4096-token chunk took
1198 of the 1325 lendable slots, and --prefill 8192 fell back to 4096.

The MMQ path's gate/up output, its SwiGLU and its q8_1 copy were sized for the whole chunk (T*K rows, ~84 KB
a token), but a group's products read them and are done before the next group starts.  With the switch they
hold one group's rows: gate/up writes its absolute rows at GU - ra, SwiGLU / quantize / down read them from 0.
The cap is T (one expert's most: a token routes to an expert once) plus three times a group's average; a
group that passes it runs as sub-ranges of its experts, each with its own relative bounds appended to the
bounds table.  Only on a layout whose every layer takes MMQ (no fused experts, no FP16 fallback); a layer
that would take another path stops the prompt with a message instead of overrunning the smaller buffers.
STRATA_PF_GROUP_CAP=<rows> forces a smaller cap (test knob).

Measured (RTX 2060 SUPER, Ryzen 9 3900X, Qwen3.8-Flash-Next GSQ-RCO IQ3_XXS, resident RAM, k8v4 + KV
streaming; v11 at --prefill 4096 vs this at 5120, 3 pairs, 3 requests per load):
- --prefill auto: 4096 tokens (1197 slots) -> 5120 tokens (1219 slots); 6144 and 8192 still do not fit
- prefill 4K 445.4 -> 449.3, 16K 456.8 -> 493.5 (+8.1%), 32K 456.2 -> 504.5 (+10.6%); decode +0.1 / +0.4 / -0.9
- first-token logits after a 15.6K-token prompt at the same chunk size: bitwise equal to the full buffers;
  with STRATA_PF_GROUP_CAP forcing sub-ranges on most groups KL 0.001 (MMQ's stream-k order per launch)
2026-10-31 23:29:01 +01:00
Niko1221 5a41f3b391 SYCL: #4185 opt-in mixed-IQ3 decode optimizations on Battlemage (from glorysdj's PR #4185), ported onto this tree, not yet measured
Ported from glorysdj's draft pull request #4185 (commits 961f62963 and 0726ef5ad of that branch; the documentation commit is not taken).
A compile option STRATA_SYCL_IQ_DECODE_FAST (default OFF: packed Q2 expansion, IQ4 bit-select lookup, fast IQ3_S sign arithmetic) and fourteen runtime controls, all default off:
STRATA_IQ3S_GU_SLM, STRATA_IQ4NL_DOWN_SLM, STRATA_RESIDENT_PLAN_COMPACT, STRATA_IQ2S_GU_ROW_REUSE, STRATA_IQ3XXS_GU_ROW_REUSE, STRATA_GR_CACHED_NORM, STRATA_GR_LO_REUSE, STRATA_MMVQ_SHAPE_RPW, STRATA_GDN_QK_REG, STRATA_EXPERT_ROW_E3, STRATA_Q6_SHAPE_GRF, STRATA_IQ3S_GU_ROW_REUSE, STRATA_GR_REDUCE_ILP, STRATA_ATTN_MERGE_ACTIVE.
Conflicts resolved against this tree: the kprof launch wrappers kept (new launches in the conflicted hunks use strata::kprof::kp), the resident-plan launch keeps the kprof wrapper with the compact thread count, native_expert_bench.cpp keeps this tree's fixed bench (the PR's older version of the same hunks is dropped), qsa_prompt_attn_parity.cpp keeps the per-cell check and adds the PR's FP32HASH line and QA_FP32_GRAPH timing.
Also in the diff: docs/INTEL.md section from the PR.
2026-10-31 23:26:20 +01:00
Niko1221 b3ff140d68 Prefill: the fused ring's layer check is cached per CUDA device, so a layer split over unlike cards keeps MMQ's buffers where it must
Port of pull request 4380 by moses-sun (commit 5d5e380c4 on v0.2.7), applied without conflict. Refs issue 3577. Homogeneous rigs answer what the single cached bool used to;
not run on a heterogeneous split (no such box here).
2026-10-31 23:26:18 +01:00
Niko1221 0922b91b64 prefill: layer-major walk expands the hyper-connection projections on the path's stream; test that hc_q8_to_bf16 honours the stream it is given
Port of pull request 4715 by cedrik-fuoco (commits bd1410701 and d5a0d8294 on 0.2.7.1). The three bf16_proj calls of the layer-major walk (now in Prefill::lm_run)
omitted the stream argument; under STRATA_HC_REQ8=1 the int8 hyper-connection codes were expanded on the default stream while the GEMMs read them on another.
2026-10-31 23:26:06 +01:00
Niko1221 1b1a28e77e prefill: STRATA_PREFILL_CPU_SHARE_BATCH=1 (opt-in) gives the prompt path the CPU pool beside batch slots, for a read with no slot decoding
Port of pull request 3382 by Vop3p (commit 2b65710f9 on v0.2.0-beta), rebased by hand on the 0.2.9 trial tree.
Conflict in generate.cpp: the share gate goes first in run(), before the co-read branch, and the kva_floors / set_cell_floor bookkeeping after the read is
kept as it is. Addition to the original: a read run on its own thread beside decoding slots (STRATA_BATCH_CONCURRENT_READ=1, run_conc) keeps the share
blocked for the whole read and does not touch the slots' state from the reader thread. Off unless the variable is set.
2026-10-31 23:25:45 +01:00
Niko1221 5273fb667d prefill: STRATA_PREFILL_ROUTED_STREAM=1 (opt-in) does not copy the streamed experts no token of the chunk routes to; test
Port of pull request 4283 by bitaccesscomau (two commits 3956b94a7 and fcb0e399c on v0.2.8), applied without conflict on the 0.2.9 trial tree.
Off unless the variable is set; STRATA_PREFILL_ROUTED_STREAM_DEPTH sets the copies queued ahead (default 16).
2026-10-31 23:25:11 +01:00
Niko1221 d34c9a31ef SYCL: #3248 prompt kernels (from maxfridbe's PR #3248): per-cell prompt attention fallback, QSA block scores as oneMKL GEMM tiles
Ported from maxfridbe's pull request #3248 (commits e55a11c08 and 8cc654f23 of that branch).
- STRATA_ATTN_PERCELL (default on): qsa_prompt_attn_percell scores one work-item per cell in the prompt path's fallback attention (reached when the Battlemage ESIMD kernel refuses the pools, e.g. FP16 or Q4_0 KV, or STRATA_PROMPT_ATTN_ESIMD=0). =0 keeps the warp kernel. Not bitwise: FP32 in another summation order.
- STRATA_SELECT_GEMM (default: from 2048 blocks): QSA block scores as oneMKL FP32 GEMM tiles plus qsa_block_scores_reduce; =0 the warp kernel, =1 always. Not bitwise.
- qsa_prompt_attn_parity also checks the per-cell kernel and is registered with ctest.
Conflict resolution against the tree: qsa_select.dp.cpp keeps qsa_block_scores_rows beside the new reduce kernel; docs/INTEL.md keeps this tree's text (the PR's table is not copied; measurements are in the Intel notes).
2026-10-31 23:25:08 +01:00
Niko1221 8dcda377a0 SYCL: #3248 correctness fixes (from maxfridbe's PR #3248): host-only copies on the CPU, s2 grouped kernels without FMA contraction, gr_parity QFUSE skip, crossover cache dir
Ported from maxfridbe's pull request #3248 (commits d95a9c4b0, 5fbef34f9, 344e43e1d, c44fb63be of that branch), cherry-picked without the prompt-kernel commits.
- conversation_snapshot: a copy with neither end in device memory (pinned host <-> pageable host) is a memcpy on the CPU; through the queue it runs on the Arc's copy engine, which the PR reports hanging (Engine reset: engine_class=bcs).
- s2_expert_grouped: #pragma clang fp contract(off) in the two transposed grouped kernels (fixups.py 9d keeps it across re-migrations; 9c the snapshot test's sycl::free).
- gr_parity: the STRATA_QFUSE read check is skipped when the SYCL fused GR read writes no q8_1.
- strata-sycl.sh: STRATA_CACHE_DIR defaults to the mounted data root so the int8 crossover cache survives the --rm container.
2026-10-31 23:24:31 +01:00
Niko1221 d17c52e7d0 batch: STRATA_PW_WITH_BATCH=1 keeps --pipeline-windows for the solo path beside --batch slots
Port of PR 3095 by Vop3p (rebased onto f-0291-int-trial; the slot drafter load and bind
conflicts with STRATA_PIPED_MTP resolved by keeping both).

With --batch slots a request that is alone runs the solo path, and that path can keep
--pipeline-windows: both parities' verifiers share the stage's session, so copy_to_slot reads a
whole state after the pipeline drains. With --batch-mtp the slot drafters follow the solo drafter
to the first stage's card when mtp_first. Opt-in; off it is unchanged.
2026-10-31 23:23:52 +01:00
Niko1221 ce2098dcc9 dsv41: the dense-weights check refuses only when the free memory itself is smaller; between that and the reserve it warns
The previous commit stopped whenever the dense weights did not fit in free memory minus --reserve-mib. They would load in the margin, so that case is now a warning (the load goes on) and only need > free memory, where the cudaMalloc cannot succeed, ends the start.
2026-10-31 23:18:37 +01:00
Niko1221 dc1bc4c34d dsv41: the router tells the request when it met NaN scores
The router's stand-in pick (previous commit) keeps a NaN from becoming a memory fault, but the layer's output is garbage and the request would stream nonsense. route / route_publish now set a device flag when no pick came out; ds4_main polls it at the first token and every 8 (one small async copy and a stream wait, not a per-token sync), and the model says once in the log which device met it. The request ends with 'ERR the model's numbers went non-finite ...' and the conversation is dropped (reset), the same ERR-then-DONE shape as a failed prompt. The --parallel loop is not covered; the host-routed window path already errors on a non-finite weight.
2026-10-31 23:18:37 +01:00
Niko1221 94edf723ca DeepSeek engine updates move to the explicit update; a start only repairs a stale path (replaces the start-time feed check)
Starting never reaches the feed again (test_no_acquisition passes unchanged). What a start does now, locally: a saved profile whose DeepSeek engine
folder an update removed is pointed at the newest installed package of its own kind (strata_control/dsv41_paths.py, called from runtime.start), which
fixes the 'engine could not start: file not found' after a strata launch updated the engine beside the app.

The explicit update (strata update, the app's Update engine, updater.run_update with the engine) now also updates an installed DeepSeek engine
(updater.update_dsv41): the feed's newer package of the same kind is downloaded, checked (signature, every file), unpacked next to the old one,
started once (--version), and only then every saved run config is pointed at it; older folders go, except one a running engine uses. update --check
reports it; the check kept after any update or check also feeds engine_component.cached_offers / status()['update_available'], which the app reads
without the network to show one card. strata launch's own upgrade (dsv41_engine.py) is left as it was.

Tests: tests/terminal/test_dsv41_update_path.py (the old keep_current tests, adapted, plus a start that makes no request).
2026-10-31 23:15:10 +01:00
Niko1221 0dfa2d9b19 App: a model install that also updates a working engine says from which version to which, that the old one is kept and that Settings can go back to it (#4823) 2026-10-31 23:12:53 +01:00
Niko1221 2473978007 Server: on /v1/messages a context overflow starts with Anthropic's own wording (prompt is too long: N tokens > M maximum, or input length and max_tokens exceed context limit) and keeps Strata's sentence after it, so Claude Code compacts or shortens max_tokens instead of retrying the same request (#4838) 2026-10-31 23:12:06 +01:00
Niko1221 6bd2565594 App: a tuning that did not finish puts the tuner's own reason and what this PC has (memory, graphics card) behind Details, and says to open it, instead of 'could not start with these settings' alone (#4805) 2026-10-31 23:12:06 +01:00
Niko1221 1ab4c2ce46 serve: --draft auto keeps both drafters current; a request picks the MTP or DFlash2 (draft= key, --draft-rule), own window width and policy per drafter 2026-10-31 23:07:24 +01:00
Niko1221 d2068bde56 CPU prefill share on by default for a single gfx12 card (R9700 / RX 9070 / RX 9060): the CUDA change's logic (share_rules::default_share_limit, staged chunks up to 3,072 tokens, only where experts are missing, one card, no batch slots) now also arms on HIP when arch_defaults::gfx12_prefill_share_default says so; gfx11 (Strix Halo, 780M, RX 7000), other AMD families and multi-card AMD stay off until measured; STRATA_GFX12_DEFAULTS=0, STRATA_DEFAULTS=026 and STRATA_PREFILL_CPU_SHARE (0 / auto / a number) decide first; R9700 (p620, IQ3_S, 54% resident): prompt 500 +18.3%, 1K +30.6%, 2K -0.19%, 8K -0.18% with it set by hand, first-token KL 0.022 at 1K (29 prompts, top-1 29/29) 2026-10-31 23:07:11 +01:00
Niko1221 3410a85881 QSA prompt attention: the output gate in the matrix-core kernel's epilogue (STRATA_QSA_FUSE_GATE)
qsa_prompt_attn_batch_gated writes half(attn * sigmoid(gate)) straight from the epilogue of
prompt_attn_wmma_kernel, with the expressions of gate_attn_kernel (a * (1 / (1 + expf(-g))), round to
FP16), so the image the o-projection reads is the same bits and the FP32 attention buffer is neither
written nor read back (about 0.4 GB a QSA layer at 8K tokens). Used only where that kernel runs (HIP,
int8 KV, STRATA_HIP_WMMA) and the output is not rotated back (kv_rot / hybrid K8V4 keep gate_attn);
anything else, and STRATA_QSA_FUSE_GATE=0, runs the two-step path. Both prompt-layer loops use it.

Exactness is checked end to end (first-token logits and greedy ids with the switch on and off); there is no
kernel-level test for it yet. Idea from halo-box/strix-llama.cpp PR 213 (attention gate fused into the QSA
prefill epilogue, MIT, The ggml authors); the code is ours.
2026-10-31 23:06:54 +01:00
Niko1221 de83080059 CPU prefill share: the default limit goes from 1,024 to 3,072 tokens (CUDA, one GPU, only where experts are missing): TTFT of a 1-2K prompt on a card holding half the experts drops 30-60%, P100 +25% at 1K and +13% at 2K, 4K and above unchanged; share_rules::default_share_limit keeps a class gate, STRATA_PREFILL_CPU_SHARE_MAX=1024 restores the old limit, unit test added 2026-10-31 23:05:20 +01:00
Niko1221 0a02ee4108 Launch: a runtime host left in failed or stale is cleared before the tuning and before the start, the 'profile already has a live runtime' message names launch --stop and --retune, and --save-preset is kept when the start fails after the tuning (#4824) 2026-10-31 23:03:01 +01:00
Niko1221 f6eed469e7 dsv41: a non-finite router input no longer reads outside the expert tables
When every score left is NaN (the gate's input went non-finite), nothing beats -FLT_MAX in route_kernel / route_publish_kernel, the pick stays at its 0x7fffffff start, and the next lines read sc[0x7fffffff] (shared memory), vram[0x7fffffff] and, in the prompt path, vs[0x7fffffff] in moe_sort_kernel: a memory fault in a kernel far from the cause. The thread that picks now takes the lowest expert not picked yet when no pick came out (one compare a pick on the finite path); the weights stay NaN as before. The host-routed window path, which has the picks on the host anyway, ends the request with a clear error on a non-finite weight.

tests/dsv41/ds4_route_guard_test.cu (target ds4_route_guard_test, needs a GPU, no model): valid and distinct picks for NaN / inf / partly NaN logits, the host's top-k when finite, and launch times (DS4_ROUTE_TIMING=1).
2026-10-31 22:57:45 +01:00
Niko1221 8b1cae731c Engine install: a checked engine that Windows will not let Strata rename into place is copied instead after a 40 s wait, and the first-run card says why it stopped (Details, plain next step, Continue named) instead of the generic sentence (#4853) 2026-10-31 22:57:23 +01:00
Niko1221 3949737f1a Trial fix (belongs to f-0291-bw-dense): fused_gr.cu ldg_v4 PTX asm and gr_v3 --resources used cuda-only names on HIP; plain load / no-op on HIP 2026-10-31 22:56:06 +01:00
Niko1221 14a85eca91 QSA prompt layers: K, query and indexer-query norm + rotary in one pass each (STRATA_QSA_FUSE_NR)
rms_rope_rows replaces split_q + rms_rows + rope for the query and rms_rows + rope for K and the indexer query.
It uses the same block_sum, the same (s * x) * w product and rope_kernel's own angle and rotation expressions, so
the outputs are the same bits; the traffic drops from about 1.1 GB to 0.4 GB a QSA layer at 8K tokens. On by
default, STRATA_QSA_FUSE_NR=0 runs the old chain.

hip_qsa_nr_parity compares every output element bit for bit against the old chain (T 1..8191, four start
positions, NaN-poisoned outputs); hip_pf_cover_test poisons every output of the gfx11 prompt GEMMs (wmma_gemm.cu)
and checks that each element is written and that sampled rows match an FP64 reference. Neither has run on a GPU yet.

Idea from halo-box/strix-llama.cpp PR 213 (fused RMS norm + RoPE, MIT, The ggml authors); the code is ours.
2026-10-31 22:52:23 +01:00
Niko1221 d4d0c30da5 DeepSeek engine: the app's start keeps the engine current (not for the 0.2.9.1 hotfix; for the next normal release)
Before runtime start, a saved profile that runs strata-dsv41 from an engine-dsv41 folder is pointed at the newest installed package of its kind
(every saved profile's run config, not only the one started), and the signed feed is asked for a newer package at most once in six hours (half an hour
after a failed check). The package is fetched by a separate background process the start waits for at most 90 seconds; after that the start goes on
with the installed engine and the fetch ends in the background. Nothing in it can fail a start. A package folder a running engine uses is never removed
(also in upgrade() and install(), so a strata launch beside the app is safe), and two fetches never run at once.

Known: tests/terminal/test_no_acquisition.py::test_the_deepseek_engine_check_is_reached_only_from_launch_and_setup fails by design, because a start now
reaches the feed (owner rule: starting never acquires). It needs the owner's decision before this branch merges.
2026-10-31 22:51:27 +01:00
Niko1221 e6d81d7a2a SYCL: dq_dense_parity, the dense dequantizers and mmvq against the CPU dequantizers with random blocks 2026-10-31 22:50:06 +01:00
Niko1221 93b35ee84c dsv41: a GPU too small for its layers' dense weights fails at the start with what it needs, instead of a cudaMalloc error part way into the load
Port of Project Maya 8b56730ad (MIT): the dense weights (attention, shared experts, head) always stay in VRAM, so a device whose free memory cannot hold its whole layers' dense weights is told so before anything loads. Devices with tensor-parallel members are not checked.
2026-10-31 22:47:00 +01:00
Niko1221 cb49530dd6 DeepSeek: a model-free test of the dense dequantizers, GEMVs and chunk GEMM for every dense weight type (ds4_dq_test)
Random legal blocks against a CPU reference: iq::dequant_rows through both dequantizers (DS4_DQ_OLD=0 / 1 / the default), iq::mv with 1..16 columns, f16_rows, ds4_gguf.cu's rows and mv on Q8_0 / BF16 / ggml types, the library GEMM and the WMMA kernel after the dequantizer, and a repeat path that runs one call many times. Shapes include k % 1024 == 0, which ds4_iq_test never ran.
2026-10-31 22:44:22 +01:00
Niko1221 c60833cc38 dsv41: a run of chunks drains the recent slots' fills whenever recent slots exist, not only while the one-GPU path is on
one_gpu_plan may turn ln_one / ln_land off after the lane timing, but recent_n and the slots' fills stay (recent_n is set at the load, before the plan). borrow_begin then skipped rc_drain: a fill done but not yet mapped, or still writing, met the run's buffers in the tail, and the tick after the hand-back mapped its expert over what the run wrote (NaN routing, then an illegal memory access on a long prompt after shorter ones on one GPU). rc_in_tail() (recent_n > 0 and the fill stream exists) now also triggers rc_drain.

Port of the idea of Project Maya v1.1.45 (c968b9486, MIT): our rc_drain has no for_lend argument and this tree has no x3 / copy_flush, so only the condition is taken.
2026-10-31 22:32:46 +01:00
Niko1221 711bba9cc9 Version 0.1.55.1 for the DeepSeek engine hotfix (the CMake project version; the Terminal, the app and Control keep their 0.2.9 / 0.1.55 packages) 2026-10-31 22:32:31 +01:00
Niko1221 8422d864c8 dsv41 (gfx11): the old dense dequantizer stages only what fits its buffer, so a Q6_K dense matrix no longer overflows into the next warp's
dq_rows_kernel, the default dense dequantizer on gfx11 (Strix Halo), stages a warp's 32 sub-blocks in a 44-uint4 (704 byte) shared buffer.
Four Q6_K blocks are 840 bytes, so the staging ran into the next warp's buffer and the dense rows came out wrong: NaN in layer 0 of every
chunked prompt (96 tokens and more) of a UD-IQ1_S or UD-IQ1_M set, whose attention is Q6_K. Q3_K (440 bytes, not a multiple of 16) read 8
bytes past what was staged. Q5_K (704 bytes, the ISTA-DAS sets' dense type) fits and keeps the staged path. Stage only when the span fits
and is a multiple of 16 bytes.

(from d6be7ff6a on f-042-strix-ds)
2026-10-31 22:32:31 +01:00
Niko1221 beae52dfc5 MMVF check: build fixes (volatile loads as two floats, format string) 2026-10-31 22:16:15 +01:00
Niko1221 0ceb8ba68e Trial fix (belongs to f-0291-ui): markdown-images and markdown-soft-breaks stub './ui' without Icon, which CopyBtn (#4748) now imports (reliability.test.mjs already has it) 2026-10-31 22:13:29 +01:00
Niko1221 007fbd1ed2 Trial fix (belongs to f-0291-bw-dense): fused_gr.cu ldg_v4 PTX asm and gr_v3 --resources used cuda-only names on HIP; plain load / no-op on HIP 2026-10-31 22:11:27 +01:00
Niko1221 0f31e635a3 dsv41 (gfx11): the old dense dequantizer stages only what fits its buffer, so a Q6_K dense matrix no longer overflows into the next warp's
dq_rows_kernel (the default on gfx11, e.g. Strix Halo) stages a warp's 32 sub-blocks in a 44-uint4 (704 byte) shared buffer. Four Q6_K
blocks are 840 bytes, so the staging ran into the next warp's buffer and the dense rows came out wrong (NaN in layer 0 of every chunked
prompt of an Unsloth UD-IQ1_S/UD-IQ1_M set, whose attention is all Q6_K; windows of 16 use the GEMV and were fine). Q5_K (704 bytes, the
IST-DAS sets' dense type) fits and keeps the staged path. Stage only when the span fits and is a multiple of 16 bytes.
2026-10-31 22:09:43 +01:00
Niko1221 8eb3b6a8c2 STRATA_MMVF_CHECK=1 diagnostic: the constant-trip router read also runs the plain loop with volatile loads and counts accumulators that differ 2026-10-31 22:02:29 +01:00
Niko1221 b35bbfc2ee Trial merge f-0291-ui (2c2893081) 2026-10-31 22:01:53 +01:00
Niko1221 eaae5a865c Trial merge f-0291-mac-q (5957d10f7) 2026-10-31 22:01:52 +01:00
Niko1221 1b2825712d Trial merge f-0291-amd-q (c0988d553): CMakeLists keeps gr_v3_screen and mmvf_unr_hash; native_bf16.cu and verify_kernels.cu take bw-dense's per-device mmvf_fast/gdn_ab_fast (SMALL_UNR default on for cc 12.0) over amd-q's default-off copy 2026-10-31 22:01:52 +01:00
Niko1221 d4d7f164e9 Trial merge f-042-strix-q (dbf6a6bb6) 2026-10-31 22:01:20 +01:00
Niko1221 76f12eb225 Trial merge f-0291-bw-dense (08b20b104) 2026-10-31 22:01:16 +01:00
Niko1221 656fcfae13 Trial merge f-0291-bw-measure (c4082ce8c) 2026-10-31 22:01:16 +01:00
Niko1221 566e1c7c10 Trial merge f-0291-qwen-par (b19b38aff) 2026-10-31 22:01:12 +01:00
Niko1221 7d94b1d5b9 Trial merge f-0291-gh-app (5c0259e20) 2026-10-31 22:01:09 +01:00
Niko1221 9567238476 Trial merge f-0291-gh-eng (31c3a16f4): NOTICE.md keeps the base's block (thread pool fence #618 already listed; ds4_pool.hpp identical) 2026-10-31 22:01:09 +01:00
Niko1221 0f2778ae22 Trial merge f-0291-fixes (dde320ffa): keep both _parallel_lines and _broken_nvidia_note in launch/cmd.py 2026-10-31 22:00:53 +01:00
Niko1221 2d47b3e96b Trial merge f-041-maya-par-offer (2784dfdcc) 2026-10-31 22:00:46 +01:00
Niko1221 2c2893081b App: a goal, a plan step and a /loop run without anyone there, so the agent's question is declined or skipped at once there and cannot stall them; normal chat still shows the question card (#4244) 2026-10-31 21:59:32 +01:00
Niko1221 c0988d5531 arch_defaults_test: the gfx12 table has twelve switches (SMALL_UNR, GR_Q32_G, GR_Q8_PFS are off under STRATA_DEFAULTS=026) 2026-10-31 21:51:04 +01:00
Niko1221 eeb21d0e4f serve: --draft-conf honored with --draft dflash2 (the window ends where the product of the selector's probabilities falls below P), as in the CLI loop 2026-10-31 21:45:10 +01:00
Niko1221 a9a2939fe9 App: the transcript stops following the newest token while the reader has words selected in it, so a selection is not pulled out from under the pointer while a reply is written; the way back to the newest message is offered (#3818, selection half) 2026-10-31 21:37:47 +01:00
Niko1221 5957d10f75 Mac config: --spec 3 --spec-min-p 0.5 for a resident Q2_0 pack too; IQ3_S numbers re-measured
M5 Max, MTP, 5 interleaved pairs, fixed harness (each run's spec, min-p and context read from the engine's own log), greedy ids identical in every run, over --spec 2:
Q2_0 story 104.4 -> 104.7 (+0.4%), code 114.4 -> 127.3 (+11.5%), JSON 124.6 -> 132.5 (+6.3%), agent turn 115.9 -> 117.9 (+1.9%, 3/5 pairs).
IQ3_S story 94.2 -> 93.3 (-0.9%), code 103.8 -> 106.1 (+2.5%), JSON 105.1 -> 122.6 (+16.7%), agent turn 102.1 -> 107.1 (+4.9%).
The earlier Q2_0 'flat' result and IQ3_S '+1.4..3.6%' came from a harness that gave the spec flags to the first pair of an arm only. Other sets keep --spec 2.
2026-10-31 21:35:23 +01:00
Niko1221 1405970281 App: the agent's own questions (ask_user_question) get a card in the style of the approval card, with options, the person's own words, one answer or a skip; the tool is offered again as the group Questions for you in Settings -> Tools (#4244) 2026-10-31 21:32:33 +01:00
Niko1221 2f4b422968 App: a turn or a selected passage can be quoted into the next message: quotes sit above the box, can be removed or jumped to, go to the model in one fixed form, and show apart from the reply in the transcript (#4785) 2026-10-31 21:23:23 +01:00
Niko1221 51f9e432b9 App: while an answer is being written, Enter can wait (as before), send the message after the answer, or interrupt it (the answer stops where it is and the message goes at once); the choice is in Settings and the waiting message is shown above the box (#4783) 2026-10-31 21:20:04 +01:00
Niko1221 7d8c2a965b App: each helper row in the Subagents panel takes a message (the Harness's own subagents/prompt: steer for a working helper, queue for a finished one) and says plainly when it was sent or refused (#4749) 2026-10-31 21:15:29 +01:00
Niko1221 a75409dc57 mmvf_unr_hash: also hashes gdn_ab_multi (STRATA_SMALL_UNR bit 1) 2026-10-31 21:14:03 +01:00
Niko1221 656ddfd050 docs/MAC.md: --parallel works on Metal; the numbers measured on an M5 Max with IQ3_S 2026-10-31 21:10:12 +01:00
Niko1221 8218055ad3 App: Context memory says in one line what each format costs, and a start that ran out of graphics memory says so in plain words with the next step (#3723, context-memory and out-of-VRAM parts) 2026-10-31 21:06:01 +01:00
Niko1221 cb7491f02d App: a first start opens in the language of the computer, and the first screen carries a language choice (#3723, language part) 2026-10-31 21:05:57 +01:00
Niko1221 91411e17f8 Metal: 256 routed experts (the expert-pruned Coder, #4706): the routers, the prompt chain and the SSD stream take the model's expert count
Window::init accepts 512 or 256 experts a layer; the decode router (router_route / moe_route), the prompt router (route_topk) and the plan buffers use the model's count, the SSD stream reads it from the GGUF. The route kernels are templated on the logits a lane holds (16 for 512, 8 for 256); the 512 path is the old code token for token, the logits rows keep their 512-float stride, the predictor's unused columns are -inf. The MTP layer keeps its own 512 experts.
Tests: prompt_parity route_topk and fuse_parity router_route run both counts (all pass on the M5 Max). IQ3_S 512 experts: greedy ids identical to the build before in every class and prompt (3 interleaved pairs), speed equal (decode +/-0.5% median, prompts -0.5..+5.9%, inside the spread). Not run: an end-to-end 256-expert model (58 GB download).
2026-10-31 20:54:38 +01:00
Niko1221 cab56e7af8 App: paragraphs, lists and quotes in a reply share one width and tables and code blocks keep the full column; the list rule named a class the renderer never emits (#4722) 2026-10-31 20:53:49 +01:00
Niko1221 4473c386a5 App: while sharing is on, Serving shows the address on this computer and the one for other devices, with one plain line on why their ports differ (#4798) 2026-10-31 20:53:48 +01:00
Niko1221 de0608b443 App: the progress card of a download, load or tuning run has its own border and tint, a spinner and a running clock ("Still working for 3m 12s. You don't need to do anything."), and a line saying the buttons below wait until it finishes (#4802) 2026-10-31 20:53:48 +01:00
Niko1221 445baf12e4 App: a plain click on a web address in a reply opens it in the person's own browser (the app window asks the host, a browser tab opens it itself); files keep the side panel (#4721) 2026-10-31 20:53:48 +01:00
Niko1221 3cb03ed32c App: Tune this model says in one plain sentence why Start tuning is greyed out (still reading the saved tunings, could not read them, another operation running), and no longer says "No saved tuning" while it is still reading (#4804) 2026-10-31 20:53:48 +01:00
Niko1221 2506046a60 App: every Copy control shows on itself that the copy worked (a check mark for about two seconds, "Copied" in menus), and the seven that never reported anything now do (#4748) 2026-10-31 20:53:48 +01:00
Niko1221 9197e13a4b App: the Approve and Deny card stays above the message box while a long reply scrolls, and the reply says it is waiting for an answer, so nobody has to scroll up to find it (#4725) 2026-10-31 20:25:16 +01:00
Niko1221 2784dfdcc4 DeepSeek --parallel: the engine flag probe lives in its own module (strata_control/dsv41_flags.py), because serving must not import dsv41_engine.py (it reaches the network) 2026-10-31 20:13:11 +01:00
Niko1221 04e9da601f Mac: the PLE residency policy stays off unless STRATA_PLE_AUTO_RAM=1
Since the conversation-cache fix gave sample_host_memory a macOS branch, the 0.2.7 policy (table in RAM when it fits) started firing on the Mac: the 26.8 GiB n-gram table was locked in RAM, the footprint went from 8 to 35-42 GB and free memory dipped to 19 GB. M5 Max, IQ3_S, 3 interleaved pairs, same binary, same tokens: a cold 4K prompt after decode work is 10.6% slower with the table in RAM (-10.7 -10.6 -10.0% against the v0.2.9 binary, +2.4 +15.2 +12.2% for direct reads against RAM), decode is the same inside the spread. Direct reads are what the Mac had before; an explicit --ple-io still wins.
2026-10-31 20:11:35 +01:00
Niko1221 d8801f628c serve: say when a DeepSeek engine runs fewer conversations than --parallel asked for 2026-10-31 20:05:38 +01:00
Niko1221 09982fff66 gfx12 decode table: STRATA_SMALL_UNR=3, STRATA_GR_Q32_G=2, STRATA_GR_Q8_PFS=1 (bitwise load-ordering variants, measured on an R9700) 2026-10-31 20:03:04 +01:00
Niko1221 93af8275dc launch: DeepSeek Parallel step test; comments say DeepSeek is one at a time unless --parallel is asked for 2026-10-31 19:56:07 +01:00
Niko1221 009491d561 strata launch --parallel for DeepSeek: the launch prints what it does and the numbers, as the plan does; tests; app test fixture lists the setting 2026-10-31 19:50:10 +01:00
Niko1221 cc7857135b mmvf_unr_hash: hashes of the router-shaped bf16 reads for comparing STRATA_SMALL_UNR settings 2026-10-31 19:46:46 +01:00
Niko1221 87ec8fd83e WIP gfx12 HC q8 up read: software-pipelined group loads (STRATA_GR_Q32_PL) 2026-10-31 19:40:59 +01:00
Niko1221 d6912d7744 docs: DeepSeek --parallel (what it does, the numbers, where it was run); app test: Concurrent requests reaches a DeepSeek run config and nothing is added without it 2026-10-31 19:37:26 +01:00
Niko1221 31c3a16f4f Qwen3.6-35B-A3B: the penalties count the reply's tokens by default too; STRATA_PENALTY_PROMPT=1 brings the old window back (#4590)
The Qwen3.6 engine (src/qwen35/q36_engine.cu) started its penalty history from the whole prompt (`hist = ids`), so its model-card presence penalty 1.5 counted the prompt's last tokens as in the other engines. The history now starts at the last prompt token and grows with the tokens written (penalty_history_start in include/strata/qwen35/sampling.hpp), the same window as src/program/generate.cpp; batch slots copy that history. STRATA_PENALTY_PROMPT=1 keeps the whole prompt.

Tested: qwen35_sampling_test (276 checks, 9 new for the default and the opt-out), and a CUDA sm_86 build on z440. Replay on z440, one A4000, UD-Q6_K_XL, the reporter's conversation cut to 250 records, 400 tokens: 16 sampled seeds and 2 greedy requests per arm. This model did not return the old JSON in either arm (0/16 with the default, 0/16 with STRATA_PENALTY_PROMPT=1; 0/2 greedy in both), so this replay does not show a difference between the two windows; no answer in either arm started with the old JSON or a code fence.

docs/DETAILS.md: the sentence saying the Qwen3.6 path still counts the prompt is replaced.
2026-10-31 19:33:33 +01:00
Niko1221 094132ca77 strata launch --parallel N for DeepSeek: passed on instead of dropped, off by default; --plan says what it does with the measured numbers; a pick beyond what was run warns (Windows, Mac, small or unknown card); an engine from before the flag is asked (--help) and the launch goes on one at a time; the TUI step offers it for DeepSeek 2026-10-31 19:33:28 +01:00
Niko1221 8ce20293e5 WIP gfx12 HC q8 read: G rows a warp loaded together in gr_up_q8_q32_kernel (STRATA_GR_Q32_G), staging loads of all tokens together in gr_down_q8_fast_kernel (STRATA_GR_Q8_PFS) 2026-10-31 19:31:44 +01:00
Niko1221 c49eb5d194 WIP: single-column router bf16 read with constant-trip loads (STRATA_SMALL_UNR bit 0), for the HIP A/B 2026-10-31 19:29:54 +01:00
Niko1221 de4d21e6f8 WIP small kernels: STRATA_SMALL_UNR (bit 0 bf16 router read, bit 1 GDN a/b projection) constant-trip fast paths, default off
Same FMAs in the same order as the runtime-bounded loops (bitwise); nsys: gdn_ab 352 -> 290 us/round, router mmvf 533 -> 492 us/round. Engine A/B pending.
2026-10-31 19:29:03 +01:00
Niko1221 3ae0fbfad1 DeepSeek --parallel: a card that cannot hold every conversation's buffers runs as many as it can hold (the failed one's partial buffers freed, said in one line), instead of stopping the engine at the start 2026-10-31 19:25:10 +01:00
Niko1221 c4082ce8c4 CPU prefill share: the default limit goes from 1,024 to 3,072 tokens (CUDA, one GPU, only where experts are missing): TTFT of a 1-2K prompt on a card holding half the experts drops 30-60%, P100 +25% at 1K and +13% at 2K, 4K and above unchanged; share_rules::default_share_limit keeps a class gate, STRATA_PREFILL_CPU_SHARE_MAX=1024 restores the old limit, unit test added 2026-10-31 19:09:33 +01:00
Niko1221 40da04d49a Serve: DFlash2 on (ENGINE_SERVES_DFLASH2 = True); tests cover both states 2026-10-31 19:04:57 +01:00
Niko1221 6667ac43d4 wip: DFlash2 in the serve loop (ring stamps, request history, prompt rows, spec loop), serve test harness 2026-10-31 19:04:57 +01:00
Niko1221 081d1c04d2 PDL: on by default for the all-resident verify graph on a compute capability 12.0 card (bit-identical, +0.3..0.5% decode on an RTX PRO 5000); the doorbell graph and batch windows keep ordinary launches, STRATA_DF_PDL=0/1/2 and STRATA_SM120_PERF=0 are honoured 2026-10-31 18:45:16 +01:00
Niko1221 36d94496fa SYCL: the penalties count the reply's tokens by default too; STRATA_PENALTY_PROMPT=1 brings the old window back (#4590)
sycl/src/program/generate.cpp is its own copy of the serve loop, so the change in ed76b12f9 did not reach Intel cards. Same window: the fed-back last prompt token and the reply's tokens, built from the request's prompt ids on every request (a restored conversation gets the same window as a cold read). The helpers are added to sycl/include/strata/kernels/sampler.hpp, which that build includes first.

Measured on two Arc Pro B65 (SYCL AOT bmg-g31), Qwen3.8-Flash-Next IQ3_S, the reporter's 4-message conversation (32,951 tokens, cached), --spec 2, 16 sampled seeds, 500 tokens: old JSON 0/16 and numbered plan 16/16 with the new default; old JSON 8/16, plan 7/16, other 1/16 with STRATA_PENALTY_PROMPT=1. Greedy, 2 requests per arm: numbered plan 2/2 in both arms on this model.
2026-10-31 18:13:49 +01:00
Niko1221 06ebf2e4d6 PcieAuto: start the PCIe share from the pool size (min(65, 4 GB/s per worker) through the refinement's own formula) instead of the link-only 0.55, so the first answers after a launch do not run ~50 windows at a share the machine does not want; the converged share is unchanged, STRATA_PCIE_START=old keeps the old start 2026-10-31 18:10:43 +01:00
Niko1221 12a1f2c545 Mac config: --spec 3 --spec-min-p 0.5 for a resident IQ3_S pack (+1.4..3.6%, same tokens)
M5 Max, MTP, IQ3_S, 5 interleaved pairs (2 + 3), greedy tokens identical in every run: --spec 3 --spec-min-p 0.5 against --spec 2:
story 93.8 vs 92.4 tok/s (+1.4%), code 103.4 vs 100.4 (+3.0%), JSON 105.3 vs 101.6 (+3.6%), agent turn 100.8 vs 98.0 (+2.8%).
Q2_0 measured flat (-1.0% to 0.0%, 5 pairs; 800-token story -0.7%), so it keeps --spec 2; --spec 3/4 without min-p lose on both.
mac_fit.spec_args picks the flags by model; engine_args takes the model name (setup.py and metal/setup_mac.py pass it); a streaming
Mac keeps STREAM_SPEC, a Mac without the draft layer keeps --spec 2. The engine's own default (--spec 2) and an explicit --spec in a
config are unchanged. Test: tools/test_setup_mac.py.
2026-10-31 18:09:56 +01:00
Niko1221 08b20b104c BW-DENSE: STRATA_SMALL_UNR default 3 and STRATA_GR_V3_PF default 5 on compute capability 12.0 cards (bw_dense_defaults.hpp); STRATA_SM120_PERF=0 / STRATA_DEFAULTS=026 / the variables turn them off
Both are bitwise the loops they replace. Engine A/B: SMALL_UNR on top of the HC change +0.18% agent, +0.28% code_py, 5/5 pairs each, identical text.
2026-10-31 17:54:59 +01:00
Niko1221 381d70902d gr_v3_screen: print registers, local memory, smem and resident blocks of the v3 kernels (--resources) 2026-10-31 17:52:33 +01:00
Niko1221 ed76b12f9d The repeat, frequency and presence penalties count the tokens the reply has written, not the last tokens of the prompt; STRATA_PENALTY_PROMPT=1 brings the old window back (#4590)
With the model-card presence penalty 1.5 over the last 64 tokens, the prompt's own tokens (the digits of a new task, a quoted JSON answer) were penalised, and a conversation whose last turn asks for something new could get its previous answer back. The window now starts at the end of the prompt: the fed-back last prompt token and the reply's tokens. Earlier assistant turns inside the prompt are not counted. The history is built from the request's prompt ids on every request, so a conversation restored from the conversation cache or a saved slot gets the same window as a cold read. The STRATA_ADMIT_MERGE_FIRST merged first window is not used for a request with a penalty window, since its prompt rows would join the reply window.

Measured with the opt-in switch (618cd7a4b) on a P100, IQ2_XS, the reporter's saved 4-message conversation, 32 seeds per arm: old JSON returned 13/32 as shipped, 4/32 reply-only, 3/32 with presence 0. Qwen IQ3_S on an R9700, 16 seeds: old JSON 8/16 as shipped, 0/16 reply-only. 61-task quality027, greedy, one run per arm: 56 and 55 passed as shipped (two runs), 55 reply-only, 54 with presence 0.

Added penalty_window_test (header-only, CPU) and a line in docs/DETAILS.md.
2026-10-31 17:46:28 +01:00
Niko1221 2d381fcf51 WIP small kernels: STRATA_SMALL_UNR (bit 0 bf16 router read, bit 1 GDN a/b projection) constant-trip fast paths, default off
Same FMAs in the same order as the runtime-bounded loops (bitwise); nsys: gdn_ab 352 -> 290 us/round, router mmvf 533 -> 492 us/round. Engine A/B pending.
2026-10-31 17:26:08 +01:00
Niko1221 5c55046d72 Remove dense_tc (E03 rejected): the Q6_K INT8 tensor-core projection is 1.4-2.3x slower than the engine's MMVQ at T 1-4 on every real shape
Measured on an RTX PRO 5000 (cold weights, real shapes, head included); the earlier commits keep the code and its parity test.
2026-10-31 16:38:28 +01:00
Niko1221 97e5a6aeca dense_tc_parity: sync after pageable host-to-device copies (the T=7/8 mismatch was the test reading a column before its copy landed, not a kernel race) 2026-10-31 16:38:28 +01:00
Niko1221 adbe6b9b95 DeepSeek Windows build: ds4_os.hpp includes timeapi.h and links winmm for timeBeginPeriod (the merged include order no longer brought it in with windows.h) 2026-10-31 16:25:36 +01:00
Niko1221 e1b27d54b0 After a fatal GPU error the engine ends itself and the server starts it again, instead of staying up and failing every later request (#4350)
DeepSeek: an illegal memory access, launch failure or hardware exception in a prompt read or a decode round used to turn into an ERR/cancel while the engine kept running on a dead GPU context, so every later request failed with HTTP 400 until someone restarted it. strata-dsv41 now prints 'ERR fatal GPU error: ...' and exits (code 3). Qwen: a failed decode window, commit or final commit wait that leaves a sticky fatal runtime error does the same (the prompt-read path already did, #224), by the runtime's error text so CUDA, HIP and SYCL builds agree.
Server: that ERR line fails the request in flight as an engine death (503, retryable) and marks the engine ended, so the next request starts it again at once, with one plain log line and a plain death note. Restart-loop guard: a third fatal death within ten minutes stops the restarts and answers 503 with the reason until the window has passed; one death counts once.
Test hooks (off unless set): DS4_INJECT_FATAL=N / STRATA_INJECT_FATAL=N fail the Nth request / decode window with a real fatal error's text; *_ONCE=<file> fires it once across restarts.
Tested: serve/test_fatal_gpu_restart.py (7 tests); through the real server, request 2 injected, DeepSeek on boston (IQ1_M pack, 2x A6000): request 2 got a 503, the restart took 44 s, requests 1, 3, 4, 5, 6 answered 42; Qwen on a P100 (IQ2_XS), fault injected mid-decode in request 4: 503, restart, requests 1-3, 5, 6 answered 42. The guard counted '1 of 3' there and '2 of 3' in an earlier run where the injection fired on every restart. NOT tested: a real GPU fault (none reproduces), the Windows and Metal builds, the third-death refusal through a running server (unit-tested only), conversations coming back from saved slots after the restart (no check made).
2026-10-31 16:11:49 +01:00
Niko1221 3e2598a835 Dispatch counters: count the HC v3 read and the Q8 weights of the hyper-connection read, key a doorbell-graph launch as its own kind 2026-10-31 15:58:34 +01:00
Niko1221 3ce5193eb4 WIP HC v3: STRATA_GR_V3_PF default 5 (batched staging loads in gr_down, batched partial/row loads in gr_up) on compute capability 12.0 cards, 0 elsewhere; =0 restores the plain kernels
Bitwise the plain v3 read (gr_v3_screen, T 1-8, 0 differing); 4-8% faster per read on cold weights. Weight prefetch and 2/4 rows a warp measured slower and stay off. Engine A/B pending.
2026-10-31 15:55:17 +01:00
Niko1221 9469a0c264 Metal: token_chain CHAIN_GDNGRID timing probe (gdn_step grid size) 2026-10-31 15:39:20 +01:00
Niko1221 96a85f7fe8 Metal: token_chain probes and the measured-negative kernels of the hyper-connection / loop-batching study
hcf.metal (gr_norm folded into the producer of bo with a last-arrival threadgroup: bit-exact, +1.2 ms slower), gemvu.metal and
moeu.metal (unit loops unrolled x2: +2.3 / +1.5 ms), qsab.metal (batched qsa_attn: +0.07 ms). None is used by the engine; they
stay with their numbers (phaseA-tests/mac-q/RESULTS.md section 7). token_chain gets the CHAIN_* switches that measured them
(NORM2, COMB2, NONORM, NOOPS, SKIPK, HCF, KEEPNORM, GEMVU, MOEU, QB, DEFS).
2026-10-31 15:32:09 +01:00
Niko1221 21c14feb86 Metal: the drafter's prompt rows run on a second queue beside the next prompt chunk (+1.4-2.0%); mtp_rms with batched loads
Window::prompt ran each chunk's MTP-layer K/V rows (9% of an 8K prompt) after the chunk, with the GPU otherwise idle for them.
They now go to a second command queue (Context::run_graph_async / wait_async) while the next chunk's main work runs on the
first. The chunk's final residual rows are double-buffered (pb_.R / pb_.R2); the rows of chunk c+1 wait for chunk c's (shared
drafter cache and scratch); every way out of prompt() joins. Not used with M-RoPE (images). STRATA_METAL_MTP_OVERLAP=0
restores the sequential rows.

mtp_rms_b: mtp_rms with the row loaded up front (same sum, same products, same order); under STRATA_METAL_LDB=0 the original.

Engine, Q2_0, MTP spec 2, 5 interleaved pairs, same binary: p8k 1936 vs 1910 tok/s (+1.4%, 3/5), p32k 1747 vs 1711 (+2.0%, 4/5),
greedy ids identical in every run. The overlap is partial: the two queues share the GPU.
2026-10-31 15:32:09 +01:00
Niko1221 618cd7a4b6 Opt-in STRATA_PENALTY_PROMPT=0: the repeat, frequency and presence penalties count the tokens the reply has written, not the last tokens of the prompt (#4590)
With the model-card default presence penalty 1.5 over the last 64 tokens, the prompt's own tokens (digits of a new task, a quoted JSON) were penalised, and a conversation whose last turn asks for something new could get its previous answer back. Measured on a P100, IQ2_XS, the reporter's saved 4-message conversation, 32 seeds per arm, 400-500 tokens: as shipped 13/32 returned the old JSON and 16/32 a numbered plan; reply-only penalty 4/32 and 25/32; presence 0 3/32 and 26/32. quality027 --quick (19 tasks, greedy, one run each, no noise arm): shipped 15, reply-only 16, presence 0 17 passed; the 1-2 task differences are single tasks and inside run-to-run noise. Default unchanged (switch off). The old JSON still came back in about 1 of 10 in the best arms, so the penalty is a large part of the cause, not all of it.
2026-10-31 15:27:53 +01:00
Niko1221 9abe0d6c45 WIP HC v3 read: bit-exact load-ordering variants of gr_down_v3 / gr_up_v3 behind STRATA_GR_V3_PF (default 0 = the existing kernels), gr_v3_screen bench
bit 0-1 down (batched staging loads, weight prefetch), bits 2-3 up, bits 4-5 rows per warp 2/4 for T<=4. Screen bit-for-bit against the plain v3 read; speed not established yet.
2026-10-31 15:15:40 +01:00
Niko1221 da1339d547 WIP dense_tc: sm_120 INT8 tensor-core Q6_K projections (T 1-8), off unless STRATA_DENSE_TC=1; parity test against a scalar reference of its arithmetic contract
Rounding-changing path (not native_mmvq's order), so opt-in until a KL check and the owner's OK. HIP/other builds get stubs. Not benchmarked yet.
2026-10-31 15:15:40 +01:00
Niko1221 af6ba4c607 Dispatch counters: which kernel path a run really took (dense MMVQ by type/shape/rows, grouped experts and gate/up form, HC read form, window graph kind, layer paths, PDL edges, head), written as JSON at exit with STRATA_DISPATCH_REPORT 2026-10-31 15:03:07 +01:00
Niko1221 1b9a1b235a Metal: gr_norm and the MoE combine with their loads batched (decode +2-3%, same tokens)
gr_norm stored R in place inside its loop, so each of its ten iterations (and the ten of the second loop) waited a full
memory latency for the next load: 98 times a token, ~6 us of a 7.7 us dispatch. gr_norm2 loads R, bo and w_norm first, then
sums, reduces and stores; moe_combine_sh2 loads the ten plan entries, then the ten rows, then sums. The arithmetic and its
order are the originals', so the outputs are bitwise the same. STRATA_METAL_LDB=0 selects the original kernels.

token_chain (Q2_0, 1 token, graph): 10.65 -> 10.14 ms. Engine, MTP spec 2, 4 interleaved pairs: story 100.97 -> 103.90 tok/s
(+2.9%, 4/4), code 108.63 -> 110.84 (+2.0%, 4/4), greedy ids identical in every run.
2026-10-31 14:11:21 +01:00
Niko1221 dbf6a6bb62 Stall report: STRATA_STALL_STACKS=1 lists every thread's call stack (Windows), and the stall dump carries thread, handle and memory data
Off by default. With STRATA_STALL_STACKS=1 the stall report suspends each other thread in turn, unwinds it with the unwind tables
(no heap, no loader lock, no stdio) and prints the frames, named from the PDB when it lies beside strata.exe. The minidump flags now
include process thread data, handle data and memory info. Used to locate the Windows gfx1151 UD-Q4_K_XL stall (host thread inside
the HIP runtime after a graph launch).
2026-10-31 14:09:27 +01:00
Niko1221 5a13c34f1b PLE table lock: raise the soft memlock limit to the hard one first, and when the table still does not fit say how much this program may lock and what to set (#4671)
A systemd service without LimitMEMLOCK may lock 8 MiB, so the 26.8 GiB mlock of the PLE table was refused and the engine ran with a reclaimable table (the reporter measured up to 6% slower decode) behind a two-line message. Before mlock the engine now raises RLIMIT_MEMLOCK's soft value to the hard one (needs no privilege) and says so; if the table still does not fit, the message gives the allowed size and the remedy (LimitMEMLOCK=infinity, or ulimit -l unlimited). The residency policy itself is unchanged. Tested here: the CUDA engine builds (sm_60). The run under 8 MiB soft/hard, 8 MiB soft with a higher hard limit, and unlimited is queued, not yet seen.
2026-10-31 14:06:55 +01:00
Niko1221 b8c69334bc Metal probe: busy_flag_probe shows the DS4 busy-spin kernel does not see the host's flag write (#4771) 2026-10-31 13:34:26 +01:00
Niko1221 d01b181751 Metal: conversations park and restore (#4730, #4144)
The Mac now reads its free RAM for the conversation-cache guard (arena_avail's rule), the window's checkpoint uses the shared
contract (3 tail rows a layer, a scratch block_pos), and the session's QsaStates point at the window's K/V buffers (and the
drafter's), which the generic snapshot copies as cell-major byte prefixes. Before, the session's own pools were never written on
Metal. Two conversations taking turns (11K and 6.5K, 24K and 3K tokens): the second request of each re-read its prompt (6.3 s,
14.9 s); now it reuses it (46-65 ms), with the same answer ids.
2026-10-31 13:30:39 +01:00
Niko1221 b0b942fa9e A pack that keeps a native-eligible projection unquantized (BF16) no longer stops the engine with 'no native GGUF blocks': the matrix is served as Q8_0 like its siblings (#4741)
Heretic v2 IQ4_NL keeps blk.0.attn_gate.weight as BF16 (GGUF type 30) while the other 35 gates are Q8_0. NativeDense has no kernel for type 30 and skipped it, so the prompt path and the verify window, which read the gate through the native route, stopped with 'blk.0.attn_gate.weight has no native GGUF blocks (run with --native)' on the first request. NativeDense::load now makes such a matrix (BF16 in the file, BF16 row in the pack, 2-D, a multiple of 32 wide) into Q8_0 on the host (the same rounding the engine already uses for STRATA_HC_Q8) and binds it, so every dispatcher finds a native projection; weight_bytes_for counts it. The pack's BF16 copy stays. This is not bit-exact against reading the BF16 values (a Q8_0 rounding on one 2560x6144 matrix, the same format its siblings have); reading the BF16 directly would need changes in the prompt path, the verify window (with its side-stream branches) and gdn_layer. Tested here: the CUDA engine builds for sm_60 (p1004). NOT tested: any run, because the 109 GB Heretic IQ4_NL model is not on a test box; the reporter's own run is the first test.
2026-10-31 13:09:49 +01:00
Niko1221 b89bf5947d Windows: an engine started from a low-priority program no longer reads its model at Low disk priority; its disk priority goes back to Normal and one plain line says so (#4733)
A Windows process starts with the I/O priority of the program that started it. Run from a Task Scheduler task, a service, or a terminal that an IDE or coding agent keeps at low priority, the engine inherited Low I/O priority: the reporter measured PLE direct reads about 15x slower, cold prompt reads 3-5x slower and decode 14% slower on the same drive, and the engine blamed the drive. Both engines (strata and strata-dsv41) now read ProcessIoPriority at start and set it to Normal when it is lower (no privilege needed to go from Low to Normal). STRATA_KEEP_IO_PRIORITY=1 leaves it as inherited. The CPU priority class is not touched. New io_priority_test (Windows): started at Low it ends at Normal, started at Normal it is untouched, KEEP leaves Low alone. Tested here: that test passes on Windows; the HIP engine builds. Not tested: the reporter's slowdown itself (needs their drive and a low-priority launcher with an engine run).
2026-10-31 13:01:12 +01:00
Niko1221 5c0259e20c Server: the sampler's and the HIP probe's files are closed after reading, so the ResourceWarning lines of every run are gone; no answer changes (#4654) 2026-10-31 12:58:46 +01:00
Niko1221 c9efd49202 Docker: RETUNE=1 passes --retune to the launcher, so a tuned preset is measured again (#4779) 2026-10-31 12:58:46 +01:00
Niko1221 89b648a65e App: a helper row saved as In progress is shown as stopped when the page loads again, so a stopped helper no longer keeps its row and the running count after a restart; a live update brings back a helper that really runs (#4218) 2026-10-31 12:57:44 +01:00
Niko1221 6d8fbf4194 App agent: the summary that replaces older history is asked for in the language the person used (the Harness's instruction says concise English); paths, commands and identifiers stay exact (#4750) 2026-10-31 12:57:26 +01:00
Niko1221 c59f766a0a App: the Open button of a website preview hands the address to the default browser through the host (the app window drops window.open, so it did nothing); http and https only (#4721) 2026-10-31 12:55:07 +01:00
Niko1221 abd5c8cb8a App serving log: an Anthropic stream's prompt count (message_start) is read too, so tokensIn is no longer left empty while tokensOut is filled (#4678) 2026-10-31 12:54:19 +01:00
Niko1221 788e372792 Own model files: Strata's draft layer (mtp-q2_0.gguf) beside a model is no longer counted as a second model, so the folder is not refused with 'holds more than one model' (#4693) 2026-10-31 12:53:10 +01:00
Niko1221 ecb2fc4502 ds4 thread pool: fence the generation store before reading the sleepers, and start workers from generation 0 (a possible part of #4664, not shown to be its cause)
A worker going to sleep at the moment a batch started could miss both the new generation and the wake-up (the caller then runs the batch alone, slower, not wrong). Taken from Project Maya v1.1.36 (#618, Cody Harrison), MIT, credited in third_party/project-maya/NOTICE.md.
2026-10-31 12:52:56 +01:00
Tycho Rasch f6e3042a61 Windows HIP: keep the pinned expert arena's GPU aliases clear of the process's memory, so one start in ten no longer reads every long prompt wrong (#3431, #2960; PR #4787)
On Windows the HIP runtime gives every cudaHostRegister'ed range a second address for the GPU and takes any host pointer inside it for the registered memory, but it does not reserve that range in the process's address space. In about one start in ten the stack of the thread that pins the expert arena lay there: small cudaMemcpy calls from local variables then read (and wrote) the arena, and every long prompt was read wrong until a restart.

- pinned.cu: each registered range's alias is reserved (MEM_RESERVE); a range whose alias already covers memory in use is unregistered and treated as refused by the driver, so it stays resident but unpinned (logged).
- generate.cpp: main keeps the free address space below 1 TiB reserved for the life of the process and runs the engine on a new thread, so its stack and later allocations lie above the aliases (STRATA_STACK_ISOLATE=0 turns it off).
- pinned_alias_test, docs/AMD_HIP.md.

Windows HIP only; other backends keep their behaviour. Author's measurements: 0 bad starts in 110 (main + change 0 in 70, main alone 5 in 70) on an RX 7900 XTX. Here: the HIP objects, pinned_alias_test and pin_budget_test build for gfx1100 with ROCm 10.2 (no gfx1100 card to run them on). The hunk in pinned.hpp was applied by hand because v0.2.9 has the shared-file fields between the lines the PR touched.
2026-10-31 12:52:41 +01:00
Niko1221 679961662d Control: the tuning probe's child output is read with errors replaced too, the last place of #4675's kind (#4629: the same crash on a Russian-locale PC was fixed in 0.2.9 by 7146d6983) 2026-10-31 12:52:12 +01:00
Niko1221 44bca4099f App: DeepSeek-V4.1-Flash is ready to read pictures without the Qwen reader program; the Models screen no longer tells its owner to update an engine that is fine, and a load with Read pictures on no longer asks for a vision section it never has (#4772)
The screen looked for strata-vision in the engine folder for every model; DeepSeek carries its own picture tower (--vision on|off), so the check always failed for it and Read pictures stayed off and locked.
2026-10-31 12:51:29 +01:00
Niko1221 35057071c1 Tool calls: a call written as tools.write, functions.write or default_api.write is the declared tool write (#4751)
A programmatic-tool-calling harness shows the model its tools as tools.write(...) inside code, and the model then wrote the native call with that spelling; the call came back as text and the run stopped. The prefix is read like the other unambiguous aliases (a declared name always wins, an undeclared name stays text). Also: a tool_reference with a non-string name no longer fails the request. Tests for the prefixes and for the history rule on the Anthropic and Responses shapes.
2026-10-31 12:47:49 +01:00
Niko1221 6a8752f9bb Tool calls: a tool the conversation already called counts as declared on the Anthropic and Responses routes too, not only on Chat Completions (#4663)
The 0.2.9 rule read only assistant messages' tool_calls. It now also reads tool_use blocks (Anthropic) and function_call / custom_tool_call input items (Responses, with the namespace in front of the name). Test on the /v1/messages route added.
2026-10-31 12:47:49 +01:00
xripandNiko1221 9efac55f51 Anthropic tool search: a request with a defer_loading tool returns a call for an undeclared name as a tool_use, and a tool_reference loads the tool's definition where it stands (#4663, PR #4700)
Claude Code declares ToolSearch and a defer_loading placeholder and names its MCP tools only in the conversation; when the model
calls one of them straight away the call came back as text and the session stopped. Ported from PR #4700 by xrip onto v0.2.9
(the server.py hunk for the parser call moved); the PR's own tests are included.

Co-authored-by: Niko1221 <[email protected]>
2026-10-31 12:46:05 +01:00
Niko1221 2fba107658 Merge branch 'f-041-maya-a' into f-041-maya-int 2026-10-31 12:34:50 +01:00
Niko1221 b19b38aff1 server: note that the FP16 weight cache and the no-loan prompt path are the engine's own automatic defaults 2026-10-31 12:33:01 +01:00
Niko1221 dde320ffaf Launch: after a saved --backend choice the launch says which make is used; the plan for an Intel card no longer announces a draft layer download and says Strata builds the pack 2026-10-31 12:18:14 +01:00
Niko1221 b0fde18b16 Launch: a PC whose NVIDIA driver does not answer but has working Intel Arc cards is told so, with the one next step (--backend intel) 2026-10-31 12:15:24 +01:00
Niko1221 caef1e879a Metal: spin-then-block wait in run_graph (decode +5%, same tokens); STRATA_METAL_PROFILE prints periodically and at exit 2026-10-31 12:12:24 +01:00
Niko1221 4f0fafbc8f Warning for UD-Q4_K_XL on a Strix Halo in setup, launch and the plan (can stop on the first request after a start; points to UD-IQ4_XS; warn only, written to be removed with the stall fix) 2026-10-31 12:05:53 +01:00
Niko1221 d88531297c Launch on an Intel Arc card prepares the model itself: setup.py --prepare-pack builds the pack from the GGUF files and fetches gguf-py on its own, the run settings are written as before, and docs/INTEL_ARC.md's quick start is true 2026-10-31 12:05:53 +01:00
Niko1221 17a572f3e3 Launch: --backend nvidia|amd|intel|auto chooses the engine make on a PC with cards of several makes and is remembered; --plan and the Cards step say the Intel cards exist; a chosen make is never refused 2026-10-31 12:05:52 +01:00
Niko1221 e809e67ecf DeepSeek Metal build: the PCIe lane code compiles on macOS (no sched_getaffinity, no PCI bus id or registered-memory attribute, no globaltimer asm); Linux/CUDA/HIP paths unchanged 2026-10-31 11:33:44 +01:00
Niko1221 6a07e8ad72 Setup: the no-internet DeepSeek message keeps its wording and adds the proxy sentence after it; the install.sh hardware-line test puts its fake tools first on PATH 2026-10-31 11:29:40 +01:00
Niko1221 5f19bf5b32 Merge branch 'f-041-maya-par' into f-041-maya-port2 2026-10-31 11:16:35 +01:00
Niko1221 b2c3847fc0 HIP: Q2_K's GEMV always through the 4-column kernel (in slices), so a column does not depend on the rows beside it; ds4_gguf_test --rows says what a differing column depends on 2026-10-31 11:13:41 +01:00
Niko1221 9f86e95a3c serve: STRATA_DS4_RESTORE_SHARED=0 leaves the shared start out of the restore points the server asks for (for A/Bs) 2026-10-31 11:08:42 +01:00
Niko1221 e9d9ed8bb3 DeepSeek engine: a shared start a fresh prompt reaches is kept from the one read, not by stopping there (A3)
A read from token 0 that reaches the shared start (shared=, pin=) no longer stops at it: the start is a restore point inside
the one run of chunks (feed_marks), and the state at it is saved apart for new conversations after the read, from that mark
(Model::slot_save(out, err, at): the window rings and partial groups from the mark, the compressed KV and index keys of rows
0 .. at / ratio, the tokens up to it).  The stop cost a whole extra pass over the streamed experts: a fresh 6000-token prompt
with the shared start at 4500 and a tail point, DGX Spark first token 21.0 s -> 16.4 s (14.35 s with no keys at all).
STRATA_DS4_PREFIX_STOP=1 stops at the start as before.  A new chat behind the start still takes it back in 0.11 s and reads
the same tokens as a cold read; the agent loop total is unchanged (188.8 s against 187.9 s, 3 runs each).

Credit: Project Maya #607 (mw00), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 11:07:52 +01:00
Niko1221 8becec7d5c gfx1151 on Windows: the exact parallel-request switches are on by default for 2 or more batch slots
serve/server.py (0.2.9) sets the nine qualified switches only on Linux; Windows had none. Measured on a Strix Halo Windows PC
(Qwen3.8-Flash-Next IQ3_S, serve/server.py --parallel 4 over the engine, tools/par_load.py w2 at 4 clients, 3 rounds, interleaved,
download paused): 51.0 / 51.2 / 51.7 / 50.7 tok/s without, 55.0 / 55.6 / 55.2 / 55.1 with (+8%), TTFT p50 2.57 -> 2.43 s,
max chunk gap p95 1.97 / 1.37 -> 1.27 s, no errors. Not STRATA_BATCH_CONCURRENT_READ.

parallel_default_env() in arch_defaults.cpp: CKPT_POOL, CKPT_POOL_READY=4, SLOT_CKPT_MOVE, SLOT_COPY_DIRECT, SLOT_COPY_BATCHSYNC,
BATCH_GDN_GROUPS, BATCH_ATTN_ROWS, BATCH_SELF_COMMIT, QSA_SLOTS, set from generate.cpp with --batch / --slots of 2 or more on a
Windows gfx1151; a switch the user set is kept; STRATA_GFX1151_DEFAULTS=0 turns it off. Unit test in arch_defaults_test.cpp.

verify.cpp read STRATA_BATCH_SELF_COMMIT and STRATA_QSA_SLOTS into namespace-scope constants, before main() sets the defaults, so
two of the nine would never have seen them (the first engine build measured 52.2 / 52.6 tok/s, the base). They are read at the
first use now. Also logs the time of each verify window graph capture.
2026-10-31 10:53:26 +01:00
Niko1221 b3b47b108b build_windows.bat: STRATA_CMAKE_EXTRA reaches the cmake configure line (for example -DSTRATA_WINDOW_MAX_T=8 builds the 8-row engine) 2026-10-31 10:53:19 +01:00
Niko1221 cda6463e8e Qwen parallel: FP16 weight cache sized by free VRAM (3 GiB kept free), no loan of cache slots when every expert fits, replica profile keeps 2500 MiB free 2026-10-31 10:47:25 +01:00
Niko1221 169d5d6a9e DeepSeek --parallel: the DSpark drafter in the steps of several requests (Project Maya #611, needmorevram; with mw00's 1434096, 4b2378b, 0d0f261) 2026-10-31 10:16:57 +01:00
Niko1221 ab0ed4feab Launch: the Intel Arc 'cannot set up this card' message points to docs/INTEL_ARC.md and the pack tool 2026-10-31 10:15:14 +01:00
Niko1221 ed3de732cc Tests: install.sh hardware-line test writes its fake tools with real newlines 2026-10-31 10:02:06 +01:00
Niko1221 b3c3909147 Setup: finish marks of a read-only model folder are kept in Strata's own folder (the 110 GB Engram is hashed once, not on every launch); a prepared model starts with no internet: the llama.cpp source is fetched only when a preparation tool runs, and a pinned file copied in by hand is checked locally instead of asking the server 2026-10-31 09:57:47 +01:00
Niko1221 35ca7ec773 App agent: the system prompt says which computer it runs on (Windows/PowerShell, macOS, Linux), so the model stops probing with ls, uname and wc 2026-10-31 09:56:02 +01:00
Niko1221 69896982ae DeepSeek --parallel on an event scheduler: prompts read a piece at a time, a read alone yields to a request, opt-in STRATA_DS4_READ_PIECES (Project Maya #587, mrsirg97-rgb) 2026-10-31 09:55:53 +01:00
Niko1221 896818a3de DeepSeek plan on AMD: the expert count matches the engine's carve-out (2,365 on one R9700 and 12,152 on four, where real runs held 2,366 and 12,147) 2026-10-31 09:54:21 +01:00
Niko1221 86c94204a1 Naming: the DeepSeek sets are ISTA-DAS on screen and in the docs; the ids (--model IST-DAS-2.25bpw) are unchanged and the new spelling is accepted too 2026-10-31 09:51:11 +01:00
Niko1221 939e1acbae install.sh: the graphics line comes from the PCI class and a failed nvidia-smi gives no name (an SSD and an error sentence were printed as the card) 2026-10-31 09:50:49 +01:00
Niko1221 61b39f08b7 Setup: a download that cannot reach its server says what to set behind a proxy 2026-10-31 09:50:49 +01:00
Niko1221 27060866eb Launch: no disk warning for model files used where they are, and the tight-fit notice no longer says tight twice 2026-10-31 09:50:48 +01:00
Niko1221 3da4517962 Update wording: a first install does not say a previous engine is kept; a second rollback says it moved to the newer version and no longer marks it skipped; --check names the command to force a skipped version; the app line says the Terminal cannot tell the installed app version 2026-10-31 09:50:48 +01:00
Niko1221 a5112326a0 Server: a stop that arrives the moment the ready file appears takes the clean stop (file removed, exit 0) instead of an uncaught interrupt; fixes the flaky ready-file test 2026-10-31 09:44:39 +01:00
Niko1221 866681a889 Tests: the encoder-build test no longer reads the real cards of the box it runs on 2026-10-31 09:44:39 +01:00
Niko1221 45f0e48725 Local models: the importer accepts the Flash-Next family's own architecture (qwen4exp) instead of sending it to llama-server (#4726, part 2 of 4) 2026-10-31 09:44:05 +01:00
Niko1221 050741096b Parser: bare and unwrapped calls keep their old closing rule; the DeepSeek plain-parser test settles its call at the end of the turn (#4701 follow-up, full serve suite 1265 passed on p1006) 2026-10-31 09:44:05 +01:00
Niko1221 49b880c01f Parser: a call's closing tags end a value only when no prose and later closing tags follow, so a file that lists them is written whole; a closed call is held until the turn ends, a new call starts or 1500 characters pass (#4701, Option C) 2026-10-31 09:44:05 +01:00
Niko1221 e9b4bf0055 Parser: tests that pin the #4701 decision cases (one expected failure today); options written up in phaseA-tests/urgent029/OPTIONS-4701-4704.md 2026-10-31 09:44:05 +01:00
Niko1221 499a68dba6 DeepSeek --parallel: the prompt read a piece at a time with the others' decode steps between the pieces, opt-in STRATA_DS4_PIECE (the idea of Project Maya #587, mrsirg97-rgb) 2026-10-31 09:42:19 +01:00
Niko1221 331ef6890d DeepSeek prompt ring sized by the GPU's measured host link, opt-in DS4_RING_MB=link (Project Maya #609, mw00) 2026-10-31 09:37:43 +01:00
Niko1221 3ce04cd88d ds4_gguf_test --rows: say which row count and column differ, and the single-column result bits 2026-10-31 09:35:38 +01:00
Niko1221 c85cfc7cc2 ds4_main: --multi-check reads --ids @file after it is expanded 2026-10-31 09:35:38 +01:00
Niko1221 ca89975ec1 serve: DeepSeek engines with --parallel (tagged T/PP/DONE lines, STOP <rid>, parallel -> --parallel) 2026-10-31 09:35:38 +01:00
Niko1221 27f3137493 DeepSeek --parallel: --serve multi protocol (GEN rid=, READY multi=), --multi-check, ds4_gguf_test --rows (Project Maya #332, mw00) 2026-10-31 09:35:38 +01:00
Niko1221 29c2d574f8 DeepSeek --parallel from Project Maya (#332, mw00): per-conversation state, attention split out of layer(), step_multi / sample_multi 2026-10-31 09:35:38 +01:00
Niko1221 f080938676 Merge branch 'f-041-maya-a' into f-041-maya-b
# Conflicts:
#	src/dsv41/ds4_main.cpp
#	src/dsv41/ds4_model.cu
2026-10-31 09:08:07 +01:00
Niko1221 8ea5361bfc Merge tag 'v0.2.9' into f-041-maya-b
Strata 0.2.9 (engines and the SYCL package built from 99866a47a2b8a21b131aad162a584ee9da92f8f0; the Windows and Linux x86 app, Terminal, Control and installers from c62d5e3a15b40ea51c96aae9e8d36928965f53ab; the Mac set from 4cf0d8cd3 and the Spark Control from 38f56619c, which the later card fix does not touch; the later commits change the Python Control, server, setup, tests, tools, docs, the app host and the Mac Metal engine only)
2026-10-31 09:07:42 +01:00
Niko1221 908a4dee7b Merge tag 'v0.2.9' into f-041-maya-a
Strata 0.2.9 (engines and the SYCL package built from 99866a47a2b8a21b131aad162a584ee9da92f8f0; the Windows and Linux x86 app, Terminal, Control and installers from c62d5e3a15b40ea51c96aae9e8d36928965f53ab; the Mac set from 4cf0d8cd3 and the Spark Control from 38f56619c, which the later card fix does not touch; the later commits change the Python Control, server, setup, tests, tools, docs, the app host and the Mac Metal engine only)
2026-10-31 09:07:32 +01:00
Niko1221 7d4465f75d DeepSeek engine: DS4_CHUNK_LOG=1 prints each chunked read's long-prompt skip (rows, kept rows, first restore point) 2026-10-31 09:07:32 +01:00
Niko1221 2bfd207d33 DeepSeek prompt path: Maya's buffered flow reads (DS4_FLOW_BUFFERED, 1024 tokens on one GPU) stay off by default
Measured on Boston (one A6000 as a 12 GB card, ballast leaving 64 GB of RAM, UD-IQ1_M): the first 1000-token prompt read took 17.0-17.5 s with the
B1 default against 11.1 s before B1 and 11.2 s with DS4_FLOW_BUFFERED=0. The setting is Project Maya #312's (Peasant Smith / mw00); it stays available
as DS4_FLOW_BUFFERED=<tokens> until it is measured to win here.
2026-10-31 08:03:15 +01:00
Niko1221 45a38347a5 DeepSeek expert cache: the RAM store's put reads the slot through sat() (tail slots), not vram_base + slot * unit
With a layer's slots partly in the lent tail region (B1, Project Maya #312 tail lending), a swap that hands its outgoing expert to the RAM store
(DS4_STORE_PUT, on by default) read vram_base + slot * unit past the layer's own allocation: on HIP (p620, short prompt, windows path, cache on)
the first swap raised an illegal memory access and poisoned the context. The DBG slot dump had the same address. Adds tests/dsv41/short_prompt_check.py
(a 40-token prompt twice with the default sampler, then greedy; fails on ERR or illegal-access / router-picked lines).
2026-10-31 07:40:38 +01:00
Niko1221 54eff48756 DeepSeek engine: a short chunk streams only the experts it picks on the batches path, opt-in (A4b); DS4_UNPICKED_STAT
Project Maya #691 (needmorevram, c7d08c5), the "a chunk streams only the experts it picks" part, onto src/dsv41's batches
path (Streamer / copy_ahead; DS4_FLOW=0 or a pack the flow does not take): with DS4_SKIP_UNPICKED_ROWS=N (or
DS4_SKIP_UNPICKED=1 for every chunk) a chunk of at most N rows waits for its layer's routing and then reads and copies only
the experts some row of it picked, into the same slots and batches, so the summing order is the same.  Off by default.
Strata's flow path already streams only routed experts, so the default path gains nothing from it.
DS4_UNPICKED_STAT=1 prints, per chunk size, the share of a layer's experts no row of the chunk picked.

Credit: Project Maya #691 (needmorevram), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 07:17:30 +01:00
Niko1221 490b4b0fad Merge f-041-maya-a2 (A2 tail cost, A5 path calibration, small items) into f-041-maya-a
Keeps A3's shared-start store and A4a's feed_marks together with Project Maya #668's tail-cost judgement (mw00).
2026-10-31 04:53:53 +01:00
Niko1221 0be5d73f00 DeepSeek engine: restore points near a prompt's end kept in one read, feed's after (A4a)
Project Maya #691 (needmorevram, c7d08c5), the restore-point part, onto src/dsv41: Model::feed_marks() keeps the restore
points inside a run of chunks from snapshot sets the layers fill on the way (kSnapN = 8 sets; feed_mark's one set became
an array), each the state a read up to that point leaves, instead of a run up to each point; Model::marks_inside() says
whether that is worth it (the long-prompt skip's extra rows at most a chunk, inner_cost); the serve loop reads the rest
of a prompt that way when the points left are inside it (not past a shared start a read from token 0 stops at, #607).
STRATA_DS4_MARKS_INSIDE=0 stops at each point as before.  feed()'s `after` (the rest of the prompt still to come) moves
the long-prompt skip's kept rows to the prompt's end.  Not ported from #691: the chunk streaming only the experts it
picks, the EXL3 lend, DMA read-back of host sums.

Credit: Project Maya #691 (needmorevram), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 04:37:02 +01:00
Niko1221 7b96b21cf9 DeepSeek engine: DS4_TEST_SAMPLE_FAIL test hook for the sampler error handling (Project Maya #619, Cody Harrison) 2026-10-31 03:22:59 +01:00
Niko1221 c17b458c4f DeepSeek engine: a new conversation behind the same system prompt and tools reads only its own part (A3)
Project Maya #607 (mw00, b2d4c30) onto src/dsv41: when a prompt read from token 0 reaches its shared start (shared=,
pin=) the state the read keeps there is also copied apart from the conversation, in RAM (slot_save).  A prompt that does
not continue the conversation in the model takes back the longest kept start it begins with, when that covers more than
the model's own restore points (a kept conversation slot only when it covers more still), and reads only the rest.  With
or without STRATA_DS4_SLOT_KEEP.  STRATA_DS4_PREFIX_KEEP (2 starts, LRU), STRATA_DS4_PREFIX_GB (2), STRATA_DS4_PREFIX_MIN
(1024); 0 keeps none.  Starts with pictures are not kept.

Credit: Project Maya #607 (mw00), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 03:09:06 +01:00
Niko1221 70f1f90d75 DeepSeek engine: DS4_ASYNC_T stays 8 by default (16 is opt-in; Project Maya #608, mw00): the window-size logits test did not show the claimed independence 2026-10-31 03:07:58 +01:00
Niko1221 9edd253c25 NOTICE: fix the B4 credit line 2026-10-31 02:13:51 +01:00
Niko1221 1daf05cb02 Merge branch 'f-041-maya-a' into f-041-maya-b
# Conflicts:
#	third_party/project-maya/NOTICE.md
2026-10-31 02:05:45 +01:00
Niko1221 a280f5bbd0 DeepSeek one-GPU decode from Project Maya: copies pulled in place, windows streamed, landings in recent slots (#476 RubenTorney)
With the lane on (DS4_STREAM=1) and one discrete NVIDIA GPU, the CPU lane's RAM copies are pinned and the fetch kernel pulls a streamed
expert where it lies; a verify window's experts stream too and land in a recent slot, so the link's bytes stay in VRAM.
Gating and follow-ups: one-GPU path only where experts stream (1b60249, 482e46c #496, 7c0ceb1 #551, mw00), no landing while the swap is pending
and a table-update wait (32f3c8c handmade0octopus), a failed fill is never published (#646 handmade0octopus), Windows with pin by need (#567 TheSmokeDev),
HIP pull clock on gfx11/gfx12 and DS4_IQ1S_ROWS (#563 Cass, mw00). Everything stays behind DS4_STREAM=1; DS4_RAM_SKIP_W (changes answers) is opt-in.
Not ported: the helper-GPU condition (#415). Built on CUDA sm_120; model identity/A-B not run yet.
2026-10-31 02:04:11 +01:00
Niko1221 ec723c27e8 DeepSeek expert cache: recent slots from Project Maya (#344, needmorevram), selective port
The layer keeps DS4_RECENT=<n> VRAM slots (taken from its coldest experts) for CPU-lane experts it used twice in its last 8 ticks.
A thread copies each fill on its own stream, the next tick maps it by one tab_set launch a device; a swap leaves recent slots and
experts being filled alone, and a run of chunks that lends the tail waits for the fills first. Opt-in (default 0), values unchanged.
Not taken from #344: all-reduce over DMA, segmented top-k, DS4_INDEX_TC, the cuBLAS workspace pin.
ds4_gguf_test: its Q8_K default now follows the engine (opt-in, DS4_IQ_Q8K=1).
2026-10-31 02:04:03 +01:00
Niko1221 c28b5141a0 Project Maya v1.1.19 to v1.1.20 DeepSeek changes (staged merge, B3 prerequisite) 2026-10-31 01:13:52 +01:00
Niko1221 216252f034 DeepSeek PCIe lane and whole-token judge from Project Maya (#312 Peasant Smith, #329 mw00), staged merge v1.1.17 to v1.1.19
Lane and Q8_K rounding opt-in (DS4_STREAM=1, DS4_IQ_Q8K=1) until their gates have run.
2026-10-31 01:10:17 +01:00
Niko1221 abe5e67923 ds4 thread pool: fence the generation store before reading the sleepers, and start workers from generation 0
A worker going to sleep at the moment a batch started could miss both the new generation and the wake-up (the caller then runs the batch alone, slower, not wrong). Taken from Project Maya v1.1.36 (#618, Cody Harrison), MIT, credited in third_party/project-maya/NOTICE.md.
2026-10-31 01:08:49 +01:00
Niko1221 3ec28f5cc0 DeepSeek engine: tail restore point judged by its cost (A2), per-class path calibration (A5), sampler/lane/bf16 fixes, DS4_ASYNC_T 16
Taken from Project Maya (MIT), staged against v1.1.42:
- A2: the tail restore point is left out where its read stop costs more than
  reading its tokens again (TailCost wired into the serve loop; one in 32 still
  kept to measure again; STRATA_DS4_RESTORE_TAIL_AUTO=0 keeps it always). Maya
  #668 (mw00, 8721412 and 66fb17b).
- A5: the flow timing correction of the opt-in timing pick stays in the prompt
  size class that measured it (ds4_path_policy.hpp + test). Maya #612 (Will
  Hampson, 6e0d27d). Our default pick is by length already.
- A sampler CUDA error ends the request instead of sending token -1; logits()
  copies on the head stream. Maya #619 (Cody Harrison, 2040174).
- A CPU-lane layer that cannot compute its experts stops the engine instead of
  answering zeros; a layer with no GPU kernel is refused at load, any other
  warns. Maya #620 (Cody Harrison, e598716).
- bf16_rn by arithmetic on CUDA too. Maya #676 (rilken, 79628d7).
- DS4_ASYNC_T default 8 -> 16: windows of 9-16 rows take the same device path
  as 1-8. Maya #608 (mw00, b9c07e7). DS4_ASYNC_T=8 gives the old path.
2026-10-31 01:08:29 +01:00
Niko1221 e6d9221305 serve: ask the DeepSeek engine for restore points (marks=, tail=, shared=) (A1)
Project Maya #639 (needmorevram) and v1.1.37 / v1.1.39 (mw00), written onto Strata's server: deepseek_restore_points()
reads the last user turn, the first user turn (the end of the system prompt and tools) and the tail from the prompt's ids
as the template wrote them; Service.prepare puts them in the request's _restore (never a client's own), StrataEngine
sampling_keys turns them into marks=<pos>,... tail=<pos> shared=<pos>.  The shared start stays Strata's pin=N where the
request already has one (the system messages); shared= is asked only without it.  STRATA_DS4_RESTORE_POINTS (4, 0 = off)
and STRATA_DS4_RESTORE_TAIL (512, 0 = none).  serve/test_ds4_restore_points.py.

Credit: Project Maya #639 (needmorevram), mw00; third_party/project-maya/NOTICE.md.
2026-10-31 00:56:53 +01:00
Niko1221 6e53c7f8d6 DeepSeek engine: restore points (marks) a request asks for, kept in the conversation and in the slots (A0)
Ports Project Maya's restore points (#639, needmorevram, 50a3313; ds4_marks.hpp v1.1.42 by mw00 and needmorevram) onto
src/dsv41 as a per-file merge, not a commit apply:

- ds4_marks.hpp as in Maya v1.1.42 (latest_point, mark_plan, mark_victim, TailCost; TailCost is not wired in yet, #668).
- Model::mark(shared) / marks_cap() / check_bytes(): up to STRATA_DS4_RESTORE_POINTS (4, 0 = none, at most 16) restore
  points besides checkpoints 0 and 1 and the anchor, the least recently used giving way, the shared start last.
  restore() and best_check() go back to a mark; a slot keeps the marks with their roles (3 mark, 4 shared start), slot
  identity strata-ds4-session-4; the .meta file takes up to 3 + 16 checkpoint positions.
- Replaces Strata's fourth checkpoint (ck[3], the pin=N shared start) by a shared mark: pin=N is now shared=N.
- GEN keys marks=<pos>,... tail=<pos> shared=<pos> (pin=N kept): the prompt read stops at each point mark_plan keeps and
  keeps the state there.
- Tests: tests/dsv41/marks_test.cpp (Maya's ds4_marks_test), slot_test for the longer checkpoint list.

Credit: Project Maya #639 (needmorevram), #668 / ds4_marks.hpp (mw00), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 00:56:53 +01:00
1056 changed files with 119003 additions and 5652 deletions

No files matched your search

+1
View File
@@ -1 +1,2 @@
github: Niko1221
buy_me_a_coffee: strataengine
@@ -0,0 +1,68 @@
name: AMD split engine report
description: Tell us whether the all-cards split engine works on your AMD cards (it prints a line asking for this on cards Strata has not run it on)
title: "[Engine] AMD split engine: <your card> x <count>"
labels: ["engine", "amd-split-report"]
body:
- type: markdown
value: |
Strata runs the all-cards split engine by itself on Radeon AI PRO R9700 / RX 9070 (gfx1201). On other AMD cards (RX 9060, RX 7000, the 780M and similar) it only starts when you ask for it with `strata launch --split-engine on`, and it prints one line asking you to report whether it works. We cannot test those cards, so your report is how we learn.
Write only what you saw. A report that says "it did not start" is as useful as one that says "it works".
- type: input
id: card
attributes:
label: Card model and count
description: For example "2 x RX 7900 XTX 24 GB" or "3 x RX 9060 XT 16 GB"
validations:
required: true
- type: input
id: arch
attributes:
label: Architecture (the gfx name)
description: From the engine's line, or `rocminfo | grep gfx`. For example gfx1100.
validations:
required: true
- type: input
id: rocm
attributes:
label: ROCm / driver version
description: Strata's engine package brings its own ROCm libraries; the kernel driver is yours (`uname -r`, `amd-smi version`).
validations:
required: true
- type: input
id: model
attributes:
label: Model and quantization
description: The model Strata loaded, for example Qwen3.8-Flash-Next UD-Q4_K_XL or the IQ3_S set.
validations:
required: true
- type: textarea
id: selftest
attributes:
label: The self-test line
description: Paste the line from the engine's log that starts "The split engine's self-test" (passed or failed). The log is in the run folder shown at launch; `strata launch` also prints it.
render: text
validations:
required: true
- type: dropdown
id: result
attributes:
label: What happened
options:
- It started and the answers look right
- It started and the answers look wrong
- The self-test failed, so the normal layer split started
- It failed to start (not the self-test)
- It started and crashed or hung later
validations:
required: true
- type: input
id: speed
attributes:
label: Speed, tok/s (the split engine, then the layer split with `--split-engine off` if you tried both)
description: For example "decode 61 tok/s, layer split 48 tok/s, one user".
- type: textarea
id: more
attributes:
label: Anything else
description: Wrong answers (the prompt and what you got), crashes (the last lines of the engine log), or what you changed.
+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.9 · engine 0.1.55"
placeholder: "Strata 0.2.12 · engine 0.1.58"
validations:
required: true
- type: input
+1 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md
Version 0.2.9, engine 0.1.55. The public model families are DeepSeekV4.1-Flash-1017,
Version 0.2.12, engine 0.1.58. 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
+293 -21
View File
@@ -8,7 +8,7 @@
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
# and is wrong about its own performance, which is the worst of both outcomes.
cmake_minimum_required(VERSION 3.24)
project(strata VERSION 0.1.55 LANGUAGES CXX)
project(strata VERSION 0.1.58 LANGUAGES CXX)
# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
# own project that takes the unported sources from src/; with this on, nothing below is configured.
option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
@@ -92,6 +92,13 @@ if(STRATA_HIP_GFX906)
endif()
option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201; gfx1012/gfx1030 unvalidated)" OFF)
option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF)
# The community-made AMD changes (include/strata/core/amd_community.hpp): on by default. This option is the build half of the one
# switch STRATA_AMD_COMMUNITY_TUNING: OFF compiles the portable signed-byte dot for gfx1010 again (the SDWA sequence of #4855).
# The rocBLAS tables are switched at run time (STRATA_AMD_COMMUNITY_TUNING=0).
option(STRATA_AMD_COMMUNITY_TUNING "Compile the community-made gfx1010 SDWA dot product (the runtime switch of the same name controls the gfx1030 rocBLAS tables)" ON)
if(NOT STRATA_AMD_COMMUNITY_TUNING)
add_compile_definitions(STRATA_AMD_COMMUNITY_DOT_OFF=1)
endif()
# The rows one verify / batch window holds (include/strata/kernels/window_limits.hpp): 8, the 0.2.7 window, or 16 (four pipelined
# groups of 16 slots = 64 slots on a four-card split, docs/BATCHING.md). Only the CUDA and HIP engines are built from
# these sources with a wide window; the SYCL and Metal builds never read this variable and keep 8. A window of up to
@@ -99,10 +106,10 @@ option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF
# the HIP engines (a HIP code object loads on first use, so the extra kernels cost nothing until a wide window forms)
# and 8 for CUDA, which loads every kernel at start (CUDA_MODULE_LOADING=EAGER: the wide kernels would take VRAM from
# the expert cache of every card); -DSTRATA_WINDOW_MAX_T=16 builds the wide CUDA engine.
set(STRATA_WINDOW_MAX_T "auto" CACHE STRING "rows of a verify / batch window: 8, 16 or auto (HIP 16, CUDA 8)")
set_property(CACHE STRATA_WINDOW_MAX_T PROPERTY STRINGS auto 8 16)
if(NOT STRATA_WINDOW_MAX_T STREQUAL "auto" AND NOT STRATA_WINDOW_MAX_T STREQUAL "8" AND NOT STRATA_WINDOW_MAX_T STREQUAL "16")
message(FATAL_ERROR "STRATA_WINDOW_MAX_T must be auto, 8 or 16 (got '${STRATA_WINDOW_MAX_T}')")
set(STRATA_WINDOW_MAX_T "auto" CACHE STRING "rows of a verify / batch window: 8, 12, 16 or auto (HIP 16, CUDA 8)")
set_property(CACHE STRATA_WINDOW_MAX_T PROPERTY STRINGS auto 8 12 16)
if(NOT STRATA_WINDOW_MAX_T STREQUAL "auto" AND NOT STRATA_WINDOW_MAX_T STREQUAL "8" AND NOT STRATA_WINDOW_MAX_T STREQUAL "12" AND NOT STRATA_WINDOW_MAX_T STREQUAL "16")
message(FATAL_ERROR "STRATA_WINDOW_MAX_T must be auto, 8, 12 or 16 (got '${STRATA_WINDOW_MAX_T}')")
endif()
if(STRATA_ENABLE_HIP OR STRATA_ENABLE_CUDA)
if(STRATA_WINDOW_MAX_T STREQUAL "auto")
@@ -381,6 +388,11 @@ if(STRATA_BUILD_TESTS)
target_include_directories(stage_reserve_test PRIVATE include)
add_test(NAME stage_reserve_test COMMAND stage_reserve_test)
# the layer split's spill walk (header-only arithmetic: who is short, which step comes next)
add_executable(split_walk_test tests/program/split_walk_test.cpp)
target_include_directories(split_walk_test PRIVATE include)
add_test(NAME split_walk_test COMMAND split_walk_test)
# the prompt planner's host-memory model (#2824): header-only, no GPU
add_executable(prefill_plan_test tests/program/prefill_plan_test.cpp)
target_include_directories(prefill_plan_test PRIVATE include)
@@ -391,10 +403,53 @@ if(STRATA_BUILD_TESTS)
target_include_directories(share_rules_test PRIVATE include)
add_test(NAME share_rules_test COMMAND share_rules_test)
# STRATA_SELECT_TWO_PASS: the bound between the single-pass and 3xTF32 QSA block scores and the cut-off rule that
# keeps the selection (header-only, no GPU)
add_executable(qsa_two_pass_test tests/prefill/qsa_two_pass_test.cpp)
target_include_directories(qsa_two_pass_test PRIVATE include)
add_test(NAME qsa_two_pass_test COMMAND qsa_two_pass_test)
# STRATA_SELECT_TWO_PASS: the request's parse and the line it prints once where the scorer cannot run (no GPU)
add_executable(qsa_two_pass_status_test tests/prefill/qsa_two_pass_status_test.cpp)
target_include_directories(qsa_two_pass_status_test PRIVATE include)
add_test(NAME qsa_two_pass_status_test COMMAND qsa_two_pass_status_test
"${CMAKE_CURRENT_BINARY_DIR}/qsa_two_pass_status_test.log")
# STRATA_PREFILL_ROUTED_STREAM: the prompt stream's issuer walk and route gate, on a simulated link and consumer
# (header-only, no GPU)
add_executable(routed_stream_test tests/prefill/routed_stream_test.cpp)
target_include_directories(routed_stream_test PRIVATE include)
target_link_libraries(routed_stream_test PRIVATE Threads::Threads)
add_test(NAME routed_stream_test COMMAND routed_stream_test)
# PR #4006: STRATA_PREFILL_TIMING does not record a mark in the phase already running (header-only, no GPU)
add_executable(timer_marks_test tests/prefill/timer_marks_test.cpp)
target_include_directories(timer_marks_test PRIVATE include)
add_test(NAME timer_marks_test COMMAND timer_marks_test)
# PR #5108: the MMQ row table's over-read margin follows the backend tile bound; every fixup read stays inside (header-only, no GPU)
add_executable(mmq_ids_margin_test tests/prefill/mmq_ids_margin_test.cpp)
target_include_directories(mmq_ids_margin_test PRIVATE include)
add_test(NAME mmq_ids_margin_test COMMAND mmq_ids_margin_test)
# PR #4844: the adaptive tier's copy-in range across serial, pipelined and drained rounds (header-only, no GPU)
add_executable(adaptive_range_test tests/program/adaptive_range_test.cpp)
target_include_directories(adaptive_range_test PRIVATE include)
add_test(NAME adaptive_range_test COMMAND adaptive_range_test)
# P0.1 (lite): STRATA_MANIFEST, the machine-readable record of the selected paths, tensor formats, engine and budgets (header-only, no GPU)
add_executable(run_manifest_test tests/core/run_manifest_test.cpp)
target_include_directories(run_manifest_test PRIVATE include)
add_test(NAME run_manifest_test COMMAND run_manifest_test)
# the Windows HIP auto prefill chunk cap (#1630) and the commit-limit verdict (#1607): header-only, no GPU
add_executable(host_limits_test tests/platform/host_limits_test.cpp)
target_include_directories(host_limits_test PRIVATE include)
add_test(NAME host_limits_test COMMAND host_limits_test)
# #4733: an engine started at Low I/O priority goes back to Normal (Windows; else skips)
add_executable(io_priority_test tests/platform/io_priority_test.cpp src/platform/io_priority.cpp)
target_include_directories(io_priority_test PRIVATE include)
add_test(NAME io_priority_test COMMAND io_priority_test)
set_tests_properties(io_priority_test PROPERTIES SKIP_RETURN_CODE 77)
endif()
# ---- strata_plan ----------------------------------------------------------
@@ -410,7 +465,7 @@ target_link_libraries(strata-plan PRIVATE strata_plan)
# ---- strata_core (GPU runtime) ---------------------------------------------
# Only built when a GPU backend is on, so the Phase 1 targets keep working without a toolkit.
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/core/graph.cpp src/core/weights.cpp
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/platform/io_priority.cpp src/core/graph.cpp src/core/weights.cpp
src/core/layout.cpp)
target_include_directories(strata_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
# the DFlash2 drafter (src/core/dflash2.cpp, src/kernels/cuda/dflash2.cu) exists in the CUDA and HIP builds; the Metal and SYCL builds compile without it
@@ -590,6 +645,13 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(qsa_scores_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME qsa_scores_parity COMMAND qsa_scores_parity)
set_tests_properties(qsa_scores_parity PROPERTIES SKIP_RETURN_CODE 77 ENVIRONMENT "STRATA_SCORES_THREAD=1")
# STRATA_SELECT_TWO_PASS: the two-pass prompt scores' selection against the tensor-core scorer's, ids and the
# scored-again blocks bitwise (sm_80+, synthetic, no model)
add_executable(qsa_two_pass_parity src/kernels/qsa_two_pass_parity.cpp)
target_include_directories(qsa_two_pass_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(qsa_two_pass_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME qsa_two_pass_parity COMMAND qsa_two_pass_parity)
set_tests_properties(qsa_two_pass_parity PROPERTIES SKIP_RETURN_CODE 77 ENVIRONMENT "STRATA_SELECT_TWO_PASS=1")
endif()
# QSAROWS (docs/QSAROWS.md): what the T rows of a verification window cost in a QSA layer's decode kernels at a long
# context (block scores, top-k, attention over each row's selection, indexer append), row by row; GPU, synthetic, no
@@ -849,6 +911,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_executable(dflash2_parity EXCLUDE_FROM_ALL src/core/dflash2_parity.cpp)
target_include_directories(dflash2_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_parity PRIVATE strata_engine strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_q4_parity EXCLUDE_FROM_ALL src/core/dflash2_q4_parity.cpp)
target_include_directories(dflash2_q4_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_q4_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_ring_test EXCLUDE_FROM_ALL src/core/dflash2_ring_test.cpp) # the drafter's ring log and rebuild against an uninterrupted update sequence
target_include_directories(dflash2_ring_test PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_ring_test PRIVATE strata_engine strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_gemm_bench EXCLUDE_FROM_ALL src/core/dflash2_gemm_bench.cpp)
target_include_directories(dflash2_gemm_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_gemm_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
@@ -917,7 +985,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(strata_dsv41_kernels PUBLIC ${_strata_gpu_runtime_target})
add_executable(strata-dsv41 src/dsv41/ds4_main.cpp src/dsv41/ds4_model.cu src/dsv41/ds4_kernels.cu src/dsv41/ds4_vision.cu
src/dsv41/ds4_gguf.cu src/dsv41/ds4_gguf_cpu.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_iq_cpu.cpp
src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_packinfo.cpp)
src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_packinfo.cpp src/platform/io_priority.cpp)
# the commit the binary was built from, readable with --version (set -DSTRATA_BUILD_ID=... for a tree without .git)
if(NOT STRATA_BUILD_ID AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/BUILD_ID) # (a git archive has no .git: the packer writes the commit here)
file(STRINGS ${CMAKE_CURRENT_SOURCE_DIR}/BUILD_ID STRATA_BUILD_ID LIMIT_COUNT 1)
@@ -952,7 +1020,31 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
target_link_libraries(ds4_gg_bench PRIVATE strata_kernels_cpu Threads::Threads)
target_compile_definitions(ds4_gg_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
# the PCIe lane's cost model on this CPU (Project Maya #312's tests/ds4_lane_bench): no GPU, no model
add_executable(ds4_lane_bench tests/dsv41/ds4_lane_bench.cpp src/dsv41/ds4_gguf_cpu.cpp src/dsv41/ds4_iq_cpu.cpp
src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp)
target_include_directories(ds4_lane_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
target_link_libraries(ds4_lane_bench PRIVATE strata_dsv41_kernels strata_kernels_cpu Threads::Threads)
target_compile_definitions(ds4_lane_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
endif()
# the GGUF kernels against a double-precision reference, the PCIe lane's streamed experts against the VRAM ones' bit for
# bit (--lane), and the packed KV caches against the BF16 rows (needs a GPU, no model; built by name)
add_executable(ds4_gguf_test EXCLUDE_FROM_ALL tests/dsv41/ds4_gguf_test.cu src/dsv41/ds4_gguf.cu src/dsv41/ds4_gguf_cpu.cpp
src/dsv41/ds4_iq.cu src/dsv41/ds4_iq_cpu.cpp src/dsv41/ds4_kernels.cu src/dsv41/ds4_tq.cu
src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp)
target_include_directories(ds4_gguf_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill)
target_link_libraries(ds4_gguf_test PRIVATE strata_prefill strata_dsv41_kernels ${_strata_gpu_runtime_target} Threads::Threads)
if(STRATA_NATIVE_EXPERTS)
target_link_libraries(ds4_gguf_test PRIVATE strata_kernels_cpu)
target_compile_definitions(ds4_gguf_test PRIVATE STRATA_DSV41_TQ_CPU=1)
endif()
# the router on a non-finite gate input (valid, distinct picks; the same picks as the host's top-k when finite) and its
# launch times; needs a GPU, no model; built by name
add_executable(ds4_route_guard_test EXCLUDE_FROM_ALL tests/dsv41/ds4_route_guard_test.cu src/dsv41/ds4_kernels.cu)
target_include_directories(ds4_route_guard_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories})
target_link_libraries(ds4_route_guard_test PRIVATE strata_dsv41_kernels ${_strata_gpu_runtime_target} Threads::Threads)
if(STRATA_NATIVE_EXPERTS)
# the trellis experts (device kernels ds4_tq.cu, CPU lane ds4_tq_cpu.cpp) against a double reference; needs a GPU, no model
# both test .cpp files launch kernels, so they compile as HIP on the AMD backend and as CUDA on the NVIDIA one
@@ -995,13 +1087,44 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
if(STRATA_ENABLE_HIP)
target_compile_options(ds4_iq_test PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
endif()
# the dense weights (ds4_iq.cu dequant_rows / mv / f16_rows, ds4_gguf.cu rows / mv, the chunk GEMM) against a CPU reference for every
# dense type and shape, no ggml, no model: tests/dsv41/dq_dense_test.cpp (run_dq_test.sh runs it a process a type and kernel)
set_source_files_properties(tests/dsv41/dq_dense_test.cpp PROPERTIES LANGUAGE ${_ds4_test_lang})
add_executable(ds4_dq_test tests/dsv41/dq_dense_test.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_tq.cu src/dsv41/ds4_gguf.cu)
target_include_directories(ds4_dq_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_link_libraries(ds4_dq_test PRIVATE strata_prefill ${_strata_gpu_runtime_target} ${_strata_gpu_blas_target} Threads::Threads)
target_compile_definitions(ds4_dq_test PRIVATE DQ_TEST_GGUF=1 DQ_TEST_GEMM=1)
if(STRATA_ENABLE_HIP)
target_compile_options(ds4_dq_test PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
endif()
# the kept conversations' .meta file and prefix matching (ds4_slotmeta.hpp): no GPU, no model
add_executable(ds4_slot_test tests/dsv41/slot_test.cpp)
target_include_directories(ds4_slot_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_slot_test PRIVATE cxx_std_17)
# restore points (ds4_marks.hpp, from Project Maya #639): which point a prompt goes back to, which ones a read keeps
add_executable(ds4_marks_test tests/dsv41/marks_test.cpp)
target_include_directories(ds4_marks_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_marks_test PRIVATE cxx_std_17)
# the staging ring's slots by the measured host link (ds4_ring_link.hpp, from Project Maya #609)
add_executable(ds4_ring_link_test tests/dsv41/ring_link_test.cpp)
target_include_directories(ds4_ring_link_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_ring_link_test PRIVATE cxx_std_17)
# the serve loop's event queue (ds4_evt.hpp, from Project Maya #587)
add_executable(ds4_evt_test tests/dsv41/evt_test.cpp)
target_include_directories(ds4_evt_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_evt_test PRIVATE cxx_std_17)
find_package(Threads)
if(Threads_FOUND)
target_link_libraries(ds4_evt_test PRIVATE Threads::Threads)
endif()
add_executable(ds4_experts_rule_test tests/dsv41/experts_rule_test.cpp)
target_include_directories(ds4_experts_rule_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_experts_rule_test PRIVATE cxx_std_17)
# the timing pick's per-size-class flow correction (ds4_path_policy.hpp, from Project Maya #612)
add_executable(ds4_path_policy_test tests/dsv41/path_policy_test.cpp)
target_include_directories(ds4_path_policy_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_path_policy_test PRIVATE cxx_std_17)
add_executable(ds4_pick_rule_test tests/dsv41/pick_rule_test.cpp)
target_include_directories(ds4_pick_rule_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_pick_rule_test PRIVATE cxx_std_17)
@@ -1028,6 +1151,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(hip_q2_zero PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_q2_zero COMMAND hip_q2_zero)
list(APPEND _strata_hip_tests hip_q2_zero)
# the QSA layer's fused norm + rope (rms_rope_rows, STRATA_QSA_FUSE_NR) against split_q + rms_rows + rope, bit for bit over the
# whole output; and the gfx11 prompt GEMMs' full-output coverage (every element written, sampled rows against FP64).
# GPU, synthetic, no model; exit 77 without a gfx11 device.
add_executable(hip_qsa_nr_parity src/prefill/qsa_nr_parity.cu)
target_link_libraries(hip_qsa_nr_parity PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_qsa_nr_parity COMMAND hip_qsa_nr_parity)
set_tests_properties(hip_qsa_nr_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
list(APPEND _strata_hip_tests hip_qsa_nr_parity)
add_executable(hip_pf_cover_test src/prefill/pf_cover_test.cu)
target_include_directories(hip_pf_cover_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill)
target_link_libraries(hip_pf_cover_test PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_pf_cover_test COMMAND hip_pf_cover_test)
set_tests_properties(hip_pf_cover_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 900)
list(APPEND _strata_hip_tests hip_pf_cover_test)
add_executable(hip_expert_cache_staging tests/hip/expert_cache_staging.cpp)
target_link_libraries(hip_expert_cache_staging PRIVATE strata_engine)
add_test(NAME hip_expert_cache_staging COMMAND hip_expert_cache_staging)
@@ -1070,6 +1207,14 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(hip_gdn_rec_head PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_gdn_rec_head COMMAND hip_gdn_rec_head)
list(APPEND _strata_hip_tests hip_gdn_rec_head)
# PR #5096: flag B stays behind queued copies (the same test as the CUDA build's flag_order_test; the HIP compat header maps the runtime calls)
set_source_files_properties(tests/core/flag_order_test.cu PROPERTIES LANGUAGE HIP)
add_executable(hip_flag_order_test tests/core/flag_order_test.cu)
target_include_directories(hip_flag_order_test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(hip_flag_order_test PRIVATE strata_hip_runtime Threads::Threads)
add_test(NAME hip_flag_order_test COMMAND hip_flag_order_test)
set_tests_properties(hip_flag_order_test PROPERTIES TIMEOUT 60)
list(APPEND _strata_hip_tests hip_flag_order_test)
add_executable(hip_gdn_chunked tests/hip/gdn_chunked.cpp)
target_link_libraries(hip_gdn_chunked PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_gdn_chunked COMMAND hip_gdn_chunked)
@@ -1273,6 +1418,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(dma_batch_parity PRIVATE ${_strata_gpu_runtime_target})
add_test(NAME dma_batch_parity COMMAND dma_batch_parity)
set_tests_properties(dma_batch_parity PROPERTIES TIMEOUT 120)
# PR #5096: flag B stays behind queued copies (delayed host-function copy, a consumer kernel, graph replay, every device)
add_executable(flag_order_test tests/core/flag_order_test.cu)
target_include_directories(flag_order_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(flag_order_test PRIVATE ${_strata_gpu_runtime_target} Threads::Threads)
add_test(NAME flag_order_test COMMAND flag_order_test)
set_tests_properties(flag_order_test PROPERTIES TIMEOUT 60)
if(WIN32)
add_test(NAME dma_batch_registered_parity COMMAND dma_batch_parity --registered)
set_tests_properties(dma_batch_registered_parity PROPERTIES TIMEOUT 120)
@@ -1310,6 +1461,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(gemm_dequant_side_parity PRIVATE strata_prefill )
add_test(NAME gemm_dequant_side_parity COMMAND gemm_dequant_side_parity)
set_tests_properties(gemm_dequant_side_parity PROPERTIES SKIP_RETURN_CODE 77)
add_executable(gemm_w16_parity src/prefill/gemm_w16_parity.cu)
target_link_libraries(gemm_w16_parity PRIVATE strata_prefill )
add_test(NAME gemm_w16_parity COMMAND gemm_w16_parity)
set_tests_properties(gemm_w16_parity PROPERTIES SKIP_RETURN_CODE 77)
# Pascal, opt-in STRATA_DENSE_F32=1: the dense projections' FP16 GEMMs as cublasSgemm on FP32 copies, bitwise vs
# the cuBLAS FP16 call over a sweep of chunk sizes (GPU, synthetic, no model; --full sweeps every size, --bench
# adds timings). Not a ctest: whether the two agree is a property of the cuBLAS build, which this checks.
@@ -1328,6 +1483,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(lm_kernels_parity PRIVATE strata_prefill )
add_test(NAME lm_kernels_parity COMMAND lm_kernels_parity)
set_tests_properties(lm_kernels_parity PROPERTIES SKIP_RETURN_CODE 77)
# hc_q8_to_bf16 on the stream it is given: the layer-major prompt walk expanded a STRATA_HC_REQ8
# hyper-connection projection on the default stream while its GEMMs read another. Synthetic, no model.
add_executable(hc_q8_stream_test tests/cuda/hc_q8_stream_test.cu)
target_link_libraries(hc_q8_stream_test PRIVATE strata_prefill )
add_test(NAME hc_q8_stream COMMAND hc_q8_stream_test)
set_tests_properties(hc_q8_stream PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
endif()
# native_expert_grouped's group stride and fused SwiGLU + q8_1, bitwise vs the launches before them (GPU, synthetic,
# no model; --bench adds v1/new timings of a window's 48 calls in a graph)
@@ -1362,13 +1523,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_include_directories(shared_expert_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(shared_expert_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME shared_expert_parity COMMAND shared_expert_parity --selftest)
# the split / batched verify-window kernels (from eddoursul's fork), bitwise vs the kernels they replace; CUDA only
if(STRATA_ENABLE_CUDA)
# the split / batched verify-window kernels (from eddoursul's fork), bitwise vs the kernels they replace
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_executable(verify_parity src/kernels/verify_parity.cpp)
target_include_directories(verify_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(verify_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME verify_parity COMMAND verify_parity --selftest)
endif()
# BW-DENSE: the v3 read's weight-prefetch variants against the plain v3 read, bit for bit, and their cold-weight time
add_executable(gr_v3_screen src/kernels/gr_v3_screen.cpp)
target_include_directories(gr_v3_screen PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(gr_v3_screen PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(mmvf_unr_hash src/kernels/mmvf_unr_hash.cpp)
target_include_directories(mmvf_unr_hash PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(mmvf_unr_hash PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(fused_gr_bench src/kernels/fused_gr_bench.cpp)
target_include_directories(fused_gr_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(fused_gr_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
@@ -1647,6 +1815,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(pin_budget_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME pin_budget_test COMMAND pin_budget_test)
# #3431: the expert arena's registered ranges keep their GPU aliases clear of the process's own memory (Windows HIP; else skips)
add_executable(pinned_alias_test src/core/pinned_alias_test.cpp)
target_link_libraries(pinned_alias_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME pinned_alias_test COMMAND pinned_alias_test)
set_tests_properties(pinned_alias_test PROPERTIES SKIP_RETURN_CODE 77)
# #3350 / #3470: the conversation copy streams and events are pooled across short-lived threads (needs a device, else skips)
add_executable(conversation_copy_streams_test src/core/conversation_copy_streams_test.cpp)
target_include_directories(conversation_copy_streams_test PRIVATE include)
@@ -1753,6 +1927,21 @@ if(STRATA_BUILD_TESTS)
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
target_include_directories(arch_defaults_test PRIVATE include)
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
add_executable(pcie_share_rule_test src/core/pcie_share_rule_test.cpp)
target_include_directories(pcie_share_rule_test PRIVATE include)
add_test(NAME pcie_share_rule_test COMMAND pcie_share_rule_test)
# STRATA_AMD_COMMUNITY_TUNING: the switch turns each community-made AMD change off (header only; run from the source tree)
add_executable(amd_community_test src/core/amd_community_test.cpp)
target_include_directories(amd_community_test PRIVATE include)
target_compile_definitions(amd_community_test PRIVATE EXPECT_DOT_BUILT=1)
add_test(NAME amd_community_test COMMAND amd_community_test WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(amd_community_test_dot_off src/core/amd_community_test.cpp) # the -DSTRATA_AMD_COMMUNITY_TUNING=OFF build
target_include_directories(amd_community_test_dot_off PRIVATE include)
target_compile_definitions(amd_community_test_dot_off PRIVATE EXPECT_DOT_BUILT=0 STRATA_AMD_COMMUNITY_DOT_OFF=1)
add_test(NAME amd_community_test_dot_off COMMAND amd_community_test_dot_off WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(gfx12_extra_test src/core/gfx12_extra_test.cpp src/core/arch_defaults.cpp) # gfx12 extra defaults and their off switches
target_include_directories(gfx12_extra_test PRIVATE include)
add_test(NAME gfx12_extra_test COMMAND gfx12_extra_test)
add_executable(nv_defaults_test src/core/nv_defaults_test.cpp src/core/nv_defaults.cpp) # the sm_121 (GB10) table and its two off switches
target_include_directories(nv_defaults_test PRIVATE include)
add_test(NAME nv_defaults_test COMMAND nv_defaults_test)
@@ -1774,6 +1963,12 @@ if(STRATA_BUILD_TESTS)
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
target_link_libraries(draft_policy_test PRIVATE strata_spec)
add_test(NAME draft_policy_test COMMAND draft_policy_test)
add_executable(window_rule_test src/spec/window_rule_test.cpp) # which rule picks the window / whose q judges it, by the active draft source
target_include_directories(window_rule_test PRIVATE include)
add_test(NAME window_rule_test COMMAND window_rule_test)
add_executable(draft_vocab_test src/spec/draft_vocab_test.cpp) # the DFlash2-alone start's draft-vocabulary choice (a build's packaged default subset; the error elsewhere)
target_include_directories(draft_vocab_test PRIVATE include)
add_test(NAME draft_vocab_test COMMAND draft_vocab_test)
add_executable(spec_start_test src/spec/spec_start_test.cpp)
target_include_directories(spec_start_test PRIVATE include)
add_test(NAME spec_start_test COMMAND spec_start_test)
@@ -1798,6 +1993,16 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(window_limits_test src/core/window_limits_test.cpp)
target_include_directories(window_limits_test PRIVATE include)
add_test(NAME window_limits_test COMMAND window_limits_test)
# #4677: the accepted rows a request commits are bounded to what it can emit (header-only, CPU; 2,736 cases against the old output loop)
add_executable(serve_stop_bound_test tests/core/serve_stop_bound_test.cpp)
target_include_directories(serve_stop_bound_test PRIVATE include)
target_compile_features(serve_stop_bound_test PRIVATE cxx_std_17)
add_test(NAME serve_stop_bound_test COMMAND serve_stop_bound_test)
# the --lookup-max-t default (12 on the 8-row CUDA serve, off on Metal, HIP and every other case; header-only, CPU)
add_executable(lookup_default_test tests/core/lookup_default_test.cpp)
target_include_directories(lookup_default_test PRIVATE include)
target_compile_features(lookup_default_test PRIVATE cxx_std_17)
add_test(NAME lookup_default_test COMMAND lookup_default_test)
# which window-size graph is freed when the next one does not fit (#3702; header-only, CPU)
add_executable(window_evict_test src/core/window_evict_test.cpp)
target_include_directories(window_evict_test PRIVATE include)
@@ -1810,6 +2015,14 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(coupled_draft_test src/core/coupled_draft_test.cpp)
target_include_directories(coupled_draft_test PRIVATE include)
add_test(NAME coupled_draft_test COMMAND coupled_draft_test)
# #4590: the penalty window is the current reply by default, STRATA_PENALTY_PROMPT=1 the old prompt + reply window (header-only, CPU)
add_executable(penalty_window_test src/kernels/penalty_window_test.cpp)
target_include_directories(penalty_window_test PRIVATE include)
add_test(NAME penalty_window_test COMMAND penalty_window_test)
# the penalty-history rows of a batch window, per slot (header-only, CPU)
add_executable(slot_history_test src/kernels/slot_history_test.cpp)
target_include_directories(slot_history_test PRIVATE include)
add_test(NAME slot_history_test COMMAND slot_history_test)
# probabilistic draft acceptance (STRATA_SPEC_PROB): rejection sampling preserves p - chi-square over 2M trials per case (CPU)
add_executable(spec_prob_test src/core/spec_prob_test.cpp)
target_include_directories(spec_prob_test PRIVATE include)
@@ -2073,6 +2286,12 @@ if(STRATA_NATIVE_EXPERTS)
# MODEL-UPDATE: the pool's expert rows per format on T threads against the RAM bandwidth (a benchmark, no test)
add_executable(kq_cpu_bench src/kernels/cpu/kq_cpu_bench.cpp)
target_link_libraries(kq_cpu_bench PRIVATE strata_kernels_cpu)
# round 0216: a hash per (pair, token count, row range) of the pool's native expert calls, to compare a change switched off and on
add_executable(cpu_dispatch_dump src/kernels/cpu/cpu_dispatch_dump.cpp)
target_link_libraries(cpu_dispatch_dump PRIVATE strata_kernels_cpu)
# round 0216: the CPU pool's part of a decode window replayed without a GPU (per-layer format pairs, calibrated expert and row counts, cold DRAM)
add_executable(kq_window_bench src/kernels/cpu/kq_window_bench.cpp)
target_link_libraries(kq_window_bench PRIVATE strata_kernels_cpu)
if(STRATA_BUILD_TESTS)
add_test(NAME iq_avx2_parity COMMAND iq_avx2_parity)
add_test(NAME iq_avx2_parity_iq3s_mt1 COMMAND ${CMAKE_COMMAND} -E env STRATA_IQ3S_MT1=1 $<TARGET_FILE:iq_avx2_parity>)
@@ -2148,7 +2367,7 @@ if(STRATA_NATIVE_EXPERTS)
endif()
# #136: the Q2_0 pack's prompt experts on Strata's own int8 kernels (moe_fused.cu, opt-in STRATA_PF_FUSED=1; CUDA
# only: the HIP branch below keeps MMQ, and an sm_75 device or an sm_75-only build reports them unavailable)
add_library(strata_mmq STATIC src/prefill/moe_mmq.cu src/prefill/moe_fused.cu src/prefill/moe_fused_iq.cu
add_library(strata_mmq STATIC src/prefill/moe_mmq.cu src/prefill/moe_iq4_w4a8.cu src/prefill/moe_fused.cu src/prefill/moe_fused_iq.cu
src/prefill/mmq_nvfp4_w4a8.cu
src/prefill/ggml_cuda_host.cu ${_strata_quantize_cu})
if(STRATA_EXPERIMENTAL_SM60 AND NOT STRATA_HIP_GFX906)
@@ -2330,6 +2549,11 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(prefill_fused_iq_test_ws PROPERTIES ENVIRONMENT "STRATA_PF_FUSED_WS=1" SKIP_RETURN_CODE 77 TIMEOUT 600)
# the MoE input quantized per token and scattered to its rows (mmq::quantize_scatter) against the per-row
# gather: the same q8_1 bytes for every MMQ layout (--bench: both at 2K, 8K and 32K tokens)
# the Unsloth v3.1 sets' expert formats through MMQ (Q5_0 down only in a STRATA_ORCA_Q4KS_MMQ build), and --bench
add_executable(prefill_mmq_v31_test tests/cuda/prefill_mmq_v31_test.cpp)
target_link_libraries(prefill_mmq_v31_test PRIVATE strata_mmq strata_kernels ggml-base ${_strata_gpu_blas_target} ${_strata_gpu_runtime_target})
add_test(NAME prefill_mmq_v31_test COMMAND prefill_mmq_v31_test)
set_tests_properties(prefill_mmq_v31_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
add_executable(prefill_mmq_scatter_test tests/cuda/prefill_mmq_scatter_test.cpp)
target_link_libraries(prefill_mmq_scatter_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
add_test(NAME prefill_mmq_scatter_test COMMAND prefill_mmq_scatter_test)
@@ -2348,6 +2572,14 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(prefill_ws_parity_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 600)
endif()
endif()
if(STRATA_BUILD_TESTS AND NOT STRATA_HIP_GFX906)
# STRATA_IQ4_W4A8: the IQ4 W4A8 expert kernel against MMQ, bit for bit, on the group buffers and (with
# STRATA_MMQ_TAB) the pointer table (exit 77 without a card or off sm_80..sm_88; gfx906: a stub, no test)
add_executable(prefill_iq4_w4a8_test tests/cuda/prefill_iq4_w4a8_test.cu)
target_link_libraries(prefill_iq4_w4a8_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
add_test(NAME prefill_iq4_w4a8_test COMMAND prefill_iq4_w4a8_test)
set_tests_properties(prefill_iq4_w4a8_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
endif()
if(STRATA_BUILD_TESTS AND _strata_mmq_tab_on)
# STRATA_MMQ_DIRECT: the pointer-table MMQ bit for bit against the gathered MMQ (exit 77 below sm_80)
add_executable(prefill_mmq_tab_test tests/cuda/prefill_mmq_tab_test.cu)
@@ -2364,10 +2596,12 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(prefill_mmq_kquant_test PROPERTIES TIMEOUT 120 ENVIRONMENT STRATA_MMQ_KQ=1)
endif()
# the NVFP4 expert products (w4a8 / w4a4, STRATA_PREFILL_NVFP4) against a double-precision reference
add_executable(mmq_nvfp4_parity src/prefill/mmq_nvfp4_parity.cpp)
target_link_libraries(mmq_nvfp4_parity PRIVATE strata_mmq ggml-base CUDA::cudart)
if(NOT MSVC) # its FP16 rounding helper uses the F16C intrinsics (GCC/Clang need the target flag)
target_compile_options(mmq_nvfp4_parity PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-mf16c>)
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64|i[3-6]86)$") # its FP16 rounding helper is the x86 F16C intrinsics: not built on aarch64
add_executable(mmq_nvfp4_parity src/prefill/mmq_nvfp4_parity.cpp)
target_link_libraries(mmq_nvfp4_parity PRIVATE strata_mmq ggml-base CUDA::cudart)
if(NOT MSVC) # GCC/Clang need the target flag
target_compile_options(mmq_nvfp4_parity PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-mf16c>)
endif()
endif()
# the routed-expert products (MMQ) timed at the prompt path's shapes (16 experts a launch)
add_executable(mmq_expert_bench src/prefill/mmq_expert_bench.cpp)
@@ -2504,13 +2738,11 @@ if(STRATA_NATIVE_EXPERTS)
add_executable(q8_gemv_bench src/kernels/q8_gemv_bench.cpp)
target_include_directories(q8_gemv_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(q8_gemv_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
if(STRATA_ENABLE_CUDA)
# EXPDEC (docs/EXPERTDEC.md): the grouped experts' default kernels against STRATA_EXPDEC=1 at T = 1..4, bit for bit
# and in microseconds, on synthetic blobs read cold from DRAM
add_executable(expdec_bench EXCLUDE_FROM_ALL src/kernels/expdec_bench.cpp)
target_include_directories(expdec_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(expdec_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
endif()
# EXPDEC (docs/EXPERTDEC.md): the grouped experts' default kernels against STRATA_EXPDEC=1 at T = 1..4, bit for bit
# and in microseconds, on synthetic blobs read cold from DRAM
add_executable(expdec_bench EXCLUDE_FROM_ALL src/kernels/expdec_bench.cpp)
target_include_directories(expdec_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(expdec_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
if(STRATA_BUILD_TESTS AND STRATA_ENABLE_CUDA)
# Unsloth UD-Q4_K_XL's three expert pairs (and Q8_0 alone, its embedding) on synthetic ggml-quantized weights
# at 2560/640: GPU and CPU experts against the float reference, GPU dequantizers against to_float.
@@ -2535,6 +2767,21 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(native_expert_parity_${_name} PROPERTIES TIMEOUT 120)
endforeach()
add_test(NAME native_expert_parity_q5_1_min COMMAND native_expert_parity --q5_1-min)
# Unsloth v3.1 Qwen3.8-Flash-Next sets (UD-IQ1_M .. UD-IQ4_XS, round 0214): every gate/up x down pair their layers use,
# at the real shapes (hidden 2560, FF 640): gate/up IQ2_XXS IQ2_XS IQ2_S IQ3_XXS IQ4_XS (+ IQ1_M, the GSQ-RCO packs'),
# down Q2_0 IQ4_NL Q5_0 Q8_0. Q5_0 down (UD-IQ4_XS 33 layers, UD-Q3_K_XL 20) had no test on this model before.
foreach(_gu iq1_m iq2_xxs iq2_xs iq2_s iq3_xxs iq4_xs)
foreach(_dn q2_0 iq4_nl q5_0 q8_0)
add_test(NAME native_expert_parity_v31_${_gu}_${_dn} COMMAND native_expert_parity --synthetic ${_gu}/${_dn})
set_tests_properties(native_expert_parity_v31_${_gu}_${_dn} PROPERTIES TIMEOUT 180)
endforeach()
endforeach()
# the dense projections (Q5_0, Q6_K, Q5_K, Q4_K, Q8_0, IQ4_XS, IQ4_NL ...): decode MMVQ and the prompt's dequantizers
add_executable(native_dense_parity src/kernels/native_dense_parity.cpp)
target_include_directories(native_dense_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(native_dense_parity PRIVATE strata_kernels ggml-base ${_strata_gpu_runtime_target})
add_test(NAME native_dense_parity COMMAND native_dense_parity)
set_tests_properties(native_dense_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
# APR: the trellis expert types and the Hadamard activation rotation, when ggml comes from the agention fork
# (the only tree with these types; marked by its ggml-cuda/tq.cuh)
if(STRATA_GGML_DIR AND EXISTS "${STRATA_GGML_DIR}/ggml/src/ggml-cuda/tq.cuh")
@@ -2553,6 +2800,24 @@ if(STRATA_NATIVE_EXPERTS)
add_test(NAME native_expert_parity_bf16_embd COMMAND native_expert_parity --bf16-embd)
endif()
endif()
# Round 0214 (the new Unsloth Qwen3.8-Flash-Next quants UD-IQ1_M .. UD-IQ4_XS), CUDA and HIP: every expert type pair the
# sets use at 2560/640 (GPU decode kernels, the CPU pool and the prompt path's dequantizers against ggml), and the dense
# tensor types (Q5_0, Q6_K, the K-quants, IQ4_NL, IQ4_XS, Q2_0) through native_mmvq.
if(STRATA_BUILD_TESTS AND (STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP))
set(_strata_new_gu iq1_m iq2_xxs iq2_xs iq2_s q2_0 iq3_xxs iq4_xs)
set(_strata_new_dn q2_0 iq4_nl q5_0 q8_0)
foreach(_g ${_strata_new_gu})
foreach(_d ${_strata_new_dn})
add_test(NAME native_expert_parity_new_${_g}_${_d} COMMAND native_expert_parity --synthetic ${_g}/${_d})
set_tests_properties(native_expert_parity_new_${_g}_${_d} PROPERTIES TIMEOUT 300)
endforeach()
endforeach()
add_executable(native_dense_types_parity src/kernels/native_dense_types_parity.cpp)
target_include_directories(native_dense_types_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(native_dense_types_parity PRIVATE strata_kernels strata_kernels_cpu ${_strata_gpu_runtime_target})
add_test(NAME native_dense_types_parity COMMAND native_dense_types_parity)
set_tests_properties(native_dense_types_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
endif()
# #296's real-weight checks (OrcaRouter Q4_K_S files; docs/ORCA_Q4_K_S.md) - test programs, not part of a release
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS)
add_executable(ple_q5_parity src/kernels/ple_q5_parity.cpp)
@@ -2792,3 +3057,10 @@ if(STRATA_BUILD_DSV41 AND NOT STRATA_ENABLE_CUDA AND NOT STRATA_ENABLE_HIP AND N
target_compile_definitions(ds4_gg_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
endif()
endif()
# ---- strata-split: the opt-in engine that splits every layer across 2-4 NVIDIA cards (src/split, from sFNX, MIT, John Fink) ----
# OFF by default, Linux + CUDA only, no other target depends on it; see src/split/CMakeLists.txt and docs/SPLIT_ENGINE.md.
option(STRATA_BUILD_SPLIT "Build strata-split: sFNX's split-every-layer engine, host-memory exchange (Linux, CUDA or HIP gfx1201, 2-4 cards)" OFF)
if(STRATA_BUILD_SPLIT)
add_subdirectory(src/split)
endif()
+16 -2
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>
@@ -56,7 +57,7 @@ Wir empfehlen **zuerst ISTA-DASLab**, danach **Unsloth** und andere Anbieter, so
In der **Desktop-App** und im **Terminal**. Was nicht in den Arbeitsspeicher passt, wird von deiner SSD gelesen. Darum brauchst du viel freien Speicherplatz.
| Set (IST-DAS) | Download | Freier Speicherplatz nötig |
| Set (ISTA-DAS) | Download | Freier Speicherplatz nötig |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | etwa 245 GB |
| 2.00 bpw | 257 GB | etwa 265 GB |
@@ -175,10 +176,23 @@ 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>
<!-- 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>
+16 -2
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>
@@ -56,7 +57,7 @@ Recomendamos **primero ISTA-DASLab**, después **Unsloth** y otros proveedores,
En la **aplicación de escritorio** y en el **Terminal**. Lo que no cabe en la memoria se lee desde tu SSD, así que hace falta mucho espacio libre en disco.
| Conjunto (IST-DAS) | Descarga | Espacio libre necesario |
| Conjunto (ISTA-DAS) | Descarga | Espacio libre necesario |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | unos 245 GB |
| 2.00 bpw | 257 GB | unos 265 GB |
@@ -175,10 +176,23 @@ 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>
<!-- 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>
+16 -2
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>
@@ -56,7 +57,7 @@ Nous recommandons **d'abord ISTA-DASLab**, puis **Unsloth** et d'autres fourniss
Dans l'**application de bureau** et dans le **Terminal**. Ce qui ne tient pas en mémoire est lu depuis votre SSD : il faut donc beaucoup d'espace disque libre.
| Jeu (IST-DAS) | Téléchargement | Espace disque libre nécessaire |
| Jeu (ISTA-DAS) | Téléchargement | Espace disque libre nécessaire |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | environ 245 GB |
| 2.00 bpw | 257 GB | environ 265 GB |
@@ -175,10 +176,23 @@ 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>
<!-- 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>
+16 -2
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>
@@ -56,7 +57,7 @@
**デスクトップアプリ**と**ターミナル**で使えます。メモリに収まらない分は SSD から読み込むため、空きディスク容量がたくさん必要です。
| セット(IST-DAS) | ダウンロード | 必要な空き容量 |
| セット(ISTA-DAS) | ダウンロード | 必要な空き容量 |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | 約245 GB |
| 2.00 bpw | 257 GB | 約265 GB |
@@ -175,10 +176,23 @@ 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>
<!-- supporters:end -->
[MIT ライセンス](LICENSE) · [クレジットとモデルのライセンス](docs/HOW_IT_WORKS.md#credits) · [プライバシー](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+17 -2
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>
@@ -57,11 +58,12 @@ We recommend **ISTA-DASLab first**, then **Unsloth** and other providers, where
In the **desktop app** and the **Terminal**. What does not fit in memory is read from your SSD, so it needs a lot of free
disk space.
| Set (IST-DAS) | Download | Free disk needed |
| Set (ISTA-DAS) | Download | Free disk needed |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | about 245 GB |
| 2.00 bpw | 257 GB | about 265 GB |
| 2.25 bpw | 274 GB | about 280 GB |
| 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.
@@ -180,10 +182,23 @@ 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>
<!-- 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>
+16 -2
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>
@@ -57,7 +58,7 @@ Najpierw polecamy **ISTA-DASLab**, potem **Unsloth** i innych dostawców — o i
W **aplikacji na komputer** i w **Terminalu**. To, co nie mieści się w pamięci, jest czytane z dysku SSD, więc potrzebujesz
dużo wolnego miejsca na dysku.
| Zestaw (IST-DAS) | Do pobrania | Potrzebne wolne miejsce |
| Zestaw (ISTA-DAS) | Do pobrania | Potrzebne wolne miejsce |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | około 245 GB |
| 2.00 bpw | 257 GB | około 265 GB |
@@ -179,10 +180,23 @@ 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>
<!-- 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>
+16 -2
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>
@@ -56,7 +57,7 @@ Recomendamos **primeiro a ISTA-DASLab**, depois a **Unsloth** e outros provedore
No **aplicativo de desktop** e no **Terminal**. O que não cabe na memória é lido do seu SSD, por isso é preciso muito espaço livre em disco.
| Conjunto (IST-DAS) | Download | Espaço livre necessário |
| Conjunto (ISTA-DAS) | Download | Espaço livre necessário |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | cerca de 245 GB |
| 2.00 bpw | 257 GB | cerca de 265 GB |
@@ -175,10 +176,23 @@ 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>
<!-- 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>
+16 -2
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>
@@ -56,7 +57,7 @@
可在**桌面应用**和**终端**中使用。内存放不下的部分会从 SSD 读取,所以需要大量空闲磁盘空间。
| 版本(IST-DAS) | 下载大小 | 所需空闲磁盘 |
| 版本(ISTA-DAS) | 下载大小 | 所需空闲磁盘 |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | 约245 GB |
| 2.00 bpw | 257 GB | 约265 GB |
@@ -175,10 +176,23 @@ 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>
<!-- 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.9",
"version": "0.2.12",
"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.9"
version = "0.2.12"
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.9"
version = "0.2.12"
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);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ Implemented 14 October 2026 in the React/Node app under `backups/strata`.
The default is one model at a time. First launch uses the visual `GuidedSetup` funnel: **Welcome → Your model → Prepare → Make it yours → Chat**. Welcome has animated vector artwork and one expert skip at the bottom. Model selection offers friendly version cards, context, RAM/VRAM inventory with an aggregate total, and an existing-folder browser. Unified CPU/GPU memory counts once. Memory verdicts are guidance: **May run slower** does not block selection or Continue. There is no compatibility alternatives panel or picture-reading question at this stage. The preparation screen confirms the download size before **Download model**, then shows engine-reported progress alongside a clickable Chat / Files / Your setup tour. Vision-capable families always prepare their mmproj/encoder files. Preparation does not load the model automatically.
After preparation, users choose **Leave 20% for my work** or **Full performance**, and choose whether to **Read pictures when this model loads** (off initially). The optional longer native calibration measures engine settings for approximately 5–10 minutes. **Tune & start chatting** applies the resource and vision preferences, loads one model, performs a short benchmark, and completes setup only when that model is healthy. Chat opens automatically with starter prompts for a new user; selecting a prompt fills the composer without sending it. Progress, choices and operation IDs survive reloads. Finish later preserves the current stage and keeps completed files available.
After preparation, users choose **Leave room for my work** or **Full performance**, and choose whether to **Read pictures when this model loads** (off initially). The optional longer native calibration measures engine settings for approximately 5–10 minutes. **Tune & start chatting** applies the resource and vision preferences, loads one model, performs a short benchmark, and completes setup only when that model is healthy. Chat opens automatically with starter prompts for a new user; selecting a prompt fills the composer without sending it. Progress, choices and operation IDs survive reloads. Finish later preserves the current stage and keeps completed files available.
Normal Models shows Version, optional picture reading, context, Computer use and Load model. The resource preference can be changed here after onboarding. Image generation is a separate collapsed option. The selected model and context remain visible in the toolbar; saved setups and Manage models are available there. Local GGUF import is a secondary entry in Version. Imported models retain the existing metadata/shard validation and reference their files without copying them.
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Strata — local AI, tuned to one model</title>
<title>Strata</title>
<meta name="description" content="Strata runs one local model, tuned to your hardware." />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f9fafb" />
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.9",
"version": "0.2.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.9",
"version": "0.2.12",
"dependencies": {
"html-to-image": "^1.11.13",
"katex": "^0.19.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata",
"private": true,
"version": "0.2.9",
"version": "0.2.12",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* AGENT-LOST end to end: a long single-file write through the real agent bridge, the real Harness and a real engine.
*
* STRATA_HARNESS_DIR=<harness folder> node scripts/agent-lost-e2e.mjs [pagoda|long|cut ...]
*
* Scenarios (default: all)
* pagoda the owner's own prompt ("Write me a 100 voxel pagoda in one html file with garden."), thinking Medium, bypass mode
* long a deterministic long write: one file of N numbered lines (E2E_LINES, default 1800, about 22K tokens), thinking Off
* mcp Strata's own tool loop (serve runs an MCP tool itself): a call that finishes, and a call the reply is cut inside
* raw the model server alone: a reply cut inside a tool call, as the client receives it
* cut the same long write with the Harness told a small window (E2E_CUT_WINDOW, default 24000), so the reply budget is
* about 7K tokens and the reply is cut inside the tool call: the turn must say so in words and the agent must carry on
*
* A model server that is already running is used when E2E_BASE_URL is set (nothing is started or stopped). Otherwise this
* starts serve/server.py as its own child with the engine named below and stops it by PID.
*
* Environment: E2E_ENGINE (strata executable), E2E_PACK, E2E_GGUF1, E2E_GGUF2, E2E_MTP, E2E_PROFILE, E2E_LIB (library folder),
* E2E_PORT (18590), E2E_WINDOW (65536), E2E_OUT (result folder), E2E_PYTHON.
*
* It prints one table and writes <E2E_OUT>/result.json with the frames summary of each scenario.
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
const REPO = join(import.meta.dirname, '..', '..', '..');
const env = (name, fallback) => process.env[name] ?? fallback;
const HOME = process.env.HOME ?? '';
const PORT = Number(env('E2E_PORT', '18590'));
const WINDOW = Number(env('E2E_WINDOW', '65536'));
const EXISTING = process.env.E2E_BASE_URL ?? '';
const PYTHON = env('E2E_PYTHON', 'python3');
const OUT = env('E2E_OUT', mkdtempSync(join(tmpdir(), 'strata-agent-lost-')));
const LINES = Number(env('E2E_LINES', '1100'));
const REPEAT = Number(env('E2E_REPEAT', '1'));
const TURN_S = Number(env('E2E_TURN_S', '900'));
const wanted = process.argv.slice(2);
const want = id => wanted.length === 0 || wanted.includes(id);
mkdirSync(OUT, { recursive: true });
if (!process.env.STRATA_HARNESS_DIR) { console.error('STRATA_HARNESS_DIR is not set'); process.exit(2); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
let server = null, bridge = null;
const finish = async (code) => {
try { await bridge?.close(); } catch { /* going away */ }
if (server?.pid) { try { process.kill(-server.pid, 'SIGTERM'); } catch { try { process.kill(server.pid, 'SIGTERM'); } catch { /* gone */ } } }
await sleep(1500);
console.log(`\nresult folder: ${OUT}`);
process.exit(code);
};
process.on('SIGINT', () => finish(130));
process.on('SIGTERM', () => finish(143));
if (!EXISTING) {
const pack = env('E2E_PACK', join(HOME, '026q/pack-iq3s'));
const g1 = env('E2E_GGUF1', join(HOME, 'models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00001-of-00002.gguf'));
const g2 = env('E2E_GGUF2', join(HOME, 'models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00002-of-00002.gguf'));
const mtp = env('E2E_MTP', join(HOME, 'pr1426/mtp-rt'));
const profile = env('E2E_PROFILE', join(HOME, 'bwm/cnt3/data/expert-profile.bin'));
const exe = env('E2E_ENGINE', join(HOME, 'agentlost/eng/strata'));
const lib = env('E2E_LIB', join(HOME, 'agentlost/eng'));
for (const f of [pack, g1, g2, mtp, exe]) if (!existsSync(f)) { console.error(`missing: ${f}`); process.exit(2); }
const config = join(OUT, 'server.json');
writeFileSync(config, JSON.stringify({
exe, args: ['--pack', pack, '--native', g1, '--ple-gguf', g2, ...(existsSync(profile) ? ['--expert-profile', profile] : []),
'--prefill', 'auto', '--spec-min-p', '0.5', '--mtp', mtp, '--max-context', String(WINDOW), '--kv', 'int8', '--expert-cache', 'auto', '--spec', '4',
...(process.env.E2E_ENGINE_ARGS ? process.env.E2E_ENGINE_ARGS.split(' ').filter(Boolean) : [])],
cwd: REPO, tokenizer: join(pack, 'tokenizer'), model_name: 'qwen3.8-flash-next-iq3_s', log: join(OUT, 'engine.log'), lib_dirs: [lib], port: PORT, gpu: 0,
fit_max_tokens: true,
// Strata's own tool loop (serve/server.py run_with_mcp): one small MCP server, offered only to requests that ask for it (strata_mcp).
mcp_servers: { fake: { command: PYTHON, args: [join(REPO, 'serve', 'mcp_fake_server.py')] } },
}, null, 2));
server = spawn(PYTHON, ['serve/server.py', '--engine', 'strata', '--config', config, '--port', String(PORT), '--no-api-key', '--ready-file', join(OUT, 'ready.json')], {
cwd: REPO, env: { ...process.env, LD_LIBRARY_PATH: `${lib}:${process.env.LD_LIBRARY_PATH ?? ''}`, STRATA_DATA_DIR: join(OUT, 'data'), PYTHONPATH: `${join(REPO, 'tools')}:${REPO}` }, stdio: ['ignore', 'pipe', 'pipe'], detached: true,
});
const sink = [];
server.stdout.on('data', d => sink.push(String(d)));
server.stderr.on('data', d => sink.push(String(d)));
server.on('exit', code => { sink.push(`[server exited ${code}]`); server = null; writeFileSync(join(OUT, 'server.log'), sink.join('')); });
}
const base = EXISTING || `http://127.0.0.1:${PORT}`;
let ready = false;
for (let i = 0; i < 360 && !ready; i++) {
if (!EXISTING && !server) break;
try { ready = (await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* still loading */ }
if (!ready) await sleep(2000);
}
if (!ready) { console.error('the engine did not become ready'); await finish(4); }
const model = (await (await fetch(`${base}/v1/models`)).json()).data[0].id;
console.log(`engine ready: ${model}`);
const rows = [];
const note = (id, ok, detail) => { rows.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${id} ${detail}`); };
/** The bridge for a window as the host would tell it (the engine's own window stays WINDOW). */
function makeBridge(window, dir) {
mkdirSync(join(OUT, dir), { recursive: true });
return createAgentBridge({
dataDir: join(OUT, dir), env: process.env, startTimeoutMs: 180_000, log: l => console.log(' bridge:', l),
localInstances: () => [{ id: 'e2e', label: 'iq3_s', baseUrl: `${base}/v1`, modelId: model, context: window, slots: 1, key: '' }], connections: () => [],
});
}
bridge = makeBridge(WINDOW, 'agent');
/** One agent turn; every frame is kept in a compact timeline so the live data path can be read afterwards. */
async function turn(name, message, { thinking = 'Medium', mode = 'bypass', chatId = name } = {}) {
const workspace = join(OUT, 'ws-' + name);
mkdirSync(workspace, { recursive: true });
const frames = [];
const t0 = Date.now();
let timedOut = false;
const stopTimer = setTimeout(() => { timedOut = true; void bridge.stop(chatId).catch(() => {}); }, TURN_S * 1000);
await bridge.runTurn({ chatId, message, workspacePath: workspace, mode, thinking, onFrame: f => frames.push({ at: Date.now() - t0, f }) });
clearTimeout(stopTimer);
const seconds = (Date.now() - t0) / 1000;
const byType = {};
for (const { f } of frames) byType[f.type] = (byType[f.type] ?? 0) + 1;
const done = frames.map(x => x.f).filter(f => f.type === 'done').at(-1) ?? null;
const error = frames.map(x => x.f).filter(f => f.type === 'error').at(-1) ?? null;
const prepare = frames.map(x => x.f).filter(f => f.type === 'status' && (f.phase === 'preparing' || f.code === 'preparing-tool'));
const cards = frames.map(x => x.f).filter(f => f.type === 'agent-tool').map(f => f.tool);
const text = frames.map(x => x.f).filter(f => f.type === 'delta').map(f => f.text).join('');
const files = readdirSync(workspace).map(n => ({ name: n, bytes: statSync(join(workspace, n)).size }));
const cutNotes = frames.map(x => x.f).filter(f => f.type === 'delta' && f.noteReason === 'cut').map(f => f.text.trim());
const drafts = frames.map(x => x.f).filter(f => f.type === 'agent-tool-draft').map(f => f.draft);
const writes = drafts.filter(d => d.name === 'write' && d.bytes > 0);
const draftStats = { frames: drafts.length, writeFrames: writes.length, lastWrite: writes.filter(d => !d.done).at(-1) ? { target: writes.filter(d => !d.done).at(-1).target, bytes: writes.filter(d => !d.done).at(-1).bytes, lines: writes.filter(d => !d.done).at(-1).lines } : null, previewSample: writes.filter(d => !d.done).at(-1)?.preview.slice(-80) ?? null };
const writesDone = cards.filter(c => c.name === 'write' && c.state === 'done').length;
const summary = { name, seconds, timedOut, writesDone, cutNotes, draftStats, byType, done, error, files, cards: cards.map(c => ({ name: c.name, state: c.state, target: c.target })), prepareStatuses: prepare.length, firstPrepare: prepare[0] ?? null, lastPrepare: prepare.at(-1) ?? null, text: text.slice(-1200) };
writeFileSync(join(OUT, `turn-${name}.json`), JSON.stringify({ summary, frames: frames.slice(0, 4000) }, null, 1));
return { ...summary, frames, workspace };
}
const longPrompt = `Create the file big.txt in the current folder with the write tool, in ONE call. It must hold exactly ${LINES} lines. Line N is "N: " followed by one short true sentence about a different city of the world (a new city on every line, no repeats, no pattern). Write every line out yourself, do not use a script or a loop, and do not shorten it.`;
if (want('raw')) {
// What the model server itself sends when a reply is cut inside a tool call (no Harness): the stream of tool_calls pieces, then finish_reason "length".
const body = { model, stream: true, max_tokens: Number(env('E2E_RAW_CAP', '700')), temperature: 0.6,
chat_template_kwargs: { enable_thinking: false },
tools: [{ type: 'function', function: { name: 'write', description: 'Write a file.', parameters: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] } } }],
messages: [{ role: 'user', content: longPrompt }] };
const res = await fetch(`${base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const text = await res.text();
const events = text.split(String.fromCharCode(10)).filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
let args = '', name = '', content = '', finish = null, pieces = 0, ids = new Set();
for (const e of events) for (const c of e.choices ?? []) {
for (const tc of c.delta?.tool_calls ?? []) { pieces += 1; if (tc.id) ids.add(tc.id); if (tc.function?.name) name = tc.function.name; args += tc.function?.arguments ?? ''; }
content += c.delta?.content ?? '';
if (c.finish_reason) finish = c.finish_reason;
}
const usage = events.map(e => e.usage).filter(Boolean).at(-1);
writeFileSync(join(OUT, 'raw-sse.txt'), text);
note('raw: a reply cut inside a tool call reaches the client as pieces of the call and finish_reason length', finish === 'length' && name === 'write' && pieces > 5, `finish ${finish}; tool ${name}; ${pieces} call pieces, ${args.length} argument characters, closed JSON ${(() => { try { JSON.parse(args); return true; } catch { return false; } })()}; plain content ${JSON.stringify(content.slice(0, 80))}; ids ${ids.size}; usage ${JSON.stringify(usage)}`);
}
if (want('mcp')) {
// Strata's own tool loop: the server runs the MCP tool itself. A call that finishes is run; a call the reply is cut inside is announced and never run.
const ask = async (content, max_tokens) => {
const res = await fetch(`${base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model, stream: true, strata_mcp: true, max_tokens, temperature: 0.6, chat_template_kwargs: { enable_thinking: false }, messages: [{ role: 'user', content }] }) });
const text = await res.text();
const events = text.split(String.fromCharCode(10)).filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
const mcp = events.map(e => e.strata_mcp).filter(Boolean);
const finish = events.flatMap(e => e.choices ?? []).map(c => c.finish_reason).filter(Boolean).at(-1);
const said = events.flatMap(e => e.choices ?? []).map(c => c.delta?.content ?? '').join('');
return { mcp, finish, said };
};
const ok = await ask('Use the fake__echo tool to echo the text "hello from the tool", then tell me what it returned.', 600);
note('mcp: a short call is run by the server and answered', ok.mcp.some(m => m.event === 'result' && m.ok) && ok.finish === 'stop', `events ${JSON.stringify(ok.mcp.map(m => m.event))}; finish ${ok.finish}; said ${JSON.stringify(ok.said.slice(-120))}`);
const cut = await ask('Use the fake__echo tool once. Its text must be the sentence "the quick brown fox jumps over the lazy dog" written out 300 times in a row, separated by spaces.', 300);
note('mcp: a call the reply is cut inside is announced and never run (#211), and the stream says length', cut.mcp.some(m => m.event === 'start') && !cut.mcp.some(m => m.event === 'call') && cut.finish === 'length', `events ${JSON.stringify(cut.mcp.map(m => m.event))}; finish ${cut.finish}; said ${JSON.stringify(cut.said.slice(0, 100))}`);
}
if (want('pagoda')) {
const t = await turn('pagoda', 'Write me a 100 voxel pagoda in one html file with garden.');
const html = t.files.find(f => /\.html?$/i.test(f.name));
console.log(` pagoda: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason, tokensOut: t.done.tokensOut })}, files ${JSON.stringify(t.files)}`);
note('pagoda: a file was written or the turn says why not', !!html || /cut|too long|ran out|limit/i.test(t.text), `file ${html ? html.name + ' ' + html.bytes + ' B' : 'none'}; finish ${t.done?.finishReason}; text tail: ${JSON.stringify(t.text.slice(-240))}`);
}
if (want('long')) for (let rep = 1; rep <= REPEAT; rep++) {
const t = await turn(`long${rep}`, longPrompt, { thinking: 'Off' });
const f = t.files.find(x => x.name === 'big.txt');
const lines = f ? readFileSync(join(t.workspace, 'big.txt'), 'utf8').split(String.fromCharCode(10)).filter(Boolean).length : 0;
console.log(` long${rep}: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason, tokensOut: t.done.tokensOut })}, draft ${JSON.stringify(t.draftStats)}, cut notes ${JSON.stringify(t.cutNotes)}`);
note(`long${rep}: big.txt written (${LINES} lines asked)`, lines >= LINES * 0.95 || t.writesDone > 0 || t.cutNotes.length > 0 && !!f, `file ${f ? f.bytes + ' B, ' + lines + ' lines' : 'none'}; finish ${t.done?.finishReason}; tokens out ${t.done?.tokensOut}; cards ${JSON.stringify(t.cards.filter(c => c.state !== 'running').map(c => c.name))}; writes done ${t.writesDone}; cut notes ${t.cutNotes.length}; draft frames ${t.draftStats.frames}`);
note(`long${rep}: the turn did not end silently`, !!f || t.cutNotes.length > 0 || t.text.trim().length > 0, `text tail ${JSON.stringify(t.text.slice(-160))}`);
}
if (want('cut')) {
// A small window told to the Harness (E2E_CUT_WINDOW, default 24000; the engine's own stays WINDOW) makes the reply budget about 7K tokens (raised to
// 12K by the fix), so the first reply is cut inside the tool call, as the pagoda's was at 19,660.
await bridge.close();
bridge = makeBridge(Number(env('E2E_CUT_WINDOW', '24000')), 'agent-cut');
const t = await turn('cut', longPrompt, { thinking: 'Off' });
const f = t.files.find(x => x.name === 'big.txt');
const lines = f ? readFileSync(join(t.workspace, 'big.txt'), 'utf8').split(String.fromCharCode(10)).filter(Boolean).length : 0;
console.log(` cut: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason })}, files ${JSON.stringify(t.files)}, cut notes ${JSON.stringify(t.cutNotes)}, draft ${JSON.stringify(t.draftStats)}`);
note('cut: the chat said the reply was cut, in words', t.cutNotes.length > 0 && t.cutNotes.every(n => /length limit/.test(n)), JSON.stringify(t.cutNotes));
note('cut: the agent went on and the file exists', !!f, `file ${f ? f.bytes + ' B, ' + lines + ' lines' : 'none'}; finish ${t.done?.finishReason}`);
}
writeFileSync(join(OUT, 'result.json'), JSON.stringify(rows, null, 2));
console.log('\n' + rows.map(r => `${r.ok ? 'PASS' : 'FAIL'} ${r.id}`).join('\n'));
await finish(rows.some(r => !r.ok) ? 1 : 0);
+99 -37
View File
@@ -16,10 +16,15 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const APP = join(ROOT, 'src/styles/app.css');
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. */
const APP_SHEETS = ['app.css', 'liquid.css']
.map((name) => ({ name, css: existsSync(join(STYLE_DIR, name)) ? readFileSync(join(STYLE_DIR, name), 'utf8') : null }))
.filter((sheet) => sheet.css !== null);
const KIT_SCALES = {
radius: new Set(['8px', '14px', '20px', '24px', '9999px', '999px', '50%', '6px', '10px', '12px', '15px', '16px', '2px', '1.5px', 'inherit', '0']),
fontSize: new Set(['11px', '12px', '13px', '14px', '15px', '16px', '17px', '18px', '19px', '22px', '24px', '30px', '32px', '40px', 'inherit']),
@@ -66,9 +71,12 @@ const BLUR_ALLOWED = new Set([
'.menu',
'.telepop',
'.scrim',
".scrim[data-open='true']", // the same scrim, once it is showing
'.dialog-scrim', // the sheet's scrim: it frosts the whole working screen
'.config-savebar', // floating commit controls
'.mobile-bar', // compact navigation controls
'.st-toast--toned',
'.nav', // the navigation layer over the ambient depth layer
]);
/**
@@ -83,6 +91,7 @@ const ELEVATION_ALLOWED = new Set([
'.app[data-nav-open=\'true\'] > .nav', // dismissible navigation sheet
'.st-tab[aria-checked=\'true\']', // selected segmented control
'.nav-new', // primary action affordance
'.nav-new.st-btn--primary', // the same affordance, with its accent recipe
'.st-drawer.sheet',
'.slashmenu',
'.select__pop',
@@ -95,9 +104,12 @@ const ELEVATION_ALLOWED = new Set([
// The jump-to-latest pill sits above a scrolling transcript, so it genuinely
// overlaps moving content — the same test the other entries pass.
'.jumplatest',
'.quote-selection', // the Quote button that floats beside selected words (#4785)
'.st-btn--primary', // the accent fill's own lift
'.st-slider::-webkit-slider-thumb', // a control affordance: the thumb needs to read against its track
'.st-toggle::after', // same: the knob needs to read against its track
'.family-switch button[aria-selected="true"]', // a selected segmented control, like .st-tab
'.native-choice__col .native-resource-choice button[data-selected="true"]', // same
]);
/**
@@ -110,25 +122,61 @@ const CAPSULE_CONTROLS = ['.st-btn', '.st-input', '.st-tab', '.navitem', '.navse
/** Literal colours that are deliberate and documented. */
const DOCUMENTED_LITERALS = ['#05261b', '#12c68e', '#0a7a55'];
/**
* The only selector allowed to paint a gradient: the single ambient depth layer.
*
* The flat-surface rule exists so surfaces read as paper rather than decoration.
* Depth was the one thing it could not express, and the navigation material has
* nothing to sample over a flat fill. A fixed, two-field wash on the page — too
* quiet to read as a gradient at ~1.04:1 — is the smallest thing that makes the
* glass mean anything. It is body-only, it never moves, and both reduced-
* transparency gates remove it. See src/styles/liquid.css §1.
*/
const AMBIENT_SELECTORS = new Set(['body']);
const failures = [];
const notes = [];
const fail = (file, line, message) => failures.push(`${file}:${line} ${message}`);
/* ------------------------------------------------------------ app.css ---- */
/* ------------------------------------------------------------ app sheets -- */
const css = readFileSync(APP, 'utf8');
const lines = css.split('\n');
let selector = '(top level)';
let inKeyframes = false;
/**
* Scan one app stylesheet for the design-system, motion and material rules.
*
* Run over every sheet in the cascade, not only app.css: a rule appended in a
* later file ships just the same, and the "audit palette" block that broke AA on
* the primary button survived precisely because it had never been scanned.
*/
function scanStylesheet(file, css) {
const lines = css.split('\n');
let selector = '(top level)';
let inKeyframes = false;
lines.forEach((raw, i) => {
const line = raw.replace(/\/\*.*?\*\//g, '');
const n = i + 1;
lines.forEach((raw, i) => {
const line = raw.replace(/\/\*.*?\*\//g, '');
const n = i + 1;
const sel = line.match(/^([^@{}\s][^{}]*)\{$/);
// Selectors are usually indented inside a media query or a nested block, so
// the line is trimmed before it is recognised. Without this every indented
// rule was attributed to "(top level)", and the material and elevation
// allowlists — which match on the selector — rejected most of the file.
const sel = line.trim().match(/^([^@{}\s][^{}]*)\{$/);
if (sel) selector = sel[1].trim();
if (/@keyframes/.test(line)) inKeyframes = true;
if (inKeyframes && /^\}/.test(line)) inKeyframes = false;
// A whole rule on one line (`a, b { … }`) never ends with `{`, so it used to
// be attributed to whatever rule ran before it — which is why a shadow on a
// segmented control was reported against `.menu .response-stats`. The rule's
// own selector is used for its declarations.
let selectorForLine = selector;
if (!sel && !inKeyframes) {
const inline = line.trim().match(/^([^@{}][^{}]*?)\s*\{/);
if (inline && /\}/.test(line)) selectorForLine = inline[1].trim();
}
// A one-line `@keyframes name { … }` opens and closes on the same line, so it
// must not switch the tracker on: it never sees a closing brace at column 0,
// and every rule after it was then treated as part of an animation — which
// disabled the one-line selector attribution above.
if (/@keyframes/.test(line) && !/\}/.test(line)) inKeyframes = true;
if (inKeyframes && /^\}/.test(raw)) inKeyframes = false;
for (const [, prop, rawValue] of line.matchAll(/([a-z-]+)\s*:\s*([^;]+);/g)) {
const value = rawValue.trim();
@@ -137,24 +185,24 @@ lines.forEach((raw, i) => {
if (prop === 'border-radius') {
for (const part of value.split(/\s+/)) {
if (!KIT_SCALES.radius.has(part) && !part.startsWith('var(') && !part.includes('%')) {
fail('app.css', n, `off-scale radius "${part}" in ${selector}`);
fail(file, n, `off-scale radius "${part}" in ${selectorForLine}`);
}
}
}
if (prop === 'font-size' && !value.startsWith('var(') && !value.includes('clamp') && !KIT_SCALES.fontSize.has(value)) {
fail('app.css', n, `off-scale font-size "${value}" in ${selector}`);
fail(file, n, `off-scale font-size "${value}" in ${selectorForLine}`);
}
if (prop === 'box-shadow' && !KIT_SCALES.shadow.some((re) => re.test(value))) {
fail('app.css', n, `shadow is not a kit token: "${value}" in ${selector}`);
fail(file, n, `shadow is not a kit token: "${value}" in ${selectorForLine}`);
}
if (/gradient|radial|conic/.test(value) && prop.startsWith('background')) {
fail('app.css', n, `gradient in ${selector} — the system is flat`);
if (/gradient|radial|conic/.test(value) && prop.startsWith('background') && !AMBIENT_SELECTORS.has(selector)) {
fail(file, n, `gradient in ${selectorForLine} — the system is flat`);
}
if (/^(color|background|background-color|border-color)$/.test(prop)) {
const literals = value.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g);
if (literals && !value.includes('data:image') && !value.includes('currentColor')
&& !DOCUMENTED_LITERALS.some((d) => value.includes(d))) {
fail('app.css', n, `literal colour "${value}" in ${selector} — use a token`);
fail(file, n, `literal colour "${value}" in ${selectorForLine} — use a token`);
}
}
@@ -163,57 +211,70 @@ lines.forEach((raw, i) => {
// Values must be a --st-material-* token: that is what collapses the blur
// under reduced transparency, so gating cannot be forgotten.
if (prop === 'backdrop-filter' || prop === '-webkit-backdrop-filter') {
if (!/^var\(--st-material-(thin|regular|thick)\)$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
fail('app.css', n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
if (!/^var\(--st-material-(thin|regular|thick)\)( !important)?$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
fail(file, n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
}
if (!BLUR_ALLOWED.has(selector)) {
fail('app.css', n, `material on "${selector}", which is not an approved surface — it does not overlay moving content`);
if (!BLUR_ALLOWED.has(selectorForLine)) {
fail(file, n, `material on "${selectorForLine}", which is not an approved surface — it does not overlay moving content`);
}
}
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selector)) {
fail('app.css', n, `elevation shadow on "${selector}" — shadows are reserved for surfaces that overlap content`);
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selectorForLine)) {
fail(file, n, `elevation shadow on "${selectorForLine}" — shadows are reserved for surfaces that overlap content`);
}
if (prop === 'transition') {
if (/^\d/.test(value) || /(^|[\s,])\d+m?s\b/.test(value)) {
fail('app.css', n, `raw duration in transition — use a --st-t-* token: "${value}"`);
fail(file, n, `raw duration in transition — use a --st-t-* token: "${value}"`);
}
for (const part of value.split(',')) {
const prop0 = part.trim().split(/\s+/)[0];
if (LAYOUT_PROPS.includes(prop0)) {
fail('app.css', n, `transition on layout property "${prop0}" in ${selector}`);
fail(file, n, `transition on layout property "${prop0}" in ${selectorForLine}`);
}
}
}
if (prop === 'transition-property') {
for (const p of value.split(',').map((v) => v.trim())) {
if (LAYOUT_PROPS.includes(p)) fail('app.css', n, `transition on layout property "${p}" in ${selector}`);
if (LAYOUT_PROPS.includes(p)) fail(file, n, `transition on layout property "${p}" in ${selectorForLine}`);
}
}
if (prop === 'animation' || prop === 'animation-name') {
const name = value.match(/^(st-[a-z-]+)/)?.[1];
const infinite = /infinite/.test(value);
if (infinite && name && !INFINITE_OK.has(name)) {
fail('app.css', n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
fail(file, n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
}
const duration = value.match(/\b(\d+(?:\.\d+)?m?s)\b/)?.[1];
if (duration && !LOOP_DURATION_OK.has(duration)) {
fail('app.css', n, `raw animation duration "${duration}" in ${selector} — use a --st-t-* token`);
fail(file, n, `raw animation duration "${duration}" in ${selectorForLine} — use a --st-t-* token`);
}
}
if (prop === 'animation-duration' && /^\d/.test(value) && !LOOP_DURATION_OK.has(value)) {
fail('app.css', n, `raw animation-duration "${value}" in ${selector}`);
fail(file, n, `raw animation-duration "${value}" in ${selectorForLine}`);
}
}
});
}
});
}
for (const sheet of APP_SHEETS) scanStylesheet(sheet.name, sheet.css);
const css = APP_SHEETS.find((sheet) => sheet.name === 'app.css')?.css ?? '';
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);
if (!existsSync(a) || !existsSync(b)) {
fail('kit', 0, `${file} missing on one side`);
// 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.
@@ -223,10 +284,11 @@ for (const file of ['tokens.css', 'components.css']) {
}
}
// Which selectors have been given the capsule radius.
// Which selectors have been given the capsule radius. Checked across the whole
// cascade: a control is a capsule wherever the rule that says so lives.
const capsuled = new Set();
{
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rules = allAppCss.replace(/\/\*[\s\S]*?\*\//g, '');
for (const [, sel, body] of rules.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!/border-radius:\s*var\(--st-r-pill\)/.test(body)) continue;
for (const part of sel.split(',')) capsuled.add(part.trim());
@@ -263,7 +325,7 @@ const referenced = new Set();
for (const file of [...readdirSync(join(ROOT, 'src'), { recursive: true })].filter((f) => /\.tsx$/.test(String(f)))) {
const full = join(ROOT, 'src', String(file));
if (!statSync(full).isFile()) continue;
for (const m of readFileSync(full, 'utf8').matchAll(/name="([a-z-]+)"/g)) referenced.add(m[1]);
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`);
+54
View File
@@ -0,0 +1,54 @@
/**
* Where the Chromium binary the probes drive actually lives.
*
* The probes launched a fixed macOS cache path, so on Windows and Linux they
* printed "No cached Chromium found" and every runtime gate (contrast, measure,
* luminance, motion) was skipped rather than run. Resolution is now a single
* place with the same order everywhere:
*
* 1. STRATA_CHROME — an explicit override, for a CI image or a second Chrome
* 2. the cached Playwright build (macOS, the original platforms)
* 3. an installed browser: Chrome, Edge, Chromium
*
* `requireChromium` exits with the harness message the scripts already used, so
* a machine with no browser at all still fails loudly instead of silently
* passing a gate.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const PLAYWRIGHT_MAC = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
];
const INSTALLED = [
// Windows
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
// Linux
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
];
export function findChromium() {
const candidates = [process.env.STRATA_CHROME, ...PLAYWRIGHT_MAC, ...INSTALLED].filter(Boolean);
return candidates.find((path) => existsSync(path)) ?? null;
}
export function requireChromium() {
const path = findChromium();
if (!path) {
console.error('No Chromium found. Set STRATA_CHROME, or install Chrome, Edge or Chromium.');
process.exit(2);
}
return path;
}
+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;
}
+2 -4
View File
@@ -19,13 +19,11 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
+10 -9
View File
@@ -7,11 +7,9 @@ import { chromium } from 'playwright-core';
import { existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const flow = process.argv[2] ?? 'chat';
@@ -1237,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);
@@ -1354,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'));
@@ -1372,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'));
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* LOAD-PICS end to end: the Models page of a running host, driven in a real Chrome (DevTools protocol, no packages), pressing "Load model" on a model that is
* downloaded, with Read pictures on and the picture files not on the computer.
*
* HOST_URL=http://127.0.0.1:18700 CHROME=/usr/bin/google-chrome OUT=/tmp/lp-out node scripts/load-pictures-e2e.mjs
*
* It looks at the page, presses the button, and writes down once per half second what the page shows (the progress card's words and bar, any dialog, the job's kind
* and state), until the job ends or LIMIT_S passes. It writes <OUT>/timeline.json and screenshots; the files on disk are checked by the shell script around it.
*
* Environment: HOST_URL (required), CHROME (default /usr/bin/google-chrome), OUT (default ./lp-out), LIMIT_S (default 900), CDP_PORT (default 9333),
* MODEL (the version's name on the page, default "High quality"), BUTTON (default "Load model").
*/
import { spawn } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const HOST = process.env.HOST_URL;
if (!HOST) { console.error('HOST_URL is required'); process.exit(2); }
const CHROME = process.env.CHROME ?? '/usr/bin/google-chrome';
const OUT = process.env.OUT ?? './lp-out';
const LIMIT = Number(process.env.LIMIT_S ?? 900) * 1000;
const PORT = Number(process.env.CDP_PORT ?? 9333);
const BUTTON = process.env.BUTTON ?? 'Load model';
mkdirSync(OUT, { recursive: true });
const sleep = ms => new Promise(r => setTimeout(r, ms));
const profile = mkdtempSync(join(tmpdir(), 'lp-chrome-'));
const chrome = spawn(CHROME, ['--headless=new', '--no-sandbox', '--disable-gpu', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, '--window-size=1400,1100', 'about:blank'], { stdio: 'ignore' });
const finish = code => { try { chrome.kill('SIGTERM'); } catch { /* gone */ } setTimeout(() => { try { rmSync(profile, { recursive: true, force: true }); } catch { /* best effort */ } process.exit(code); }, 800); };
process.on('SIGINT', () => finish(130)); process.on('SIGTERM', () => finish(143));
async function target() {
for (let i = 0; i < 80; i++) {
try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); const page = list.find(t => t.type === 'page'); if (page) return page.webSocketDebuggerUrl; } catch { /* not up yet */ }
await sleep(250);
}
throw new Error('Chrome did not start');
}
const ws = new WebSocket(await target());
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('DevTools socket')); });
let seq = 0; const waiting = new Map();
ws.onmessage = event => { const message = JSON.parse(event.data); const w = waiting.get(message.id); if (w) { waiting.delete(message.id); message.error ? w.reject(new Error(message.error.message)) : w.resolve(message.result); } };
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; waiting.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const run = async expression => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + (r.exceptionDetails.exception?.description ?? '')); return r.result.value; };
const shot = async name => { const r = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(OUT, `${name}.png`), Buffer.from(r.data, 'base64')); };
try {
await send('Page.enable');
await send('Page.navigate', { url: `${HOST}/#/models` });
// the page is up when the Load button is on it and enabled
const ready = `(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(BUTTON)}); return !!b && !b.disabled; })()`;
let up = false;
for (let i = 0; i < 240 && !up; i++) { up = await run(ready).catch(() => false); if (!up) await sleep(500); }
if (!up) { await shot('never-ready'); throw new Error(`The "${BUTTON}" button never came up`); }
const before = await run(`(() => { const t = document.body.innerText; const box = document.querySelector('.native-image-reading input[type=checkbox]'); return { readPicturesOn: !!box && box.checked, offersFiles: t.includes('Picture reading needs a few more files'), downloadButton: [...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'Download picture-reading files'), modelFiles: document.querySelector('.native-path')?.textContent ?? null, shownModel: document.querySelector('.native-version')?.innerText?.slice(0, 200) ?? null }; })()`);
await shot('1-before');
const baseDialogs = await run("document.querySelectorAll('[role=dialog], dialog[open]').length"); // dialogs the page always has (closed ones the app keeps mounted)
const clicked = await run(`(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(BUTTON)}); b.click(); return true; })()`);
const started = Date.now();
const timeline = []; let last = '', shots = 0, ended = null;
while (Date.now() - started < LIMIT) {
const seen = await run(`(async () => {
const card = document.querySelector('.native-progress');
const state = await (await fetch('/api/native')).json();
const job = [...(state.jobs ?? [])].reverse()[0] ?? null;
return { card: card ? card.innerText.replace(/\\s+/g, ' ').trim() : null, dialogs: document.querySelectorAll('[role=dialog], dialog[open]').length - ${baseDialogs},
job: job && { startedAt: job.startedAt, kind: job.kind, state: job.state, summary: job.summary, error: job.error, details: job.details ?? null, bar: job.result?.install?.download ?? null },
instance: (state.instances ?? []).map(i => ({ status: i.status, vision: i.vision, note: i.picturesNote ?? null, gpus: i.gpus })),
note: [...document.querySelectorAll('.native-spill-note')].map(n => n.innerText.trim()), error: [...document.querySelectorAll('.inline-error, [role=alert]')].map(n => n.innerText.trim()).filter(Boolean) };
})()`);
const key = JSON.stringify([seen.card, seen.dialogs, seen.job?.kind, seen.job?.state, seen.job?.summary, seen.instance, seen.note, seen.error, Math.floor((seen.job?.bar?.percent ?? 0) / 25)]);
if (key !== last) { last = key; timeline.push({ t: +((Date.now() - started) / 1000).toFixed(1), ...seen }); if (seen.card && shots < 2 && seen.job?.state === 'running') await shot(`2-running-${++shots}`); }
if (seen.job && Date.parse(seen.job.startedAt) >= started - 2000 && seen.job.state !== 'running') { ended = seen.job.state; break; }
await sleep(500);
}
await sleep(1500);
await shot('3-after');
const after = await run(`(() => ({ text: [...document.querySelectorAll('.native-spill-note, .native-ready, .toast, [role=status]')].map(n => n.innerText.trim()).filter(Boolean), buttons: [...document.querySelectorAll('.native-model-actions button')].map(b => b.textContent.trim()), pictureOffer: document.body.innerText.includes('Picture reading needs a few more files') }))()`);
const posted = await run("fetch('/__fixture/posted').then(r => r.ok ? r.json() : null).catch(() => null)"); // only the UI-preview fixture keeps what the page posted
writeFileSync(join(OUT, 'timeline.json'), JSON.stringify({ clicked, posted, before, ended, seconds: +((Date.now() - started) / 1000).toFixed(1), timeline, after }, null, 1));
console.log(JSON.stringify({ ended, seconds: +((Date.now() - started) / 1000).toFixed(1), steps: timeline.length, before, after }));
finish(ended === 'done' ? 0 : 1);
} catch (error) {
console.error(error.message);
writeFileSync(join(OUT, 'error.txt'), String(error.stack ?? error));
finish(2);
}
+2 -4
View File
@@ -25,6 +25,7 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
/** Anything at or above this projected area is "large". Roughly a 64×64 block. */
@@ -43,10 +44,7 @@ const MIN_THICKNESS = 32;
/** Ceiling for the relative luminance of a large surface, in dark mode. */
const CEILING = 0.15;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found.');
@@ -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")
+242
View File
@@ -0,0 +1,242 @@
"""Writes src/locales/df2-<lang>.json: the strings of the Drafting choice that came with the DFlash2 option (branch f-0291-df2-option): the plain-words
paragraph under the dropdown, the three lines about the person's graphics card, the "not on this card" and "no internet" lines, and the SSD line of the
This computer panel.
Run from apps/host: python scripts/make-df2-locales.py
Every entry names all nine languages; the keys are df01.. in the order of S. df2-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 pick-, tune-, tools-, fix-, gh-, ui- and engine- exist).
The English lines of df03-df07 and df09 are also written in strata_control/drafting.py and strata_control/drafting_fetch.py; tests/drafting.test.mjs keeps the two the same."""
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("MTP is the default. DFlash2 is another way to guess the next words ahead, for Qwen3.8-Flash-Next only. On an RTX PRO 5000 it was 7.8% faster than MTP "
"on 19 short prompts and 1.1% faster on long thinking, and faster on 21 of 23 prompts. On the short prompts the answers were identical.",
"默认使用 MTP。DFlash2 是另一种提前猜测后续词语的方式,仅适用于 Qwen3.8-Flash-Next。在 RTX PRO 5000 上,它在 19 个短提示上比 MTP 快 7.8%,在长篇思考上快 1.1%,"
"并且在 23 个提示中有 21 个更快。在短提示上,回答完全相同。",
"既定では MTP を使います。DFlash2 は次の語を先読みするもう一つの方法で、Qwen3.8-Flash-Next 専用です。RTX PRO 5000 では、短いプロンプト 19 件で MTP より 7.8% 速く、"
"長い思考では 1.1% 速く、23 件中 21 件で速くなりました。短いプロンプトでは回答が同一でした。",
"MTP ist die Voreinstellung. DFlash2 ist eine andere Art, die nächsten Wörter vorauszuraten, nur für Qwen3.8-Flash-Next. Auf einer RTX PRO 5000 war es bei 19 kurzen "
"Prompts 7,8 % schneller als MTP, bei langem Nachdenken 1,1 % schneller und bei 21 von 23 Prompts schneller. Bei den kurzen Prompts waren die Antworten identisch.",
"MTP es la opción predeterminada. DFlash2 es otra forma de adivinar las siguientes palabras por adelantado, solo para Qwen3.8-Flash-Next. En una RTX PRO 5000 fue un 7,8 % "
"más rápido que MTP en 19 prompts cortos y un 1,1 % más rápido en razonamientos largos, y más rápido en 21 de 23 prompts. En los prompts cortos las respuestas fueron idénticas.",
"По умолчанию используется MTP. DFlash2 — другой способ угадывать следующие слова наперёд, только для Qwen3.8-Flash-Next. На RTX PRO 5000 он был на 7,8 % быстрее MTP "
"на 19 коротких запросах и на 1,1 % быстрее на длинных рассуждениях, а на 21 из 23 запросов — быстрее. На коротких запросах ответы были идентичны.",
"MTP é o padrão. DFlash2 é outra forma de adivinhar as próximas palavras antecipadamente, só para o Qwen3.8-Flash-Next. Numa RTX PRO 5000 foi 7,8 % mais rápido que o MTP "
"em 19 prompts curtos e 1,1 % mais rápido em raciocínios longos, e mais rápido em 21 de 23 prompts. Nos prompts curtos as respostas foram idênticas.",
"기본값은 MTP입니다. DFlash2는 다음 단어를 미리 추측하는 또 다른 방식이며 Qwen3.8-Flash-Next 전용입니다. RTX PRO 5000에서 짧은 프롬프트 19개에서는 MTP보다 7.8% 빨랐고, "
"긴 사고 과정에서는 1.1% 빨랐으며, 23개 중 21개 프롬프트에서 더 빨랐습니다. 짧은 프롬프트에서는 답변이 동일했습니다.",
"MTP est le réglage par défaut. DFlash2 est une autre façon de deviner à l'avance les mots suivants, uniquement pour Qwen3.8-Flash-Next. Sur une RTX PRO 5000, il a été 7,8 % "
"plus rapide que MTP sur 19 prompts courts et 1,1 % plus rapide en réflexion longue, et plus rapide sur 21 prompts sur 23. Sur les prompts courts, les réponses étaient identiques.")
add("It downloads about 1.2 GB once, works with one graphics card and one request at a time, and uses MTP otherwise.",
"它会一次性下载约 1.2 GB,仅在使用一张显卡且一次处理一个请求时有效,其他情况下使用 MTP。",
"約 1.2 GB を一度だけダウンロードします。グラフィックスカード 1 枚で一度に 1 件のリクエストを処理するときに動作し、それ以外では MTP を使います。",
"Es lädt einmalig etwa 1,2 GB herunter, funktioniert mit einer Grafikkarte und einer Anfrage zur Zeit und nutzt sonst MTP.",
"Descarga unos 1,2 GB una sola vez, funciona con una tarjeta gráfica y una solicitud a la vez, y en los demás casos usa MTP.",
"Один раз скачивает около 1,2 ГБ, работает с одной видеокартой и одним запросом за раз, в остальных случаях используется MTP.",
"Baixa cerca de 1,2 GB uma única vez, funciona com uma placa de vídeo e um pedido de cada vez, e nos outros casos usa o MTP.",
"약 1.2 GB를 한 번만 내려받으며, 그래픽 카드 한 장과 한 번에 하나의 요청에서 작동하고, 그 외에는 MTP를 사용합니다.",
"Il télécharge environ 1,2 Go une seule fois, fonctionne avec une carte graphique et une requête à la fois, et utilise MTP sinon.")
add("On graphics cards like yours, DFlash2 was faster than MTP on some kinds of text and slower on others in our tests.",
"在与您类似的显卡上,我们的测试中 DFlash2 在某些类型的文本上比 MTP 快,在另一些上更慢。",
"お使いのようなグラフィックスカードでは、私たちのテストで DFlash2 は MTP より速い種類の文章と遅い種類の文章がありました。",
"Auf Grafikkarten wie Ihrer war DFlash2 in unseren Tests bei manchen Textarten schneller als MTP und bei anderen langsamer.",
"En tarjetas gráficas como la suya, en nuestras pruebas DFlash2 fue más rápido que MTP con algunos tipos de texto y más lento con otros.",
"На видеокартах, подобных вашей, в наших тестах DFlash2 был быстрее MTP на одних видах текста и медленнее на других.",
"Em placas de vídeo como a sua, nos nossos testes o DFlash2 foi mais rápido que o MTP em alguns tipos de texto e mais lento em outros.",
"고객님과 비슷한 그래픽 카드에서 저희 테스트 결과 DFlash2는 일부 유형의 글에서는 MTP보다 빨랐고 다른 유형에서는 더 느렸습니다.",
"Sur des cartes graphiques comme la vôtre, DFlash2 a été plus rapide que MTP sur certains types de texte et plus lent sur d'autres lors de nos tests.")
add("On graphics cards like yours, DFlash2 was slower than MTP in our tests.",
"在与您类似的显卡上,我们的测试中 DFlash2 比 MTP 更慢。",
"お使いのようなグラフィックスカードでは、私たちのテストで DFlash2 は MTP より遅くなりました。",
"Auf Grafikkarten wie Ihrer war DFlash2 in unseren Tests langsamer als MTP.",
"En tarjetas gráficas como la suya, en nuestras pruebas DFlash2 fue más lento que MTP.",
"На видеокартах, подобных вашей, в наших тестах DFlash2 был медленнее MTP.",
"Em placas de vídeo como a sua, nos nossos testes o DFlash2 foi mais lento que o MTP.",
"고객님과 비슷한 그래픽 카드에서 저희 테스트 결과 DFlash2는 MTP보다 느렸습니다.",
"Sur des cartes graphiques comme la vôtre, DFlash2 a été plus lent que MTP lors de nos tests.")
add("DFlash2 has not been tested on your graphics card yet.",
"DFlash2 尚未在您的显卡上测试过。",
"DFlash2 はまだお使いのグラフィックスカードでテストされていません。",
"DFlash2 wurde auf Ihrer Grafikkarte noch nicht getestet.",
"DFlash2 todavía no se ha probado en su tarjeta gráfica.",
"DFlash2 ещё не проверялся на вашей видеокарте.",
"O DFlash2 ainda não foi testado na sua placa de vídeo.",
"DFlash2는 아직 고객님의 그래픽 카드에서 테스트되지 않았습니다.",
"DFlash2 n'a pas encore été testé sur votre carte graphique.")
add("Drafting: MTP (DFlash2 isn't available on this graphics card)",
"预测方式:MTP(DFlash2 不适用于这张显卡)",
"先読み:MTP(DFlash2 はこのグラフィックスカードでは使えません)",
"Vorausraten: MTP (DFlash2 ist auf dieser Grafikkarte nicht verfügbar)",
"Predicción: MTP (DFlash2 no está disponible en esta tarjeta gráfica)",
"Упреждающая генерация: MTP (DFlash2 недоступен на этой видеокарте)",
"Previsão: MTP (o DFlash2 não está disponível nesta placa de vídeo)",
"미리 추측: MTP (이 그래픽 카드에서는 DFlash2를 사용할 수 없습니다)",
"Anticipation : MTP (DFlash2 n'est pas disponible sur cette carte graphique)")
add("There is no internet connection, so the DFlash2 drafter can't be downloaded now.",
"没有网络连接,因此现在无法下载 DFlash2 草稿模型。",
"インターネットに接続されていないため、今は DFlash2 のドラフターをダウンロードできません。",
"Es besteht keine Internetverbindung, deshalb kann der DFlash2-Drafter jetzt nicht heruntergeladen werden.",
"No hay conexión a internet, así que ahora no se puede descargar el borrador de DFlash2.",
"Нет подключения к интернету, поэтому сейчас нельзя скачать черновую модель DFlash2.",
"Não há ligação à internet, por isso não é possível transferir agora o rascunhador do DFlash2.",
"인터넷에 연결되어 있지 않아 지금은 DFlash2 드래프터를 내려받을 수 없습니다.",
"Il n'y a pas de connexion internet, le drafter DFlash2 ne peut donc pas être téléchargé pour le moment.")
add("SSD: not needed",
"SSD:不需要", "SSD:不要", "SSD: nicht nötig", "SSD: no hace falta", "SSD: не нужен", "SSD: não é necessário", "SSD: 필요 없음", "SSD : pas nécessaire")
add("DFlash2 answers one request at a time, so Strata set this model to one request at a time.",
"DFlash2 一次只处理一个请求,因此 Strata 将此模型设置为一次一个请求。",
"DFlash2 は一度に 1 件のリクエストしか処理しないため、Strata はこのモデルを一度に 1 件に設定しました。",
"DFlash2 beantwortet eine Anfrage nach der anderen, deshalb hat Strata dieses Modell auf eine Anfrage zur Zeit gestellt.",
"DFlash2 responde una solicitud a la vez, así que Strata configuró este modelo para una solicitud a la vez.",
"DFlash2 отвечает на один запрос за раз, поэтому Strata настроила эту модель на один запрос за раз.",
"O DFlash2 responde a um pedido de cada vez, por isso o Strata configurou este modelo para um pedido de cada vez.",
"DFlash2는 한 번에 하나의 요청만 처리하므로 Strata가 이 모델을 한 번에 하나의 요청으로 설정했습니다.",
"DFlash2 répond à une requête à la fois, Strata a donc réglé ce modèle sur une requête à la fois.")
add("Drafting: off (no drafter is downloaded yet)",
"预测草稿:关闭(尚未下载任何草稿模型)",
"先読み:オフ(ドラフトモデルがまだダウンロードされていません)",
"Vorausraten: aus (noch kein Entwurfsbaustein heruntergeladen)",
"Predicción: desactivada (aún no hay ningún predictor descargado)",
"Предсказание: выключено (черновая модель ещё не скачана)",
"Previsão: desativada (nenhum preditor foi baixado ainda)",
"초안 예측: 꺼짐(초안 모델이 아직 내려받아지지 않았습니다)",
"Anticipation : désactivée (aucun module d'anticipation n'est encore téléchargé)")
add("Drafting: DFlash2 (the MTP drafter isn't downloaded yet)",
"预测草稿:DFlash2(MTP 草稿模型尚未下载)",
"先読み:DFlash2(MTP のドラフトモデルはまだダウンロードされていません)",
"Vorausraten: DFlash2 (der MTP-Entwurfsbaustein ist noch nicht heruntergeladen)",
"Predicción: DFlash2 (el predictor de MTP aún no está descargado)",
"Предсказание: DFlash2 (черновая модель MTP ещё не скачана)",
"Previsão: DFlash2 (o preditor do MTP ainda não foi baixado)",
"초안 예측: DFlash2(MTP 초안 모델이 아직 내려받아지지 않았습니다)",
"Anticipation : DFlash2 (le module d'anticipation MTP n'est pas encore téléchargé)")
add("Choosing DFlash2 downloads its drafter: about {0} GB, once.",
"选择 DFlash2 会下载它的草稿模型:约 {0} GB,只下载一次。",
"DFlash2 を選ぶと、そのドラフトモデルを一度だけダウンロードします(約 {0} GB)。",
"Wenn Sie DFlash2 wählen, wird sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig.",
"Elegir DFlash2 descarga su predictor: unos {0} GB, una sola vez.",
"Если выбрать DFlash2, будет скачана его черновая модель: около {0} ГБ, один раз.",
"Escolher o DFlash2 baixa o preditor dele: cerca de {0} GB, uma vez.",
"DFlash2를 고르면 그 초안 모델을 한 번 내려받습니다(약 {0}GB).",
"Choisir DFlash2 télécharge son module d'anticipation : environ {0} Go, une seule fois.")
add("Choosing MTP downloads its drafter: about {0} GB, once.",
"选择 MTP 会下载它的草稿模型:约 {0} GB,只下载一次。",
"MTP を選ぶと、そのドラフトモデルを一度だけダウンロードします(約 {0} GB)。",
"Wenn Sie MTP wählen, wird sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig.",
"Elegir MTP descarga su predictor: unos {0} GB, una sola vez.",
"Если выбрать MTP, будет скачана его черновая модель: около {0} ГБ, один раз.",
"Escolher o MTP baixa o preditor dele: cerca de {0} GB, uma vez.",
"MTP를 고르면 그 초안 모델을 한 번 내려받습니다(약 {0}GB).",
"Choisir MTP télécharge son module d'anticipation : environ {0} Go, une seule fois.")
add("The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…",
"无法下载 DFlash2 草稿模型,因此模型将不使用草稿模型启动。正在加载模型…",
"DFlash2 のドラフトモデルをダウンロードできなかったため、ドラフトモデルなしで起動します。モデルを読み込んでいます…",
"Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell ohne Entwurfsbaustein. Das Modell wird geladen …",
"No se pudo descargar el predictor de DFlash2, así que el modelo arranca sin predictor. Cargando el modelo…",
"Не удалось скачать черновую модель DFlash2, поэтому модель запускается без черновой модели. Загружается модель…",
"Não foi possível baixar o preditor do DFlash2, então o modelo inicia sem preditor. Carregando o modelo…",
"DFlash2 초안 모델을 내려받지 못해 초안 모델 없이 시작합니다. 모델을 불러오는 중…",
"Le module d'anticipation DFlash2 n'a pas pu être téléchargé, le modèle démarre donc sans module d'anticipation. Chargement du modèle…")
add("Downloading the MTP drafter…",
"正在下载 MTP 草稿模型…",
"MTP のドラフトモデルをダウンロードしています…",
"MTP-Entwurfsbaustein wird heruntergeladen …",
"Descargando el predictor de MTP…",
"Скачивается черновая модель MTP…",
"Baixando o preditor do MTP…",
"MTP 초안 모델을 내려받는 중…",
"Téléchargement du module d'anticipation MTP…")
add("The MTP drafter could not be downloaded.",
"无法下载 MTP 草稿模型。",
"MTP のドラフトモデルをダウンロードできませんでした。",
"Der MTP-Entwurfsbaustein konnte nicht heruntergeladen werden.",
"No se pudo descargar el predictor de MTP.",
"Не удалось скачать черновую модель MTP.",
"Não foi possível baixar o preditor do MTP.",
"MTP 초안 모델을 내려받지 못했습니다.",
"Le module d'anticipation MTP n'a pas pu être téléchargé.")
add("Drafting: MTP (DFlash2 didn't start)",
"预测草稿:MTP(DFlash2 未能启动)",
"先読み:MTP(DFlash2 は起動しませんでした)",
"Vorausraten: MTP (DFlash2 ist nicht gestartet)",
"Predicción: MTP (DFlash2 no arrancó)",
"Предсказание: MTP (DFlash2 не запустился)",
"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 = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", 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"df{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"df2-{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")
+226
View File
@@ -0,0 +1,226 @@
"""Writes src/locales/engine-<lang>.json: the strings of Settings > Engine (the list of installed engines: switch, delete) and the words that now point to
About for updating (branch f-0291-engine-tab). Run from apps/host: python scripts/make-engine-locales.py
Every entry names all nine languages; the keys are et01.. in the order of S. engine-en.json is the English source. Placeholders ({0}, {1}) must be kept in
every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix-, gh- and ui- 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))
# ---- the page
add("Installed engines",
"已安装的引擎", "インストール済みのエンジン", "Installierte Engines", "Motores instalados", "Установленные движки", "Motores instalados", "설치된 엔진", "Moteurs installés")
add("Switch to an older engine to test something, then switch back to the newest. Updating to the latest is in About.",
"切换到较旧的引擎来测试某些内容,然后再切换回最新的。更新到最新版本请前往“关于”。",
"古いエンジンに切り替えて試し、終わったら最新のエンジンに戻せます。最新版への更新は「情報」で行います。",
"Wechsle zu einer älteren Engine, um etwas zu testen, und danach zurück zur neuesten. Das Update auf die neueste Version findest du unter „Über“.",
"Cambia a un motor anterior para probar algo y vuelve después al más reciente. La actualización a la última versión está en «Acerca de».",
"Переключитесь на более старый движок, чтобы что-то проверить, а потом вернитесь на самый новый. Обновление до последней версии — в разделе «О программе».",
"Troque para um motor mais antigo para testar algo e depois volte ao mais recente. A atualização para a versão mais recente fica em «Sobre».",
"이전 엔진으로 바꿔 테스트한 뒤 다시 최신 엔진으로 돌아올 수 있습니다. 최신 버전으로의 업데이트는 '정보'에서 합니다.",
"Passez à un moteur plus ancien pour tester quelque chose, puis revenez au plus récent. La mise à jour vers la dernière version se trouve dans « À propos ».")
add("Engines can be switched and deleted in the installed Strata app.",
"可以在已安装的 Strata 应用中切换和删除引擎。", "エンジンの切り替えと削除は、インストール済みの Strata アプリで行えます。",
"Engines lassen sich in der installierten Strata-App wechseln und löschen.", "Los motores se pueden cambiar y eliminar en la aplicación Strata instalada.",
"Переключать и удалять движки можно в установленном приложении Strata.", "Os motores podem ser trocados e excluídos no aplicativo Strata instalado.",
"엔진 전환과 삭제는 설치된 Strata 앱에서 할 수 있습니다.", "Les moteurs peuvent être changés et supprimés dans l'application Strata installée.")
add("The installed engines couldn't be read. Try again in a moment.",
"无法读取已安装的引擎。请稍后重试。", "インストール済みのエンジンを読み取れませんでした。しばらくしてからもう一度お試しください。",
"Die installierten Engines konnten nicht gelesen werden. Versuche es gleich noch einmal.", "No se pudieron leer los motores instalados. Inténtalo de nuevo en un momento.",
"Не удалось прочитать список установленных движков. Попробуйте ещё раз через минуту.", "Não foi possível ler os motores instalados. Tente novamente em instantes.",
"설치된 엔진을 읽지 못했습니다. 잠시 후 다시 시도하세요.", "Les moteurs installés n'ont pas pu être lus. Réessayez dans un instant.")
add("No engine is installed on this computer yet. Update in About downloads the right one.",
"此电脑上还没有安装引擎。在“关于”中点击更新即可下载合适的引擎。", "このコンピューターにはまだエンジンがありません。「情報」で更新すると、合ったものがダウンロードされます。",
"Auf diesem Computer ist noch keine Engine installiert. Das Update unter „Über“ lädt die passende herunter.",
"Todavía no hay ningún motor instalado en este equipo. Actualizar en «Acerca de» descarga el adecuado.",
"На этом компьютере ещё не установлен движок. Обновление в разделе «О программе» скачает подходящий.",
"Ainda não há nenhum motor instalado neste computador. Atualizar em «Sobre» baixa o adequado.",
"이 컴퓨터에는 아직 설치된 엔진이 없습니다. '정보'에서 업데이트하면 알맞은 엔진을 내려받습니다.",
"Aucun moteur n'est encore installé sur cet ordinateur. La mise à jour dans « À propos » télécharge le bon.")
add("Engines take {0} on this computer.",
"引擎在此电脑上共占用 {0}。", "エンジンはこのコンピューターで {0} を使っています。", "Die Engines belegen auf diesem Computer {0}.", "Los motores ocupan {0} en este equipo.",
"Движки занимают на этом компьютере {0}.", "Os motores ocupam {0} neste computador.", "엔진이 이 컴퓨터에서 {0}을(를) 차지합니다.", "Les moteurs occupent {0} sur cet ordinateur.")
add("Newest", "最新", "最新", "Neueste", "Más reciente", "Новейший", "Mais recente", "최신", "Le plus récent")
add("Use this", "使用此引擎", "これを使う", "Diese verwenden", "Usar este", "Использовать", "Usar este", "이 엔진 사용", "Utiliser celui-ci")
add("Qwen and other models",
"Qwen 及其他模型", "Qwen とその他のモデル", "Qwen und andere Modelle", "Qwen y otros modelos", "Qwen и другие модели", "Qwen e outros modelos", "Qwen 및 기타 모델", "Qwen et autres modèles")
add("A running program is using this engine. Unload your models first.",
"有程序正在使用此引擎。请先卸载你的模型。", "実行中のプログラムがこのエンジンを使っています。先にモデルをアンロードしてください。",
"Ein laufendes Programm benutzt diese Engine. Entlade zuerst deine Modelle.", "Un programa en ejecución está usando este motor. Descarga primero tus modelos.",
"Этот движок используется работающей программой. Сначала выгрузите свои модели.", "Um programa em execução está usando este motor. Descarregue seus modelos primeiro.",
"실행 중인 프로그램이 이 엔진을 사용하고 있습니다. 먼저 모델을 언로드하세요.", "Un programme en cours utilise ce moteur. Déchargez d'abord vos modèles.")
add("Engine {0} is now in use.",
"现在使用引擎 {0}。", "エンジン {0} を使用中です。", "Engine {0} wird jetzt verwendet.", "Ahora se usa el motor {0}.", "Теперь используется движок {0}.", "O motor {0} está em uso agora.",
"이제 엔진 {0}을(를) 사용합니다.", "Le moteur {0} est maintenant utilisé.")
add("Engine {0} was deleted.",
"引擎 {0} 已删除。", "エンジン {0} を削除しました。", "Engine {0} wurde gelöscht.", "Se eliminó el motor {0}.", "Движок {0} удалён.", "O motor {0} foi excluído.",
"엔진 {0}을(를) 삭제했습니다.", "Le moteur {0} a été supprimé.")
add("Use engine {0}?",
"使用引擎 {0}?", "エンジン {0} を使いますか?", "Engine {0} verwenden?", "¿Usar el motor {0}?", "Использовать движок {0}?", "Usar o motor {0}?", "엔진 {0}을(를) 사용할까요?", "Utiliser le moteur {0} ?")
add("Use engine {0}",
"使用引擎 {0}", "エンジン {0} を使う", "Engine {0} verwenden", "Usar el motor {0}", "Использовать движок {0}", "Usar o motor {0}", "엔진 {0} 사용", "Utiliser le moteur {0}")
add("Models you start from now on run on engine {0}. Your models and settings stay as they are. You can switch back at any time.",
"从现在起启动的模型将使用引擎 {0}。你的模型和设置保持不变。你可以随时切换回来。",
"これから起動するモデルはエンジン {0} で動きます。モデルと設定はそのままです。いつでも元に戻せます。",
"Modelle, die du ab jetzt startest, laufen mit Engine {0}. Deine Modelle und Einstellungen bleiben unverändert. Du kannst jederzeit zurückwechseln.",
"Los modelos que inicies a partir de ahora usarán el motor {0}. Tus modelos y ajustes no cambian. Puedes volver cuando quieras.",
"Модели, которые вы запустите с этого момента, будут работать на движке {0}. Ваши модели и настройки останутся как есть. Вернуться можно в любой момент.",
"Os modelos que você iniciar a partir de agora usarão o motor {0}. Seus modelos e ajustes continuam como estão. Você pode voltar quando quiser.",
"지금부터 시작하는 모델은 엔진 {0}에서 실행됩니다. 모델과 설정은 그대로 유지됩니다. 언제든 다시 바꿀 수 있습니다.",
"Les modèles que vous démarrez à partir de maintenant utilisent le moteur {0}. Vos modèles et réglages restent inchangés. Vous pouvez revenir en arrière à tout moment.")
add("These models need a newer engine and may not start on it: {0}.",
"这些模型需要更新的引擎,在此引擎上可能无法启动:{0}。", "次のモデルはより新しいエンジンが必要で、このエンジンでは起動しない可能性があります:{0}。",
"Diese Modelle brauchen eine neuere Engine und starten damit eventuell nicht: {0}.", "Estos modelos necesitan un motor más reciente y puede que no arranquen con este: {0}.",
"Этим моделям нужен более новый движок, и на этом они могут не запуститься: {0}.", "Estes modelos precisam de um motor mais novo e podem não iniciar com este: {0}.",
"다음 모델은 더 새로운 엔진이 필요해 이 엔진에서는 시작되지 않을 수 있습니다: {0}.", "Ces modèles demandent un moteur plus récent et pourraient ne pas démarrer avec celui-ci : {0}.")
add("A model is running. Unload it in Models first, then switch.",
"有模型正在运行。请先在“模型”中卸载它,然后再切换。", "モデルが実行中です。先に「モデル」でアンロードしてから切り替えてください。",
"Ein Modell läuft. Entlade es zuerst unter „Modelle“ und wechsle dann.", "Hay un modelo en ejecución. Descárgalo primero en «Modelos» y luego cambia.",
"Работает модель. Сначала выгрузите её в разделе «Модели», затем переключитесь.", "Há um modelo em execução. Descarregue-o primeiro em «Modelos» e depois troque.",
"모델이 실행 중입니다. 먼저 '모델'에서 언로드한 다음 전환하세요.", "Un modèle est en cours d'exécution. Déchargez-le d'abord dans « Modèles », puis changez de moteur.")
add("Delete engine {0}?",
"删除引擎 {0}?", "エンジン {0} を削除しますか?", "Engine {0} löschen?", "¿Eliminar el motor {0}?", "Удалить движок {0}?", "Excluir o motor {0}?", "엔진 {0}을(를) 삭제할까요?", "Supprimer le moteur {0} ?")
add("Keep engine", "保留引擎", "エンジンを残す", "Engine behalten", "Conservar motor", "Оставить движок", "Manter motor", "엔진 유지", "Garder le moteur")
add("Delete engine", "删除引擎", "エンジンを削除", "Engine löschen", "Eliminar motor", "Удалить движок", "Excluir motor", "엔진 삭제", "Supprimer le moteur")
add("This frees {0}. Your models and settings are kept.",
"这将释放 {0}。你的模型和设置会保留。", "{0} が空きます。モデルと設定はそのまま残ります。", "Das gibt {0} frei. Deine Modelle und Einstellungen bleiben erhalten.",
"Esto libera {0}. Tus modelos y ajustes se conservan.", "Это освободит {0}. Ваши модели и настройки сохранятся.", "Isso libera {0}. Seus modelos e ajustes são mantidos.",
"{0}의 공간이 확보됩니다. 모델과 설정은 그대로 유지됩니다.", "Cela libère {0}. Vos modèles et réglages sont conservés.")
add("The newest engine can be downloaded again with Update in About.",
"最新的引擎可以在“关于”中通过更新重新下载。", "最新のエンジンは、「情報」の更新でもう一度ダウンロードできます。",
"Die neueste Engine lässt sich über das Update unter „Über“ erneut herunterladen.", "El motor más reciente se puede volver a descargar con Actualizar en «Acerca de».",
"Новейший движок можно скачать снова через обновление в разделе «О программе».", "O motor mais recente pode ser baixado de novo com Atualizar em «Sobre».",
"최신 엔진은 '정보'의 업데이트로 다시 내려받을 수 있습니다.", "Le moteur le plus récent peut être retéléchargé avec la mise à jour dans « À propos ».")
add("Strata can only download the newest engine, so a deleted older engine can't be brought back.",
"Strata 只能下载最新的引擎,所以删除后无法找回较旧的引擎。", "Strata がダウンロードできるのは最新のエンジンだけなので、削除した古いエンジンは元に戻せません。",
"Strata kann nur die neueste Engine herunterladen. Eine gelöschte ältere Engine lässt sich deshalb nicht wiederherstellen.",
"Strata solo puede descargar el motor más reciente, así que un motor anterior eliminado no se puede recuperar.",
"Strata может скачать только новейший движок, поэтому удалённый старый движок вернуть нельзя.",
"O Strata só pode baixar o motor mais recente, então um motor antigo excluído não pode ser recuperado.",
"Strata는 최신 엔진만 내려받을 수 있으므로, 삭제한 이전 엔진은 되돌릴 수 없습니다.",
"Strata ne peut télécharger que le moteur le plus récent : un ancien moteur supprimé ne peut donc pas être récupéré.")
# ---- why an engine cannot be used on this computer
add("This engine was made for a different operating system.",
"此引擎是为其他操作系统制作的。", "このエンジンは別のオペレーティングシステム向けです。", "Diese Engine wurde für ein anderes Betriebssystem gebaut.",
"Este motor se creó para otro sistema operativo.", "Этот движок создан для другой операционной системы.", "Este motor foi feito para outro sistema operacional.",
"이 엔진은 다른 운영체제용입니다.", "Ce moteur a été conçu pour un autre système d'exploitation.")
add("This engine was made for a different kind of graphics card than this computer has.",
"此引擎是为与这台电脑不同类型的显卡制作的。", "このエンジンは、このコンピューターとは別の種類のグラフィックスカード向けです。",
"Diese Engine wurde für eine andere Art von Grafikkarte gebaut als die in diesem Computer.", "Este motor se creó para un tipo de tarjeta gráfica distinto del de este equipo.",
"Этот движок создан для другого типа видеокарты, чем в этом компьютере.", "Este motor foi feito para um tipo de placa de vídeo diferente da deste computador.",
"이 엔진은 이 컴퓨터와 다른 종류의 그래픽 카드용입니다.", "Ce moteur a été conçu pour un autre type de carte graphique que celle de cet ordinateur.")
add("This engine needs Strata {0} or newer.",
"此引擎需要 Strata {0} 或更新版本。", "このエンジンには Strata {0} 以降が必要です。", "Diese Engine braucht Strata {0} oder neuer.", "Este motor necesita Strata {0} o posterior.",
"Для этого движка нужна Strata {0} или новее.", "Este motor precisa do Strata {0} ou mais recente.", "이 엔진에는 Strata {0} 이상이 필요합니다.", "Ce moteur nécessite Strata {0} ou plus récent.")
add("This engine doesn't support this computer's graphics card.",
"此引擎不支持这台电脑的显卡。", "このエンジンは、このコンピューターのグラフィックスカードに対応していません。", "Diese Engine unterstützt die Grafikkarte dieses Computers nicht.",
"Este motor no admite la tarjeta gráfica de este equipo.", "Этот движок не поддерживает видеокарту этого компьютера.", "Este motor não oferece suporte à placa de vídeo deste computador.",
"이 엔진은 이 컴퓨터의 그래픽 카드를 지원하지 않습니다.", "Ce moteur ne prend pas en charge la carte graphique de cet ordinateur.")
add("This engine needs NVIDIA driver {0} or newer. This computer has {1}.",
"此引擎需要 NVIDIA 驱动 {0} 或更新版本。这台电脑的驱动是 {1}。", "このエンジンには NVIDIA ドライバー {0} 以降が必要です。このコンピューターは {1} です。",
"Diese Engine braucht den NVIDIA-Treiber {0} oder neuer. Dieser Computer hat {1}.", "Este motor necesita el controlador NVIDIA {0} o posterior. Este equipo tiene {1}.",
"Для этого движка нужен драйвер NVIDIA {0} или новее. На этом компьютере {1}.", "Este motor precisa do driver NVIDIA {0} ou mais recente. Este computador tem o {1}.",
"이 엔진에는 NVIDIA 드라이버 {0} 이상이 필요합니다. 이 컴퓨터는 {1}입니다.", "Ce moteur nécessite le pilote NVIDIA {0} ou plus récent. Cet ordinateur a le {1}.")
add("Some of this engine's files are missing or changed, so it can't be started.",
"此引擎的部分文件缺失或已被更改,因此无法启动。", "このエンジンのファイルの一部が見つからないか変更されているため、起動できません。",
"Einige Dateien dieser Engine fehlen oder wurden verändert, deshalb lässt sie sich nicht starten.", "Faltan archivos de este motor o han cambiado, así que no se puede iniciar.",
"Часть файлов этого движка отсутствует или изменена, поэтому его нельзя запустить.", "Alguns arquivos deste motor estão ausentes ou foram alterados, então ele não pode ser iniciado.",
"이 엔진의 일부 파일이 없거나 변경되어 시작할 수 없습니다.", "Certains fichiers de ce moteur sont absents ou modifiés : il ne peut pas démarrer.")
add("This engine's description is missing or damaged, so it can't be started.",
"此引擎的说明文件缺失或已损坏,因此无法启动。", "このエンジンの説明ファイルが見つからないか壊れているため、起動できません。",
"Die Beschreibung dieser Engine fehlt oder ist beschädigt, deshalb lässt sie sich nicht starten.", "Falta la descripción de este motor o está dañada, así que no se puede iniciar.",
"Описание этого движка отсутствует или повреждено, поэтому его нельзя запустить.", "A descrição deste motor está ausente ou danificada, então ele não pode ser iniciado.",
"이 엔진의 설명 파일이 없거나 손상되어 시작할 수 없습니다.", "La description de ce moteur est absente ou endommagée : il ne peut pas démarrer.")
# ---- updating moved to About: the words that sent people to Settings > Engine
add("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → About).",
"Strata 未能识别这台电脑的引擎。模型会下载,但在安装合适的引擎之前可能无法运行(设置 → 关于)。",
"Strata はこのコンピューター用のエンジンを認識できませんでした。モデルはダウンロードされますが、合ったエンジンをインストールするまで動かない可能性があります(設定 → 情報)。",
"Strata hat für diesen Computer keine Engine erkannt. Das Modell wird heruntergeladen, läuft aber möglicherweise erst, wenn die passende Engine installiert ist (Einstellungen → Über).",
"Strata no reconoció ningún motor para este equipo. El modelo se descargará, pero puede que no funcione hasta que se instale el motor adecuado (Ajustes → Acerca de).",
"Strata не распознала движок для этого компьютера. Модель скачается, но может не запуститься, пока не установлен подходящий движок (Настройки → О программе).",
"O Strata não reconheceu um motor para este computador. O modelo será baixado, mas pode não funcionar até que o motor certo seja instalado (Configurações → Sobre).",
"Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 내려받지만, 알맞은 엔진이 설치되기 전에는 실행되지 않을 수 있습니다(설정 → 정보).",
"Strata n’a pas reconnu de moteur pour cet ordinateur. Le modèle sera téléchargé, mais il risque de ne pas fonctionner tant que le bon moteur n’est pas installé (Réglages → À propos).")
add("Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → About.",
"Strata 未能识别这台电脑的引擎。模型已下载,但在安装合适的引擎之前无法运行。请打开 设置 → 关于。",
"Strata はこのコンピューター用のエンジンを認識できませんでした。モデルはダウンロード済みですが、合ったエンジンをインストールするまで動きません。設定 → 情報 を開いてください。",
"Strata hat für diesen Computer keine Engine erkannt. Das Modell ist heruntergeladen, läuft aber erst, wenn die passende Engine installiert ist. Öffne Einstellungen → Über.",
"Strata no reconoció ningún motor para este equipo. El modelo está descargado, pero no puede funcionar hasta que se instale el motor adecuado. Abre Ajustes → Acerca de.",
"Strata не распознала движок для этого компьютера. Модель скачана, но не запустится, пока не установлен подходящий движок. Откройте Настройки → О программе.",
"O Strata não reconheceu um motor para este computador. O modelo foi baixado, mas não pode funcionar até que o motor certo seja instalado. Abra Configurações → Sobre.",
"Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 내려받았지만, 알맞은 엔진이 설치되기 전에는 실행할 수 없습니다. 설정 → 정보를 여세요.",
"Strata n’a pas reconnu de moteur pour cet ordinateur. Le modèle est téléchargé, mais il ne peut pas fonctionner tant que le bon moteur n’est pas installé. Ouvrez Réglages → À propos.")
add("Update the engine (Settings, About), then try again.",
"请更新引擎(设置,关于),然后重试。", "エンジンを更新して(設定、情報)、もう一度お試しください。", "Aktualisiere die Engine (Einstellungen, Über) und versuche es dann erneut.",
"Actualiza el motor (Ajustes, Acerca de) y vuelve a intentarlo.", "Обновите движок (Настройки, О программе) и попробуйте снова.", "Atualize o motor (Configurações, Sobre) e tente novamente.",
"엔진을 업데이트(설정, 정보)한 뒤 다시 시도하세요.", "Mettez le moteur à jour (Réglages, À propos), puis réessayez.")
# ---- answers of the host (shown as they are, so each one is a catalog entry)
add("A model is running, so nothing was changed. Unload the model in Models, then try again.",
"有模型正在运行,所以没有做任何更改。请先在“模型”中卸载模型,然后重试。", "モデルが実行中のため、何も変更していません。「モデル」でモデルをアンロードしてから、もう一度お試しください。",
"Ein Modell läuft, deshalb wurde nichts geändert. Entlade das Modell unter „Modelle“ und versuche es dann erneut.", "Hay un modelo en ejecución, así que no se cambió nada. Descarga el modelo en «Modelos» y vuelve a intentarlo.",
"Работает модель, поэтому ничего не изменено. Выгрузите модель в разделе «Модели» и попробуйте снова.", "Há um modelo em execução, então nada foi alterado. Descarregue o modelo em «Modelos» e tente novamente.",
"모델이 실행 중이라 아무것도 바뀌지 않았습니다. '모델'에서 모델을 언로드한 다음 다시 시도하세요.", "Un modèle est en cours d'exécution, rien n'a donc été modifié. Déchargez le modèle dans « Modèles », puis réessayez.")
add("This is the engine in use. Switch to another one first, then delete it.",
"这是正在使用的引擎。请先切换到另一个引擎,再删除它。", "これは使用中のエンジンです。先に別のエンジンに切り替えてから削除してください。",
"Das ist die verwendete Engine. Wechsle zuerst zu einer anderen und lösche sie dann.", "Este es el motor en uso. Cambia primero a otro y luego elimínalo.",
"Это используемый движок. Сначала переключитесь на другой, затем удалите его.", "Este é o motor em uso. Troque primeiro para outro e depois exclua-o.",
"사용 중인 엔진입니다. 먼저 다른 엔진으로 전환한 다음 삭제하세요.", "C'est le moteur utilisé. Passez d'abord à un autre, puis supprimez-le.")
add("A program is still using this engine, so it was not deleted. Unload your models and try again.",
"仍有程序在使用此引擎,所以没有删除。请卸载你的模型后重试。", "プログラムがまだこのエンジンを使っているため、削除しませんでした。モデルをアンロードしてから、もう一度お試しください。",
"Ein Programm benutzt diese Engine noch, deshalb wurde sie nicht gelöscht. Entlade deine Modelle und versuche es erneut.", "Un programa todavía usa este motor, así que no se eliminó. Descarga tus modelos y vuelve a intentarlo.",
"Этот движок ещё используется программой, поэтому он не удалён. Выгрузите модели и попробуйте снова.", "Um programa ainda está usando este motor, então ele não foi excluído. Descarregue seus modelos e tente novamente.",
"프로그램이 아직 이 엔진을 사용하고 있어 삭제하지 않았습니다. 모델을 언로드한 다음 다시 시도하세요.", "Un programme utilise encore ce moteur : il n'a pas été supprimé. Déchargez vos modèles, puis réessayez.")
add("That engine is already gone. The list has been refreshed.",
"该引擎已经不存在。列表已刷新。", "そのエンジンはすでにありません。一覧を更新しました。", "Diese Engine ist schon nicht mehr da. Die Liste wurde aktualisiert.",
"Ese motor ya no está. La lista se ha actualizado.", "Этого движка уже нет. Список обновлён.", "Esse motor já não existe. A lista foi atualizada.", "해당 엔진은 이미 없습니다. 목록을 새로 고쳤습니다.", "Ce moteur n'existe déjà plus. La liste a été actualisée.")
add("An update is running. Wait for it to finish, then try again.",
"正在进行更新。请等它完成后再试。", "更新を実行中です。終わるまで待ってから、もう一度お試しください。", "Ein Update läuft. Warte, bis es fertig ist, und versuche es dann erneut.",
"Hay una actualización en curso. Espera a que termine y vuelve a intentarlo.", "Идёт обновление. Дождитесь его окончания и попробуйте снова.", "Uma atualização está em andamento. Aguarde o fim e tente novamente.",
"업데이트가 진행 중입니다. 끝날 때까지 기다린 다음 다시 시도하세요.", "Une mise à jour est en cours. Attendez qu'elle se termine, puis réessayez.")
add("This engine can't run on this computer, so nothing was changed.",
"此引擎无法在这台电脑上运行,所以没有做任何更改。", "このエンジンはこのコンピューターでは動かないため、何も変更していません。", "Diese Engine läuft auf diesem Computer nicht, deshalb wurde nichts geändert.",
"Este motor no puede funcionar en este equipo, así que no se cambió nada.", "Этот движок не может работать на этом компьютере, поэтому ничего не изменено.", "Este motor não pode funcionar neste computador, então nada foi alterado.",
"이 엔진은 이 컴퓨터에서 실행할 수 없어 아무것도 바뀌지 않았습니다.", "Ce moteur ne peut pas fonctionner sur cet ordinateur : rien n'a été modifié.")
add("The engine wasn't deleted. Nothing else was changed.",
"引擎没有被删除。其他内容均未更改。", "エンジンは削除されませんでした。ほかには何も変更していません。", "Die Engine wurde nicht gelöscht. Sonst wurde nichts geändert.",
"El motor no se eliminó. No se cambió nada más.", "Движок не удалён. Больше ничего не изменено.", "O motor não foi excluído. Nada mais foi alterado.", "엔진이 삭제되지 않았습니다. 다른 것은 바뀌지 않았습니다.", "Le moteur n'a pas été supprimé. Rien d'autre n'a été modifié.")
add("The engine wasn't switched. Nothing was changed.",
"引擎没有被切换。没有做任何更改。", "エンジンは切り替わりませんでした。何も変更していません。", "Die Engine wurde nicht gewechselt. Es wurde nichts geändert.",
"No se cambió el motor. No se modificó nada.", "Движок не переключён. Ничего не изменено.", "O motor não foi trocado. Nada foi alterado.", "엔진이 전환되지 않았습니다. 아무것도 바뀌지 않았습니다.", "Le moteur n'a pas été changé. Rien n'a été modifié.")
add("Choose an engine from the list.",
"请从列表中选择一个引擎。", "一覧からエンジンを選んでください。", "Wähle eine Engine aus der Liste.", "Elige un motor de la lista.", "Выберите движок из списка.", "Escolha um motor da lista.", "목록에서 엔진을 선택하세요.", "Choisissez un moteur dans la liste.")
add("A newer engine is already on this computer. Update switches to it; nothing is downloaded.",
"这台电脑上已经有更新的引擎。点击更新会切换到它,不会下载任何内容。", "このコンピューターにはすでに新しいエンジンがあります。更新するとそれに切り替わり、ダウンロードはされません。",
"Auf diesem Computer ist schon eine neuere Engine. Das Update wechselt zu ihr; es wird nichts heruntergeladen.", "Este equipo ya tiene un motor más reciente. Actualizar cambia a él; no se descarga nada.",
"На этом компьютере уже есть более новый движок. Обновление переключит на него; ничего не скачивается.", "Este computador já tem um motor mais novo. Atualizar troca para ele; nada é baixado.",
"이 컴퓨터에 이미 더 새로운 엔진이 있습니다. 업데이트하면 그 엔진으로 전환되며, 내려받는 것은 없습니다.", "Cet ordinateur a déjà un moteur plus récent. La mise à jour bascule dessus ; rien n'est téléchargé.")
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)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", 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"et{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"engine-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+60
View File
@@ -0,0 +1,60 @@
"""Writes src/locales/gui-<lang>.json: the strings of the GUI feedback round 2 (branch f-0291-gui-fb2): the new name and hint of the "Leave room for my work"
choice (it keeps 5-8 GB of memory free; it was "Leave 20% for my work"), the memory-error sentence that names it, and the words of the compact files block
("Delivered to", "Working folder", "{0} files").
Run from apps/host: python scripts/make-gui-locales.py
Every entry names all nine languages; the keys are gf01.. in the order of S. gui-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 pick-, tune-, tools-, fix-, gh-, ui-, engine- and df2- 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("Leave room for my work",
"为我的工作留出空间", "作業用の余裕を残す", "Platz für meine Arbeit lassen", "Dejar espacio para mi trabajo",
"Оставить запас для моей работы", "Deixar espaço para o meu trabalho", "내 작업을 위한 여유 남기기", "Garder de la place pour mon travail")
add("Keeps 5-8 GB of memory free for other programs.",
"为其他程序保留 5-8 GB 空闲内存。", "他のプログラム用に 5~8 GB のメモリを空けておきます。", "Hält 5-8 GB Arbeitsspeicher für andere Programme frei.",
"Mantiene libres 5-8 GB de memoria para otros programas.", "Оставляет 5-8 ГБ памяти свободными для других программ.",
"Mantém 5-8 GB de memória livres para outros programas.", "다른 프로그램을 위해 메모리 5-8 GB를 비워 둡니다.", "Garde 5-8 Go de mémoire libres pour les autres programmes.")
add("Close other programs and try again, or choose “Leave room for my work”. In Expert mode you can also turn on the low-RAM option, so part of the model is read from the SSD.",
"请关闭其他程序后重试,或选择“为我的工作留出空间”。在专家模式下,你还可以开启低内存选项,让部分模型从 SSD 读取。",
"他のプログラムを閉じてもう一度試すか、「作業用の余裕を残す」を選んでください。エキスパートモードでは低 RAM オプションをオンにして、モデルの一部を SSD から読み込むこともできます。",
"Schließen Sie andere Programme und versuchen Sie es erneut, oder wählen Sie „Platz für meine Arbeit lassen“. Im Expertenmodus können Sie außerdem die Option für wenig Arbeitsspeicher aktivieren, sodass ein Teil des Modells von der SSD gelesen wird.",
"Cierre otros programas e inténtelo de nuevo, o elija «Dejar espacio para mi trabajo». En el modo experto también puede activar la opción de poca RAM, para que una parte del modelo se lea desde el SSD.",
"Закройте другие программы и повторите попытку или выберите «Оставить запас для моей работы». В экспертном режиме можно также включить режим малого объёма ОЗУ, и часть модели будет читаться с SSD.",
"Feche outros programas e tente novamente, ou escolha “Deixar espaço para o meu trabalho”. No modo especialista, você também pode ativar a opção de pouca RAM, para que parte do modelo seja lida do SSD.",
"다른 프로그램을 닫고 다시 시도하거나 “내 작업을 위한 여유 남기기”를 선택하세요. 전문가 모드에서는 저RAM 옵션을 켜서 모델의 일부를 SSD에서 읽게 할 수도 있습니다.",
"Fermez les autres programmes et réessayez, ou choisissez « Garder de la place pour mon travail ». En mode expert, vous pouvez aussi activer l'option de faible RAM : une partie du modèle est alors lue depuis le SSD.")
add("Delivered to",
"已交付到", "保存先", "Geliefert nach", "Entregado en", "Результат в", "Entregue em", "전달 위치", "Livré dans")
add("Working folder",
"工作文件夹", "作業フォルダー", "Arbeitsordner", "Carpeta de trabajo", "Рабочая папка", "Pasta de trabalho", "작업 폴더", "Dossier de travail")
add("{0} files",
"{0} 个文件", "{0} 個のファイル", "{0} Dateien", "{0} archivos", "Файлов: {0}", "{0} arquivos", "파일 {0}개", "{0} fichiers")
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)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", 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"gf{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"gui-{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")
+75
View File
@@ -0,0 +1,75 @@
"""Writes src/locales/pick-<lang>.json: the strings of the model version list (VersionPicker), the code-block preview button and the This computer panel (branch f-0291-ui-fixes2).
Run from apps/host: python scripts/make-pick-locales.py
Every entry names all nine languages; the keys are vp01.. in the order of S. pick-en.json is the English source. Placeholders ({0}, {1}) must be kept
in every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix-, gh-, ui- and engine- 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("{0} download",
"下载 {0}", "ダウンロード {0}", "{0} Download", "Descarga de {0}", "Загрузка {0}", "Download de {0}", "다운로드 {0}", "Téléchargement de {0}")
add("Needs {0} of memory",
"需要 {0} 内存", "{0} のメモリーが必要", "Braucht {0} Speicher", "Necesita {0} de memoria", "Нужно {0} памяти", "Precisa de {0} de memória", "메모리 {0} 필요", "Nécessite {0} de mémoire")
add("Needs {0} of memory and about {1} GB of disk",
"需要 {0} 内存和约 {1} GB 磁盘空间", "{0} のメモリーと約 {1} GB のディスク容量が必要", "Braucht {0} Speicher und etwa {1} GB Festplattenplatz", "Necesita {0} de memoria y unos {1} GB de disco",
"Нужно {0} памяти и около {1} ГБ на диске", "Precisa de {0} de memória e cerca de {1} GB de disco", "메모리 {0}와 디스크 약 {1} GB 필요", "Nécessite {0} de mémoire et environ {1} Go de disque")
add("Fits",
"可以运行", "問題なく動きます", "Passt", "Cabe", "Помещается", "Cabe", "문제없이 실행됩니다", "Tient")
add("Fits with SSD streaming",
"可以运行,部分内容从 SSD 读取", "SSD から読み込みながら動きます", "Passt mit SSD-Streaming", "Cabe con streaming desde el SSD", "Помещается с потоковой подгрузкой с SSD", "Cabe com streaming do SSD", "SSD 스트리밍으로 실행됩니다", "Tient avec streaming depuis le SSD")
add("Doesn't fit",
"放不下", "収まりません", "Passt nicht", "No cabe", "Не помещается", "Não cabe", "맞지 않습니다", "Ne tient pas")
add("Show details",
"显示详情", "詳細を表示", "Details anzeigen", "Mostrar detalles", "Показать подробности", "Mostrar detalhes", "자세히 보기", "Afficher les détails")
add("Hide details",
"隐藏详情", "詳細を隠す", "Details ausblenden", "Ocultar detalles", "Скрыть подробности", "Ocultar detalhes", "자세히 숨기기", "Masquer les détails")
add("{0} RAM + VRAM",
"{0} 内存 + 显存", "{0} の RAM + VRAM", "{0} RAM + VRAM", "{0} de RAM + VRAM", "{0} RAM + VRAM", "{0} de RAM + VRAM", "RAM + VRAM {0}", "{0} de RAM + VRAM")
add("Close preview",
"关闭预览", "プレビューを閉じる", "Vorschau schließen", "Cerrar vista previa", "Закрыть предпросмотр", "Fechar pré-visualização", "미리보기 닫기", "Fermer l'aperçu")
add("Free right now",
"当前可用", "現在の空き", "Gerade frei", "Libre ahora", "Свободно сейчас", "Livre agora", "지금 비어 있음", "Libre en ce moment")
add("{0} free",
"可用 {0}", "空き {0}", "{0} frei", "{0} libres", "свободно {0}", "{0} livres", "{0} 사용 가능", "{0} libres")
add("{0}: {1} free of {2}",
"{0}:{2} 中有 {1} 可用", "{0}:{2} のうち {1} が空き", "{0}: {1} frei von {2}", "{0}: {1} libres de {2}", "{0}: свободно {1} из {2}", "{0}: {1} livres de {2}", "{0}: {2} 중 {1} 사용 가능", "{0} : {1} libres sur {2}")
add("It takes about",
"它大约占用", "使用量の目安", "Es belegt etwa", "Ocupa aproximadamente", "Занимает примерно", "Ocupa cerca de", "대략 사용하는 양", "Il occupe environ")
add("Nothing. It fits in memory.",
"没有。它完全放得进内存。", "ありません。すべてメモリーに収まっています。", "Nichts. Es passt in den Speicher.", "Nada. Cabe en la memoria.", "Ничего. Всё помещается в память.", "Nada. Cabe na memória.", "없음. 메모리에 모두 들어갑니다.", "Rien. Tout tient en mémoire.")
add("Reading from your SSD now: {0}",
"此刻正从 SSD 读取:{0}", "現在 SSD から読み込み中:{0}", "Gerade vom SSD gelesen: {0}", "Leyendo del SSD ahora: {0}", "Сейчас читается с SSD: {0}", "Lendo do SSD agora: {0}", "지금 SSD에서 읽는 중: {0}", "Lecture depuis le SSD en cours : {0}")
add("This version would take about {0} of graphics memory and {1} of RAM.",
"这个版本大约会占用 {0} 显存和 {1} 内存。", "このバージョンはグラフィックスメモリーを約 {0}、RAM を約 {1} 使います。", "Diese Version würde etwa {0} Grafikspeicher und {1} RAM belegen.",
"Esta versión ocuparía aproximadamente {0} de memoria gráfica y {1} de RAM.", "Эта версия займёт примерно {0} видеопамяти и {1} оперативной памяти.", "Esta versão ocuparia cerca de {0} de memória gráfica e {1} de RAM.",
"이 버전은 그래픽 메모리 약 {0}와 RAM 약 {1}을 사용합니다.", "Cette version occuperait environ {0} de mémoire graphique et {1} de RAM.")
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)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", 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"vp{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"pick-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+67
View File
@@ -0,0 +1,67 @@
"""Writes src/locales/r3-<lang>.json: the strings added by the owner's GUI feedback round 3 (branch f-0291-ui-r3).
Run from apps/host: python scripts/make-r3-locales.py
Every entry names all nine languages; the keys are r3_01.. in the order of S. r3-en.json is the English source. Placeholders ({0}, {1}) must be kept in every
language. A separate file keeps this work from colliding with the other catalogs (the same reason pick-, upd-, df2- 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))
# 2: the Read pictures row while the installed engine has no picture reader (a tooltip, not a paragraph)
add("Needs an engine update",
"需要更新引擎", "エンジンの更新が必要です", "Braucht ein Engine-Update", "Necesita actualizar el motor", "Нужно обновить движок", "Precisa de uma atualização do motor", "엔진 업데이트가 필요합니다", "Nécessite une mise à jour du moteur")
# 5: the one word the version list writes for a version that does not fully fit in memory
add("SSD streaming · will run slower",
"SSD 流式读取 · 运行会更慢", "SSD ストリーミング · 動作は遅くなります", "SSD-Streaming · läuft langsamer", "Streaming desde el SSD · irá más lento", "Потоковая подгрузка с SSD · будет работать медленнее",
"Streaming do SSD · vai rodar mais devagar", "SSD 스트리밍 · 더 느리게 실행됩니다", "Streaming depuis le SSD · plus lent")
# 6: the warning under the conversation context when a long context leaves little graphics memory for the model (nothing is refused or lowered)
add("This much context takes about {0} GB of graphics memory and leaves little room for the model on this card. Strata will try it; if it does not start, choose a shorter context.",
"这么长的上下文大约占用 {0} GB 显存,留给模型的空间很少。Strata 会尝试运行;如果无法启动,请选择更短的上下文。",
"このコンテキストはグラフィックスメモリーを約 {0} GB 使い、このカードではモデルに残る余裕がわずかです。Strata は試しますが、起動しない場合はより短いコンテキストを選んでください。",
"So viel Kontext belegt etwa {0} GB Grafikspeicher und lässt auf dieser Karte wenig Platz für das Modell. Strata versucht es; startet es nicht, wähle einen kürzeren Kontext.",
"Este contexto ocupa unos {0} GB de memoria gráfica y deja poco espacio para el modelo en esta tarjeta. Strata lo intentará; si no arranca, elige un contexto más corto.",
"Такой контекст занимает около {0} ГБ видеопамяти и оставляет модели мало места на этой карте. Strata попробует запустить; если не получится, выберите контекст короче.",
"Esse contexto ocupa cerca de {0} GB de memória gráfica e deixa pouco espaço para o modelo nesta placa. O Strata vai tentar; se não iniciar, escolha um contexto menor.",
"이 정도의 컨텍스트는 그래픽 메모리를 약 {0} GB 사용하며 이 카드에서는 모델이 쓸 공간이 거의 남지 않습니다. Strata가 시도하지만 시작되지 않으면 더 짧은 컨텍스트를 선택하세요.",
"Ce contexte occupe environ {0} Go de mémoire graphique et laisse peu de place au modèle sur cette carte. Strata va essayer ; s'il ne démarre pas, choisissez un contexte plus court.")
# 9: a chat that is working while the person is elsewhere (the ring on its row, and the model chip's line)
add("Working · {0}",
"进行中 · {0}", "作業中 · {0}", "In Arbeit · {0}", "Trabajando · {0}", "В работе · {0}", "Trabalhando · {0}", "작업 중 · {0}", "En cours de travail · {0}")
add("{0} chats working",
"{0} 个对话进行中", "{0} 件のチャットが作業中", "{0} Chats in Arbeit", "{0} chats trabajando", "В работе чатов: {0}", "{0} chats trabalhando", "대화 {0}개 작업 중", "{0} conversations en cours")
# 10: the size line of a file being written
add("{0} lines",
"{0} 行", "{0} 行", "{0} Zeilen", "{0} líneas", "{0} строк", "{0} linhas", "{0}줄", "{0} lignes")
#@@MORE@@
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)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", 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"r3_{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"r3-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+249
View File
@@ -0,0 +1,249 @@
"""Writes src/locales/ui-<lang>.json: the interface strings of the 0.2.10 interface changes (branch f-0291-ui).
Run from apps/host: python scripts/make-ui-locales.py
Every entry names all nine languages; the keys are ui01.. in the order of S. ui-en.json is the English source. Placeholders ({0}, {1}) must be
kept in every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix- and gh- exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(**kw):
assert sorted(kw) == sorted(LANGS), sorted(set(LANGS) ^ set(kw))
S.append(kw)
# #4725 the approval stays in view
add(en="Waiting for your answer",
zh="等待你的回答", ja="あなたの返答を待っています", de="Wartet auf deine Antwort", es="Esperando tu respuesta",
ru="Ждёт вашего ответа", pt="Aguardando a sua resposta", ko="답변을 기다리는 중", fr="En attente de votre réponse")
add(en="Waiting for your answer below",
zh="等待你在下方回答", ja="下で返答をお待ちしています", de="Wartet unten auf deine Antwort", es="Esperando tu respuesta abajo",
ru="Ждёт вашего ответа ниже", pt="Aguardando a sua resposta abaixo", ko="아래에서 답변을 기다리는 중", fr="En attente de votre réponse ci-dessous")
# #4804 why Start tuning is greyed out
add(en="Strata is still checking this model for saved tunings. Start tuning works as soon as that is done.",
zh="Strata 仍在检查此模型是否有已保存的调优。检查完成后即可开始调优。",
ja="Strata がこのモデルの保存済みチューニングを確認しています。確認が終わりしだい、チューニングを開始できます。",
de="Strata prüft dieses Modell noch auf gespeicherte Abstimmungen. Sobald das fertig ist, kannst du die Abstimmung starten.",
es="Strata todavía está comprobando si este modelo tiene ajustes guardados. Podrás empezar el ajuste en cuanto termine.",
ru="Strata ещё проверяет, есть ли у этой модели сохранённые настройки. Как только проверка закончится, можно будет запустить настройку.",
pt="O Strata ainda está verificando se este modelo tem ajustes salvos. Assim que terminar, você poderá iniciar o ajuste.",
ko="Strata가 이 모델의 저장된 최적화를 확인하는 중입니다. 확인이 끝나면 바로 최적화를 시작할 수 있습니다.",
fr="Strata vérifie encore si ce modèle a des optimisations enregistrées. Dès que c’est fini, vous pourrez lancer l’optimisation.")
add(en="Strata could not read the tuning details for this model, so tuning cannot start. Close this window and open it again.",
zh="Strata 无法读取此模型的调优信息,因此无法开始调优。请关闭此窗口后重新打开。",
ja="Strata がこのモデルのチューニング情報を読み取れなかったため、チューニングを開始できません。このウィンドウを閉じて、もう一度開いてください。",
de="Strata konnte die Abstimmungsdaten dieses Modells nicht lesen, deshalb lässt sich die Abstimmung nicht starten. Schließe dieses Fenster und öffne es erneut.",
es="Strata no pudo leer los datos de ajuste de este modelo, así que el ajuste no puede empezar. Cierra esta ventana y ábrela de nuevo.",
ru="Strata не смогла прочитать данные настройки этой модели, поэтому настройку нельзя запустить. Закройте это окно и откройте его снова.",
pt="O Strata não conseguiu ler os dados de ajuste deste modelo, então o ajuste não pode começar. Feche esta janela e abra-a novamente.",
ko="Strata가 이 모델의 최적화 정보를 읽지 못해 최적화를 시작할 수 없습니다. 이 창을 닫았다가 다시 여세요.",
fr="Strata n’a pas pu lire les données d’optimisation de ce modèle, l’optimisation ne peut donc pas démarrer. Fermez cette fenêtre et rouvrez-la.")
add(en="Checking for saved tunings\u2026",
zh="正在检查已保存的调优\u2026", ja="保存済みチューニングを確認しています\u2026", de="Gespeicherte Abstimmungen werden geprüft\u2026", es="Comprobando los ajustes guardados\u2026",
ru="Проверяем сохранённые настройки\u2026", pt="Verificando os ajustes salvos\u2026", ko="저장된 최적화를 확인하는 중\u2026", fr="Vérification des optimisations enregistrées\u2026")
# #4802 the progress card
add(en="Still working for {0}. You don't need to do anything.",
zh="仍在处理,已用时 {0}。你无需做任何操作。",
ja="処理中です({0}経過)。操作は必要ありません。",
de="Läuft noch seit {0}. Du musst nichts tun.",
es="Sigue trabajando desde hace {0}. No tienes que hacer nada.",
ru="Всё ещё работает: {0}. От вас ничего не требуется.",
pt="Ainda trabalhando há {0}. Você não precisa fazer nada.",
ko="{0}째 작업 중입니다. 따로 할 일은 없습니다.",
fr="Toujours en cours depuis {0}. Vous n'avez rien à faire.")
add(en="The buttons below wait until this finishes.",
zh="下方的按钮会在此操作完成后恢复可用。",
ja="この処理が終わるまで、下のボタンは使えません。",
de="Die Schaltflächen unten warten, bis das fertig ist.",
es="Los botones de abajo esperan a que esto termine.",
ru="Кнопки ниже подождут, пока это не закончится.",
pt="Os botões abaixo esperam até isto terminar.",
ko="아래 버튼은 이 작업이 끝날 때까지 기다립니다.",
fr="Les boutons ci-dessous attendent la fin de cette opération.")
# #4798 two addresses
add(en="API base URL on this computer",
zh="此电脑上的 API 基础地址", ja="このパソコンでの API ベース URL", de="API-Basis-URL auf diesem Computer", es="URL base de la API en este equipo",
ru="Базовый URL API на этом компьютере", pt="URL base da API neste computador", ko="이 컴퓨터의 API 기본 URL", fr="URL de base de l'API sur cet ordinateur")
add(en="Address for other devices",
zh="其他设备使用的地址", ja="他のデバイス用のアドレス", de="Adresse für andere Geräte", es="Dirección para otros dispositivos",
ru="Адрес для других устройств", pt="Endereço para outros dispositivos", ko="다른 기기용 주소", fr="Adresse pour les autres appareils")
add(en="Other devices use this address. It has its own port, separate from the one Strata's window uses, because only the model's API is shared on the network, not the app.",
zh="其他设备使用此地址。它有自己的端口,与 Strata 窗口使用的端口分开,因为网络上只共享模型的 API,而不是应用本身。",
ja="他のデバイスはこのアドレスを使います。ネットワークで共有されるのはモデルの API だけでアプリ本体ではないため、Strata のウィンドウが使うポートとは別のポートを使います。",
de="Andere Geräte verwenden diese Adresse. Sie hat einen eigenen Port, getrennt von dem des Strata-Fensters, weil im Netzwerk nur die API des Modells freigegeben wird, nicht die App.",
es="Los demás dispositivos usan esta dirección. Tiene su propio puerto, distinto del que usa la ventana de Strata, porque en la red solo se comparte la API del modelo, no la aplicación.",
ru="Другие устройства используют этот адрес. У него свой порт, отдельный от порта окна Strata, потому что в сети доступен только API модели, а не само приложение.",
pt="Os outros dispositivos usam este endereço. Ele tem a própria porta, separada da que a janela do Strata usa, porque só a API do modelo é compartilhada na rede, não o aplicativo.",
ko="다른 기기는 이 주소를 사용합니다. 네트워크에는 앱이 아니라 모델의 API만 공유되므로, Strata 창이 쓰는 포트와는 별도의 포트를 씁니다.",
fr="Les autres appareils utilisent cette adresse. Elle a son propre port, distinct de celui de la fenêtre de Strata, car seule l'API du modèle est partagée sur le réseau, pas l'application.")
# #3723 Context memory and the out-of-graphics-memory reason
add(en="INT8 is the standard: good quality and a medium amount of memory.",
zh="INT8 是标准选项:质量好,内存占用适中。",
ja="INT8 は標準の設定です。品質が良く、メモリ使用量は中程度です。",
de="INT8 ist der Standard: gute Qualität und mittlerer Speicherbedarf.",
es="INT8 es el estándar: buena calidad y un uso de memoria medio.",
ru="INT8 — стандартный вариант: хорошее качество и средний расход памяти.",
pt="INT8 é o padrão: boa qualidade e uso médio de memória.",
ko="INT8은 표준 설정입니다. 품질이 좋고 메모리 사용량은 보통입니다.",
fr="INT8 est le réglage standard : bonne qualité et une consommation de mémoire moyenne.")
add(en="Q4 uses about half the memory of INT8, but is less precise on very long documents.",
zh="Q4 占用的内存约为 INT8 的一半,但在很长的文档上精度较低。",
ja="Q4 は INT8 の約半分のメモリで済みますが、非常に長い文書では精度が少し下がります。",
de="Q4 braucht etwa halb so viel Speicher wie INT8, ist aber bei sehr langen Dokumenten weniger genau.",
es="Q4 usa aproximadamente la mitad de memoria que INT8, pero es menos preciso con documentos muy largos.",
ru="Q4 занимает примерно вдвое меньше памяти, чем INT8, но менее точен на очень длинных документах.",
pt="Q4 usa cerca de metade da memória do INT8, mas é menos preciso em documentos muito longos.",
ko="Q4는 INT8의 약 절반 메모리를 쓰지만, 아주 긴 문서에서는 정밀도가 떨어집니다.",
fr="Q4 utilise environ moitié moins de mémoire que INT8, mais il est moins précis sur de très longs documents.")
add(en="K8 / V4 uses about a quarter less memory than INT8 and is more precise than Q4. Prompts are 2 to 5% slower.",
zh="K8 / V4 比 INT8 约少占用四分之一的内存,精度高于 Q4。处理提示词会慢 2% 到 5%。",
ja="K8 / V4 は INT8 より約4分の1少ないメモリで済み、Q4 より精度が高い設定です。プロンプトの処理は 2〜5% 遅くなります。",
de="K8 / V4 braucht etwa ein Viertel weniger Speicher als INT8 und ist genauer als Q4. Prompts sind 2 bis 5 % langsamer.",
es="K8 / V4 usa aproximadamente una cuarta parte menos de memoria que INT8 y es más preciso que Q4. Los prompts son entre un 2 y un 5 % más lentos.",
ru="K8 / V4 занимает примерно на четверть меньше памяти, чем INT8, и точнее, чем Q4. Запросы обрабатываются на 2–5 % медленнее.",
pt="K8 / V4 usa cerca de um quarto a menos de memória que o INT8 e é mais preciso que o Q4. Os prompts ficam de 2 a 5 % mais lentos.",
ko="K8 / V4는 INT8보다 메모리를 약 4분의 1 적게 쓰고 Q4보다 정밀합니다. 프롬프트 처리는 2~5% 느려집니다.",
fr="K8 / V4 utilise environ un quart de mémoire en moins que INT8 et est plus précis que Q4. Les prompts sont 2 à 5 % plus lents.")
add(en="The model did not fit in your graphics card’s memory.",
zh="模型放不进你的显卡显存。",
ja="モデルがグラフィックスカードのメモリに収まりませんでした。",
de="Das Modell hat nicht in den Speicher deiner Grafikkarte gepasst.",
es="El modelo no cabía en la memoria de tu tarjeta gráfica.",
ru="Модель не поместилась в память вашей видеокарты.",
pt="O modelo não coube na memória da sua placa de vídeo.",
ko="모델이 그래픽 카드 메모리에 들어가지 않았습니다.",
fr="Le modèle ne tenait pas dans la mémoire de votre carte graphique.")
add(en="Lower the conversation context, or set Context memory to Q4 in Expert settings, then try again.",
zh="请调低对话上下文,或在专家设置中把“上下文内存”设为 Q4,然后重试。",
ja="会話のコンテキストを小さくするか、上級設定で「コンテキストメモリー」を Q4 にして、もう一度お試しください。",
de="Verkleinere den Gesprächskontext oder stelle in den Experteneinstellungen „Kontextspeicher“ auf Q4 und versuche es erneut.",
es="Reduce el contexto de la conversación o pon «Memoria de contexto» en Q4 en Ajustes expertos, y vuelve a intentarlo.",
ru="Уменьшите контекст разговора или установите «Память контекста» в Q4 в разделе «Экспертные настройки» и повторите попытку.",
pt="Reduza o contexto da conversa ou defina «Memória de contexto» como Q4 em Configurações avançadas e tente de novo.",
ko="대화 컨텍스트를 줄이거나 전문가 설정에서 ‘컨텍스트 메모리’를 Q4로 바꾼 뒤 다시 시도하세요.",
fr="Réduisez le contexte de la conversation, ou réglez « Mémoire de contexte » sur Q4 dans Réglages experts, puis réessayez.")
# #4749 a message for a helper
add(en="Message this helper",
zh="给这个助手发消息", ja="このヘルパーにメッセージを送る", de="Diesem Helfer schreiben", es="Escribir a este ayudante",
ru="Написать этому помощнику", pt="Enviar mensagem a este ajudante", ko="이 도우미에게 메시지 보내기", fr="Écrire à cet assistant")
add(en="Message to {0}",
zh="发给 {0} 的消息", ja="{0} へのメッセージ", de="Nachricht an {0}", es="Mensaje para {0}",
ru="Сообщение для {0}", pt="Mensagem para {0}", ko="{0}에게 보낼 메시지", fr="Message pour {0}")
add(en="Write to {0}\u2026",
zh="写给 {0}\u2026", ja="{0} に書く\u2026", de="An {0} schreiben\u2026", es="Escribe a {0}\u2026",
ru="Напишите {0}\u2026", pt="Escreva para {0}\u2026", ko="{0}에게 쓰기\u2026", fr="Écrire à {0}\u2026")
add(en="Sent to {0}.",
zh="已发给 {0}。", ja="{0} に送りました。", de="An {0} gesendet.", es="Enviado a {0}.",
ru="Отправлено: {0}.", pt="Enviado para {0}.", ko="{0}에게 보냈습니다.", fr="Envoyé à {0}.")
# #4783 a message while an answer is being written
add(en="While an answer is being written",
zh="回答生成期间", ja="回答の作成中", de="Während eine Antwort geschrieben wird", es="Mientras se escribe una respuesta",
ru="Пока пишется ответ", pt="Enquanto uma resposta é escrita", ko="답변을 작성하는 동안", fr="Pendant qu'une réponse s'écrit")
add(en="What Enter does when you send a message while the assistant is still answering.",
zh="助手仍在回答时,你发送消息后按回车会发生什么。", ja="アシスタントがまだ回答している間にメッセージを送ったとき、Enter で何が起こるかを選びます。",
de="Was die Eingabetaste bewirkt, wenn du eine Nachricht sendest, während der Assistent noch antwortet.",
es="Qué hace Intro cuando envías un mensaje mientras el asistente todavía está respondiendo.",
ru="Что делает Enter, когда вы отправляете сообщение, пока ассистент ещё отвечает.",
pt="O que o Enter faz quando você envia uma mensagem enquanto o assistente ainda está respondendo.",
ko="어시스턴트가 아직 답하는 중에 메시지를 보낼 때 Enter가 하는 일입니다.",
fr="Ce que fait Entrée quand vous envoyez un message pendant que l'assistant répond encore.")
add(en="Wait for the answer",
zh="等回答完成", ja="回答が終わるまで待つ", de="Auf die Antwort warten", es="Esperar la respuesta",
ru="Дождаться ответа", pt="Esperar a resposta", ko="답변이 끝날 때까지 기다리기", fr="Attendre la réponse")
add(en="Send after the answer",
zh="回答完成后再发送", ja="回答が終わってから送る", de="Nach der Antwort senden", es="Enviar después de la respuesta",
ru="Отправить после ответа", pt="Enviar depois da resposta", ko="답변이 끝난 뒤 보내기", fr="Envoyer après la réponse")
add(en="Interrupt the answer",
zh="打断回答", ja="回答を中断する", de="Die Antwort unterbrechen", es="Interrumpir la respuesta",
ru="Прервать ответ", pt="Interromper a resposta", ko="답변 중단하기", fr="Interrompre la réponse")
add(en="Enter sends it when the answer is done.",
zh="回答完成后,按回车即可发送。", ja="回答が終わったら Enter で送信されます。", de="Die Eingabetaste sendet sie, sobald die Antwort fertig ist.",
es="Intro lo envía cuando termina la respuesta.", ru="Enter отправит сообщение, когда ответ закончится.",
pt="O Enter envia quando a resposta terminar.", ko="답변이 끝나면 Enter로 보냅니다.", fr="Entrée l'envoie quand la réponse est terminée.")
add(en="Enter stops the answer here and sends it now.",
zh="按回车会在此处停止回答并立即发送。", ja="Enter で回答をここで止めて、すぐに送信します。", de="Die Eingabetaste stoppt die Antwort hier und sendet die Nachricht sofort.",
es="Intro detiene la respuesta aquí y lo envía ahora.", ru="Enter остановит ответ здесь и сразу отправит сообщение.",
pt="O Enter para a resposta aqui e envia agora.", ko="Enter를 누르면 여기서 답변을 멈추고 바로 보냅니다.", fr="Entrée arrête la réponse ici et l'envoie tout de suite.")
add(en="Waiting to send",
zh="等待发送", ja="送信待ち", de="Wartet auf das Senden", es="Esperando para enviar",
ru="Ждёт отправки", pt="Aguardando envio", ko="보내기 대기 중", fr="En attente d'envoi")
add(en="Take back",
zh="撤回", ja="取り消す", de="Zurücknehmen", es="Retirar",
ru="Забрать обратно", pt="Desfazer", ko="되돌리기", fr="Reprendre")
# #4785 quoting
add(en="Quote",
zh="引用", ja="引用", de="Zitieren", es="Citar",
ru="Цитировать", pt="Citar", ko="인용", fr="Citer")
add(en="Quoting the assistant",
zh="引用助手的话", ja="アシスタントの発言を引用", de="Zitat des Assistenten", es="Citando al asistente",
ru="Цитата ассистента", pt="Citando o assistente", ko="어시스턴트의 말을 인용", fr="Citation de l'assistant")
add(en="Quoting you",
zh="引用你的话", ja="あなたの発言を引用", de="Zitat von dir", es="Citándote a ti",
ru="Ваша цитата", pt="Citando você", ko="내 말을 인용", fr="Citation de vous")
add(en="Remove this quote",
zh="移除这条引用", ja="この引用を外す", de="Dieses Zitat entfernen", es="Quitar esta cita",
ru="Убрать эту цитату", pt="Remover esta citação", ko="이 인용 제거", fr="Retirer cette citation")
add(en="Go to the quoted message",
zh="跳到被引用的消息", ja="引用元のメッセージへ移動", de="Zur zitierten Nachricht springen", es="Ir al mensaje citado",
ru="Перейти к цитируемому сообщению", pt="Ir para a mensagem citada", ko="인용한 메시지로 이동", fr="Aller au message cité")
add(en="Quoted",
zh="引用", ja="引用", de="Zitiert", es="Citado",
ru="Цитата", pt="Citado", ko="인용됨", fr="Cité")
add(en="Show or hide the rest of the quote",
zh="显示或隐藏引用的其余部分", ja="引用の続きを表示または非表示", de="Rest des Zitats ein- oder ausblenden", es="Mostrar u ocultar el resto de la cita",
ru="Показать или скрыть остальную часть цитаты", pt="Mostrar ou ocultar o resto da citação", ko="인용의 나머지 부분 보기 또는 숨기기", fr="Afficher ou masquer la suite de la citation")
# #4244 the agent's own questions
add(en="Skip",
zh="跳过", ja="スキップ", de="Überspringen", es="Omitir",
ru="Пропустить", pt="Pular", ko="건너뛰기", fr="Passer")
add(en="The assistant has a question for you",
zh="助手有一个问题要问你", ja="アシスタントからの質問があります", de="Der Assistent hat eine Frage an dich", es="El asistente tiene una pregunta para ti",
ru="У ассистента есть вопрос к вам", pt="O assistente tem uma pergunta para você", ko="어시스턴트가 질문이 있습니다", fr="L'assistant a une question pour vous")
add(en="Type your own answer",
zh="输入你自己的回答", ja="自分の答えを入力", de="Eigene Antwort eingeben", es="Escribe tu propia respuesta",
ru="Введите свой ответ", pt="Digite a sua própria resposta", ko="직접 답변 입력", fr="Saisissez votre propre réponse")
add(en="Send answer",
zh="发送回答", ja="回答を送る", de="Antwort senden", es="Enviar respuesta",
ru="Отправить ответ", pt="Enviar resposta", ko="답변 보내기", fr="Envoyer la réponse")
add(en="Questions for you",
zh="向你提问", ja="あなたへの質問", de="Fragen an dich", es="Preguntas para ti",
ru="Вопросы к вам", pt="Perguntas para você", ko="내게 하는 질문", fr="Questions pour vous")
add(en="Lets the assistant stop and ask you a question, with answers to pick from, when it needs your decision.",
zh="当助手需要你做决定时,可以暂停并向你提问,并给出可选的答案。", ja="アシスタントがあなたの判断を必要とするとき、選択肢つきで質問できるようにします。",
de="Der Assistent kann anhalten und dir eine Frage mit Antworten zur Auswahl stellen, wenn er deine Entscheidung braucht.",
es="Permite que el asistente se detenga y te haga una pregunta, con respuestas para elegir, cuando necesita tu decisión.",
ru="Позволяет ассистенту остановиться и задать вам вопрос с вариантами ответа, когда ему нужно ваше решение.",
pt="Permite que o assistente pare e faça uma pergunta, com respostas para escolher, quando precisa da sua decisão.",
ko="어시스턴트가 결정이 필요할 때 멈추고 고를 수 있는 답과 함께 질문할 수 있게 합니다.",
fr="Permet à l'assistant de s'arrêter et de vous poser une question, avec des réponses à choisir, quand il a besoin de votre décision.")
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)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", 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"ui{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"ui-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+125
View File
@@ -0,0 +1,125 @@
"""Writes src/locales/upd-<lang>.json: the strings of the engine update beside a loaded model (the dialog of Settings > About, branch f-0291-ui-final).
Run from apps/host: python scripts/make-update-locales.py
Every entry names all nine languages; the keys are upd01.. in the order of S. upd-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 pick-, tune-, tools-, fix-, gh-, ui-, engine- and df2- 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("To update the engine, Strata has to stop the model that is loaded: {0}.",
"要更新引擎,Strata 必须先停止当前已加载的模型:{0}。",
"エンジンを更新するには、読み込み中のモデルを Strata が停止する必要があります:{0}。",
"Um die Engine zu aktualisieren, muss Strata das geladene Modell anhalten: {0}.",
"Para actualizar el motor, Strata tiene que detener el modelo cargado: {0}.",
"Чтобы обновить движок, Strata должна остановить загруженную модель: {0}.",
"Para atualizar o motor, o Strata precisa parar o modelo carregado: {0}.",
"엔진을 업데이트하려면 Strata가 불러온 모델을 중지해야 합니다: {0}.",
"Pour mettre à jour le moteur, Strata doit arrêter le modèle chargé : {0}.")
add("The new engine downloads first while the model keeps answering. Then the model stops (answers in progress finish first), the engine is updated, and you can start the model again with the same settings. Your models and settings are kept.",
"新引擎会先下载,此时模型仍可继续回答。然后模型停止(正在进行的回答会先完成),引擎随即更新,之后你可以用相同的设置再次启动该模型。你的模型和设置都会保留。",
"まず新しいエンジンをダウンロードします。その間、モデルは応答を続けます。その後モデルが停止し(進行中の回答は先に完了します)、エンジンが更新され、同じ設定でモデルをもう一度起動できます。モデルと設定はそのまま残ります。",
"Zuerst wird die neue Engine heruntergeladen, während das Modell weiter antwortet. Danach hält das Modell an (laufende Antworten werden zuerst beendet), die Engine wird aktualisiert, und Sie können das Modell mit denselben Einstellungen wieder starten. Ihre Modelle und Einstellungen bleiben erhalten.",
"Primero se descarga el motor nuevo mientras el modelo sigue respondiendo. Después el modelo se detiene (las respuestas en curso terminan antes), se actualiza el motor y puedes volver a iniciar el modelo con los mismos ajustes. Tus modelos y ajustes se conservan.",
"Сначала новый движок скачивается, пока модель продолжает отвечать. Затем модель останавливается (ответы в процессе сначала завершаются), движок обновляется, и вы можете снова запустить модель с теми же настройками. Ваши модели и настройки сохраняются.",
"Primeiro o novo motor é baixado enquanto o modelo continua a responder. Depois o modelo para (as respostas em curso terminam antes), o motor é atualizado e pode iniciar o modelo de novo com as mesmas definições. Os seus modelos e definições são mantidos.",
"먼저 모델이 계속 응답하는 동안 새 엔진을 다운로드합니다. 그다음 모델이 중지되고(진행 중인 답변은 먼저 끝납니다) 엔진이 업데이트되며, 같은 설정으로 모델을 다시 시작할 수 있습니다. 모델과 설정은 그대로 유지됩니다.",
"Le nouveau moteur est d'abord téléchargé pendant que le modèle continue de répondre. Ensuite le modèle s'arrête (les réponses en cours se terminent d'abord), le moteur est mis à jour, et vous pouvez redémarrer le modèle avec les mêmes réglages. Vos modèles et réglages sont conservés.")
add("Stop and update", "停止并更新", "停止して更新", "Anhalten und aktualisieren", "Detener y actualizar", "Остановить и обновить", "Parar e atualizar", "중지하고 업데이트", "Arrêter et mettre à jour")
add("Getting the new engine ready. {0} keeps working until it is ready.",
"正在准备新引擎。在准备好之前,{0} 会继续工作。",
"新しいエンジンを準備しています。準備ができるまで {0} は動き続けます。",
"Die neue Engine wird vorbereitet. {0} arbeitet weiter, bis sie bereit ist.",
"Preparando el motor nuevo. {0} sigue funcionando hasta que esté listo.",
"Готовим новый движок. {0} продолжает работать, пока он не будет готов.",
"A preparar o novo motor. {0} continua a funcionar até estar pronto.",
"새 엔진을 준비하는 중입니다. 준비될 때까지 {0}은(는) 계속 작동합니다.",
"Préparation du nouveau moteur. {0} continue de fonctionner jusqu'à ce qu'il soit prêt.")
add("Stopping {0}. Answers in progress finish first.",
"正在停止 {0}。正在进行的回答会先完成。",
"{0} を停止しています。進行中の回答は先に完了します。",
"{0} wird angehalten. Laufende Antworten werden zuerst beendet.",
"Deteniendo {0}. Las respuestas en curso terminan antes.",
"Останавливаем {0}. Ответы в процессе сначала завершаются.",
"A parar {0}. As respostas em curso terminam antes.",
"{0}을(를) 중지하는 중입니다. 진행 중인 답변은 먼저 끝납니다.",
"Arrêt de {0}. Les réponses en cours se terminent d'abord.")
add("{0} is stopped. Updating the engine…",
"{0} 已停止。正在更新引擎…",
"{0} を停止しました。エンジンを更新しています…",
"{0} ist angehalten. Die Engine wird aktualisiert…",
"{0} está detenido. Actualizando el motor…",
"{0} остановлена. Обновляем движок…",
"{0} está parado. A atualizar o motor…",
"{0}이(가) 중지되었습니다. 엔진을 업데이트하는 중입니다…",
"{0} est arrêté. Mise à jour du moteur…")
add("Updating the engine", "正在更新引擎", "エンジンを更新しています", "Engine wird aktualisiert", "Actualizando el motor", "Обновление движка", "A atualizar o motor", "엔진 업데이트 중", "Mise à jour du moteur")
add("The engine is updated", "引擎已更新", "エンジンを更新しました", "Die Engine ist aktualisiert", "El motor está actualizado", "Движок обновлён", "O motor está atualizado", "엔진이 업데이트되었습니다", "Le moteur est à jour")
add("The engine was not updated", "引擎未更新", "エンジンは更新されませんでした", "Die Engine wurde nicht aktualisiert", "El motor no se actualizó", "Движок не обновлён", "O motor não foi atualizado", "엔진이 업데이트되지 않았습니다", "Le moteur n'a pas été mis à jour")
add("The engine is updated. Start {0} again with the same settings?",
"引擎已更新。要用相同的设置再次启动 {0} 吗?",
"エンジンを更新しました。同じ設定で {0} をもう一度起動しますか?",
"Die Engine ist aktualisiert. {0} mit denselben Einstellungen wieder starten?",
"El motor está actualizado. ¿Iniciar {0} de nuevo con los mismos ajustes?",
"Движок обновлён. Запустить {0} снова с теми же настройками?",
"O motor está atualizado. Iniciar {0} de novo com as mesmas definições?",
"엔진이 업데이트되었습니다. 같은 설정으로 {0}을(를) 다시 시작할까요?",
"Le moteur est à jour. Redémarrer {0} avec les mêmes réglages ?")
add("The update didn't finish. Your old engine is unchanged and still works. Start {0} again on it?",
"更新没有完成。你原来的引擎没有改动,仍然可用。要在它上面再次启动 {0} 吗?",
"更新は完了しませんでした。以前のエンジンは変更されておらず、そのまま使えます。そのエンジンで {0} をもう一度起動しますか?",
"Die Aktualisierung wurde nicht abgeschlossen. Ihre alte Engine ist unverändert und funktioniert weiter. {0} damit wieder starten?",
"La actualización no terminó. Tu motor anterior no cambió y sigue funcionando. ¿Iniciar {0} de nuevo con él?",
"Обновление не завершилось. Прежний движок не изменён и работает. Запустить {0} снова на нём?",
"A atualização não terminou. O seu motor anterior não foi alterado e continua a funcionar. Iniciar {0} de novo com ele?",
"업데이트가 끝나지 않았습니다. 기존 엔진은 그대로이며 계속 작동합니다. 기존 엔진으로 {0}을(를) 다시 시작할까요?",
"La mise à jour ne s'est pas terminée. Votre ancien moteur est inchangé et fonctionne toujours. Redémarrer {0} avec lui ?")
add("Not now", "暂不", "今はしない", "Jetzt nicht", "Ahora no", "Не сейчас", "Agora não", "나중에", "Pas maintenant")
add("Starting {0}. Loading can take a few minutes.",
"正在启动 {0}。加载可能需要几分钟。",
"{0} を起動しています。読み込みには数分かかることがあります。",
"{0} wird gestartet. Das Laden kann einige Minuten dauern.",
"Iniciando {0}. La carga puede tardar unos minutos.",
"Запускаем {0}. Загрузка может занять несколько минут.",
"A iniciar {0}. O carregamento pode demorar alguns minutos.",
"{0}을(를) 시작하는 중입니다. 불러오는 데 몇 분이 걸릴 수 있습니다.",
"Démarrage de {0}. Le chargement peut prendre quelques minutes.")
add("Starting {0} again", "正在再次启动 {0}", "{0} をもう一度起動しています", "{0} wird wieder gestartet", "Iniciando {0} de nuevo", "Снова запускаем {0}", "A iniciar {0} de novo", "{0}을(를) 다시 시작하는 중", "Redémarrage de {0}")
add("The model did not start again", "模型没有重新启动", "モデルを再起動できませんでした", "Das Modell wurde nicht wieder gestartet", "El modelo no se inició de nuevo", "Модель не запустилась снова", "O modelo não voltou a iniciar", "모델이 다시 시작되지 않았습니다", "Le modèle n'a pas redémarré")
add("You can load it again from Models.", "你可以在“模型”中再次加载它。", "「モデル」からもう一度読み込めます。", "Sie können es unter „Modelle“ erneut laden.", "Puedes cargarlo de nuevo desde Modelos.", "Вы можете загрузить её снова в разделе «Модели».", "Pode carregá-lo de novo em Modelos.", "모델 화면에서 다시 불러올 수 있습니다.", "Vous pouvez le recharger depuis Modèles.")
add("A model that was not started from this app is running, so nothing was changed. Stop it, then update the engine again.",
"有一个不是从此应用启动的模型正在运行,因此没有做任何更改。请先停止它,然后再次更新引擎。",
"このアプリから起動していないモデルが動作しているため、何も変更していません。それを停止してから、もう一度エンジンを更新してください。",
"Ein Modell, das nicht aus dieser App gestartet wurde, läuft noch, daher wurde nichts geändert. Halten Sie es an und aktualisieren Sie die Engine dann erneut.",
"Hay un modelo en ejecución que no se inició desde esta aplicación, así que no se cambió nada. Deténlo y vuelve a actualizar el motor.",
"Работает модель, запущенная не из этого приложения, поэтому ничего не изменено. Остановите её и обновите движок снова.",
"Está em execução um modelo que não foi iniciado a partir desta aplicação, por isso nada foi alterado. Pare-o e atualize o motor de novo.",
"이 앱에서 시작하지 않은 모델이 실행 중이어서 아무것도 변경하지 않았습니다. 그 모델을 중지한 다음 엔진을 다시 업데이트하세요.",
"Un modèle qui n'a pas été démarré depuis cette application est en cours d'exécution, rien n'a donc été modifié. Arrêtez-le, puis mettez à nouveau le moteur à jour.")
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"upd{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"upd-{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")
+2 -4
View File
@@ -20,15 +20,13 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const MIN = 45;
const MAX = 75;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found.');
+2 -4
View File
@@ -21,6 +21,7 @@ import { promisify } from 'node:util';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const run = promisify(execFile);
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
@@ -28,10 +29,7 @@ const ROOT = resolve(process.env.STRATA_SHOTS ?? '.shots');
const TMP = join(ROOT, 'motion-frames');
mkdirSync(TMP, { recursive: true });
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
const ffmpeg = [
join(homedir(), 'Library/Caches/ms-playwright/ffmpeg-1011/ffmpeg-mac'),
+2 -4
View File
@@ -20,14 +20,12 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const BUDGET = 1.2;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
+794
View File
@@ -0,0 +1,794 @@
#!/usr/bin/env node
/**
* Drives the built app in a headless Chrome against a real host with a clean temporary profile (no engine, no model), for the 0.2.10
* interface changes (branch f-0291-ui). Each case seeds the chat history it needs into the page's local storage, so the real screens
* draw real data without a model.
*
* npx vite build && node scripts/ui-do-drive.mjs <shots-dir> [case ...]
*
* Never point it at a real install: the host it starts uses a fresh STRATA_HOME in the temp folder and a random port.
* Light and dark, at a normal and a narrow window. Prints "ok" / "FAIL" lines and exits 1 on a failure.
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { startScriptedWorker } from '../tests/helpers/scripted-worker.mjs';
const HOST_DIR = resolve(import.meta.dirname, '..');
const SHOTS = resolve(process.argv[2] ?? 'ui-shots');
const ONLY = process.argv.slice(3);
mkdirSync(SHOTS, { recursive: true });
const { chromium } = await import(pathToFileURL(join(HOST_DIR, 'node_modules/playwright-core/index.mjs')).href);
const CHROME = process.env.CHROME ?? ['C:/Program Files/Google/Chrome/Application/chrome.exe', '/usr/bin/google-chrome'].find(existsSync);
const freePort = () => new Promise(r => { const s = createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let failed = false;
export const check = (ok, label, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); if (!ok) failed = true; };
const home = mkdtempSync(join(tmpdir(), 'strata-ui-do-'));
const ws = join(home, 'workspace'); mkdirSync(ws, { recursive: true });
const port = await freePort();
const env = { ...process.env, STRATA_HOME: home, STRATA_PORT: String(port), STRATA_WORKSPACE: ws, STRATA_REAL_ENGINE: '0', STRATA_COMPONENTS_DIR: join(home, 'components'), STRATA_SETTINGS_FILE: join(home, 'app-settings.json'), LOCALAPPDATA: join(home, 'Local'), APPDATA: join(home, 'Roaming'), XDG_DATA_HOME: join(home, 'xdg-data'), XDG_CONFIG_HOME: join(home, 'xdg-config'), XDG_STATE_HOME: join(home, 'xdg-state') };
delete env.STRATA_HARNESS_DIR;
const child = spawn(process.execPath, [join(HOST_DIR, 'server', 'index.mjs')], { cwd: HOST_DIR, env, stdio: 'ignore', windowsHide: true });
export const base = `http://127.0.0.1:${port}`;
for (let i = 0; i < 100; i++) { try { if ((await fetch(`${base}/api/health`)).ok) break; } catch { /* starting */ } await sleep(200); }
// A saved API connection to a scripted model gives the app a model to talk to, so it stays on the chat screen (no model loaded would send it to Models).
const worker = await startScriptedWorker({ model: 'cloud-model' });
const saved = await (await fetch(`${base}/api/model-providers`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'custom', name: 'Scripted API', baseUrl: worker.url, protocol: 'openai-chat', apiKey: 'sk-ui-do-test-0123456789', models: [{ id: 'cloud-model', name: 'Cloud model', contextMax: 64000 }] }) })).json();
const remoteModel = { connectionId: saved.connections?.[0]?.id, modelId: 'cloud-model' };
check(!!remoteModel.connectionId, 'a scripted API model is connected');
// "Go straight to the app": the first-run screens are not what these cases look at.
await fetch(`${base}/api/setup/step`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ step: 1, dismissed: true, flowVersion: 2 }) });
const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
const iso = (n = 0) => new Date(Date.UTC(2026, 9, 31, 9, 0, n)).toISOString();
export const user = (id, content, n = 0) => ({ id, role: 'user', content, at: iso(n) });
export const reply = (id, content, extra = {}) => ({ id, role: 'assistant', content, at: iso(5), tokens: 120, tps: 40, durationMs: 3000, ...extra });
export const chat = (id, title, messages) => ({ id, title, defaultTitle: false, updatedAt: iso(9), createdAt: iso(0), messages, workspace: ws, tokensIn: 0, tokensOut: 0, preferences: { useTools: true, mode: 'manual', remoteModel } });
/** A page with the given chats already in local storage. `prefs` are merged into the saved preferences. */
export async function open(scheme, width, height, sessions, { hash = '#/chat', prefs = {}, origin = base, before = null, locale = 'en-US', keepSetup = false } = {}) {
const context = await browser.newContext({ viewport: { width, height }, colorScheme: scheme, locale, permissions: ['clipboard-read', 'clipboard-write'] });
const page = await context.newPage();
pages.push(page);
const problems = [];
page.on('pageerror', e => problems.push(e.message));
page.problems = problems;
await page.addInitScript(({ sessions, prefs }) => {
try {
if (sessionStorage.getItem('ui-do-seeded')) return;
sessionStorage.setItem('ui-do-seeded', '1');
// 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 });
if (before) await before(page);
await page.goto(`${origin}/${hash}`);
await page.waitForTimeout(1800);
const skip = page.getByText('Go straight to the app');
if (!keepSetup && await skip.count()) { await skip.click(); await page.waitForTimeout(600); }
return page;
}
export const shot = (page, name) => page.screenshot({ path: join(SHOTS, `${name}.png`) });
export const sizes = { wide: [1280, 860], narrow: [390, 800] };
export const cases = {};
const pages = [];
/* ------------------------------------------------------------------------------------------------------------------- #4725 */
const longReply = Array.from({ length: 70 }, (_, i) => `Paragraph ${i + 1}. This is part of a long answer from the assistant that goes on for many screens before it asks to change a file in the project.`).join('\n\n');
const askTool = (id, state = 'proposed', file = 'notes.txt') => ({ id, name: 'write', args: { file_path: file }, preview: `write ${file}\n\na\nb\nc`, effect: 'Creates a file.', scope: 'write', verb: 'Create a file', target: file, state, ask: { id: 'create-file', params: [file], why: 'replaces-if-exists' }, live: false });
const plainTool = (id, state = 'proposed') => ({ id, name: 'bash', args: {}, preview: 'npm install left-pad', effect: 'Runs a command on your computer.', scope: 'shell', verb: 'Run a command', target: 'npm install left-pad', state });
cases.approval = async () => {
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const sessions = [chat('s1', 'Long reply', [user('u1', 'Please write notes.txt'), reply('a1', longReply, { tools: [askTool('t1')] })])];
const page = await open(scheme, w, h, sessions);
const dock = page.locator('.approvaldock');
await dock.waitFor({ timeout: 15000 });
const view = page.viewportSize();
const inView = async sel => { const b = await page.locator(sel).first().boundingBox(); return !!b && b.y >= 0 && b.y + b.height <= view.height + 1 && b.x >= 0 && b.x + b.width <= view.width + 1; };
check(await page.locator('.askcard').count() === 1, `${tag}: one Approve/Deny card on the page`);
check(await page.getByRole('button', { name: 'Approve' }).count() === 1 && await page.getByRole('button', { name: 'Deny' }).count() === 1, `${tag}: one Approve and one Deny`);
check(await inView('.approvaldock'), `${tag}: the card is inside the window at the end of the reply`);
const scroller = page.locator('.main__body.chat');
const atEnd = () => scroller.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight);
check(await atEnd() < 4, `${tag}: the reply is still scrolled to its end`, String(await atEnd()));
check(await page.locator('.approval-wait').count() === 1, `${tag}: the reply says it waits for an answer`);
await shot(page, `4725-${tag}-end`);
await scroller.evaluate(el => { el.scrollTop = 0; });
await page.waitForTimeout(300);
check(await inView('.approvaldock'), `${tag}: the card stays in the window when the reader is at the top`);
const composerTop = await page.locator('.composer .st-composer').boundingBox();
const dockBox = await dock.boundingBox();
check(dockBox.y + dockBox.height <= composerTop.y, `${tag}: the card sits above the message box`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await shot(page, `4725-${tag}-top`);
await page.getByRole('button', { name: 'Approve' }).click();
await page.waitForTimeout(500);
check(await page.locator('.approvaldock').count() === 0, `${tag}: the card goes away after Approve`);
check(await page.locator('.approval-wait').count() === 0, `${tag}: and so does the waiting line`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
// Two waiting at once, one in each card style; Deny works from the dock.
const sessions = [chat('s1', 'Two waiting', [user('u1', 'Do two things'), reply('a1', longReply.slice(0, 1500), { tools: [askTool('t1'), plainTool('t2')] })])];
const page = await open('light', 1280, 860, sessions);
await page.locator('.approvaldock').waitFor({ timeout: 15000 });
check(await page.locator('.approvaldock > section').count() === 2, 'two waiting: both cards are in the dock');
await shot(page, '4725-two-waiting');
await page.getByRole('button', { name: 'Deny' }).first().click();
await page.waitForTimeout(400);
check(await page.locator('.approvaldock > section').count() === 1, 'two waiting: Deny on the first leaves the second');
await page.context().close();
};
/* ------------------------------------------------------------------------------------------------------------------- #4748 */
const codeReply = 'Here is a command.\n\n```bash\nnpm install left-pad\n```\n\nThat is all.';
cases.copy = async () => {
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const sessions = [chat('s1', 'Copy', [user('u1', 'Show me a command'), reply('a1', codeReply), user('u2', 'Thanks'), reply('a2', 'You are welcome. Second reply.')])];
const page = await open(scheme, w, h, sessions);
await page.locator('.st-msg--assistant').last().waitFor({ timeout: 15000 });
const flag = async loc => loc.evaluate(el => ({ copied: el.hasAttribute('data-copied'), icon: el.querySelector('use')?.getAttribute('href') ?? '' }));
const read = () => page.evaluate(() => navigator.clipboard.readText());
// The last reply's copy button (an icon in the action row).
const copyResponse = page.getByRole('button', { name: 'Copy response' });
await copyResponse.last().click();
await page.mouse.move(2, 2);
await page.waitForTimeout(250);
let f = await flag(copyResponse.last());
check(f.copied && f.icon.endsWith('i-check'), `${tag}: Copy response turns into a check mark`, JSON.stringify(f));
check((await read()) === 'You are welcome. Second reply.', `${tag}: the reply is on the clipboard`);
const row = page.locator('.st-msg--assistant').last().locator('.st-msg__actions');
check(await row.evaluate(el => getComputedStyle(el).opacity) === '1', `${tag}: the action row stays visible while it says so, mouse away`);
check(/Copied/.test(await page.locator('.st-msg--assistant').last().locator('[role=status]').first().innerText()), `${tag}: a screen reader hears Copied`);
await shot(page, `4748-${tag}-reply`);
await page.waitForTimeout(2200);
f = await flag(copyResponse.last());
check(!f.copied && f.icon.endsWith('i-copy'), `${tag}: after about two seconds it is a Copy button again`, JSON.stringify(f));
// The code block.
const code = page.locator('.st-code__head').getByRole('button', { name: 'Copy code' });
await code.click();
await page.waitForTimeout(250);
f = await flag(code);
check(f.copied && f.icon.endsWith('i-check'), `${tag}: the code block's Copy turns into a check mark`, JSON.stringify(f));
check((await read()) === 'npm install left-pad', `${tag}: the code is on the clipboard`);
await shot(page, `4748-${tag}-code`);
await page.waitForTimeout(2200);
// The menu item of an earlier reply: says Copied, then the menu closes.
await page.locator('.st-msg--assistant').first().hover();
await page.locator('.st-msg--assistant').first().getByRole('button', { name: 'More response actions' }).click();
const item = page.getByRole('menuitem', { name: 'Copy response' });
await item.click();
await page.waitForTimeout(250);
check(/Copied/.test(await page.locator('.menu [data-copied]').innerText()), `${tag}: the menu item says Copied`);
check((await read()).startsWith('Here is a command'), `${tag}: the earlier reply is on the clipboard`);
await shot(page, `4748-${tag}-menu`);
await page.waitForTimeout(1300);
check(await page.locator('.menu').count() === 0, `${tag}: and the menu closes by itself`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
// A refused clipboard: nothing says Copied on the button, and the person is told in a toast.
{
const page = await open('light', 1280, 860, [chat('s1', 'Refused', [user('u1', 'Hi'), reply('a1', 'Hello there.')])]);
await page.locator('.st-msg--assistant').last().waitFor({ timeout: 15000 });
await page.evaluate(() => { Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new Error('denied')) }, configurable: true }); document.execCommand = () => false; });
const b = page.getByRole('button', { name: 'Copy response' }).last();
await b.click();
await page.waitForTimeout(400);
check(await b.evaluate(el => !el.hasAttribute('data-copied')), 'refused clipboard: the button does not claim success');
check(/Couldn.t copy/.test(await page.locator('.st-toast').first().innerText()), 'refused clipboard: a toast says it did not work');
await page.context().close();
}
// The Serving screen: the address and the request template.
for (const scheme of ['light', 'dark']) {
const page = await open(scheme, 1280, 860, [chat('s1', 'Serving', [user('u1', 'Hi'), reply('a1', 'Hello there.')])], { hash: '#/serving' });
const c = page.getByRole('button', { name: 'Copy API base URL' });
await c.waitFor({ timeout: 15000 });
await c.click();
await page.waitForTimeout(250);
check(await c.evaluate(el => el.hasAttribute('data-copied') && (el.querySelector('use')?.getAttribute('href') ?? '').endsWith('i-check')), `${scheme}: Serving, Copy API base URL turns into a check mark`);
check(/^http:\/\/127\.0\.0\.1:\d+\/v1$/.test(await page.evaluate(() => navigator.clipboard.readText())), `${scheme}: Serving, the address is on the clipboard`);
await shot(page, `4748-serving-${scheme}`);
await page.getByRole('button', { name: 'Connection guide' }).click();
const t = page.getByRole('button', { name: 'Copy request template' });
await t.click();
await page.waitForTimeout(250);
check(await t.evaluate(el => el.hasAttribute('data-copied')), `${scheme}: Serving, Copy request template turns into a check mark`);
check((await page.evaluate(() => navigator.clipboard.readText())).startsWith('curl http'), `${scheme}: Serving, the template is on the clipboard`);
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4804 */
let tuneHostInfo = null;
async function tuneHost() {
if (tuneHostInfo) return tuneHostInfo;
const tport = await freePort();
const proc = spawn(process.execPath, [join(HOST_DIR, 'tests/fixtures/tune-ui-host.mjs')], { cwd: HOST_DIR, env: { ...process.env, TUNE_UI_PORT: String(tport), TUNE_UPSTREAM: base, TUNE_SHOT_DIR: SHOTS }, stdio: 'ignore', windowsHide: true });
const origin = `http://127.0.0.1:${tport}`;
for (let i = 0; i < 100; i++) { try { if ((await fetch(`${origin}/api/native`)).ok) break; } catch { /* starting */ } await sleep(200); }
tuneHostInfo = { origin, proc, mode: m => fetch(`${origin}/__fixture/status-mode`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: m }) }) };
return tuneHostInfo;
}
cases.tune = async () => {
const t = await tuneHost();
const sessions = [chat('s1', 'Tune', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
const openDialog = async (scheme, w, h, mode, inject = false) => {
await t.mode(mode);
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
if (inject) await page.route(/\/api\/native(\?.*)?$/, async route => {
if (!page.injectJob) return route.continue();
const r = await route.fetch(); const j = await r.json();
j.jobs = [...(j.jobs ?? []), { id: 'job-x', kind: 'download', state: 'running', startedAt: new Date().toISOString(), summary: 'Downloading the model', error: null, result: null, progress: { text: 'Downloading', percent: 12 } }];
return route.fulfill({ response: r, json: j });
});
const tune = page.getByRole('button', { name: 'Tune model' });
await tune.waitFor({ timeout: 20000 });
for (let i = 0; i < 40 && await tune.isDisabled(); i++) await page.waitForTimeout(250);
await tune.click();
await page.getByRole('dialog').waitFor({ timeout: 10000 });
return page;
};
const start = page => page.getByRole('button', { name: 'Start tuning' });
// The sentence must be on screen without scrolling the dialog, beside the button (above it when the window is narrow).
const seen = async (page, w, h) => {
const s = await page.locator('.tune-why').boundingBox(), b = await start(page).boundingBox();
return !!s && !!b && s.y >= 0 && s.y + s.height <= h && s.x >= 0 && s.x + s.width <= w && Math.abs((s.y + s.height / 2) - (b.y + b.height / 2)) < 110;
};
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
// 1. The saved tunings are still being read: greyed out, with the reason, and not "No saved tuning".
let page = await openDialog(scheme, w, h, 'slow');
await page.waitForTimeout(1200);
check(await start(page).isDisabled(), `${tag}: loading: Start tuning is greyed out`);
let why = await page.locator('.tune-why').innerText().catch(() => '');
check(/still checking this model for saved tunings/.test(why), `${tag}: loading: one sentence says why`, why);
check(await page.locator('.tune-why').count() === 1, `${tag}: loading: only one such sentence`);
check(!/No saved tuning for these choices yet/.test(await page.getByRole('dialog').innerText()), `${tag}: loading: it does not claim there is no saved tuning`);
check(await seen(page, w, h), `${tag}: loading: the sentence is on screen next to the button`);
await shot(page, `4804-${tag}-loading`);
await page.context().close();
// 2. Reading them failed.
page = await openDialog(scheme, w, h, 'fail');
await page.waitForTimeout(1200);
check(await start(page).isDisabled(), `${tag}: unreadable: Start tuning is greyed out`);
why = await page.locator('.tune-why').innerText().catch(() => '');
check(/could not read the tuning details/.test(why), `${tag}: unreadable: one sentence says why`, why);
check(await seen(page, w, h), `${tag}: unreadable: the sentence is on screen next to the button`);
check(!/No saved tuning/.test(await page.getByRole('dialog').innerText()), `${tag}: unreadable: it does not claim there is no saved tuning`);
await shot(page, `4804-${tag}-unreadable`);
await page.context().close();
// 3. Another operation is running.
page = await openDialog(scheme, w, h, 'ok', true);
await page.waitForTimeout(800);
check(await start(page).isEnabled() && await page.locator('.tune-why').count() === 0, `${tag}: ready: Start tuning works and shows no sentence`);
page.injectJob = true;
await page.waitForTimeout(3500);
check(await start(page).isDisabled(), `${tag}: another operation: Start tuning is greyed out`);
why = await page.locator('.tune-why').innerText().catch(() => '');
check(/Finish the current operation first/.test(why), `${tag}: another operation: one sentence says why`, why);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
check(await seen(page, w, h), `${tag}: other: the sentence is on screen next to the button`);
await shot(page, `4804-${tag}-other`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4721 */
cases.links = async () => {
const sessions = () => [chat('s1', 'Links', [user('u1', 'Show links'), reply('a1', `Read [the docs](https://example.com/docs) or open [the logo](${base}/icons/sprite.svg).`)])];
for (const scheme of ['light', 'dark']) {
// A browser tab: the browser opens the address in a new tab; a file stays in the side panel.
let page = await open(scheme, 1280, 860, sessions());
await page.getByRole('link', { name: 'the docs' }).waitFor({ timeout: 15000 });
await page.evaluate(() => { window.__opened = []; window.open = (...a) => { window.__opened.push(a); return null; }; });
await page.getByRole('link', { name: 'the docs' }).click();
await page.waitForTimeout(300);
let opened = await page.evaluate(() => window.__opened);
check(JSON.stringify(opened) === JSON.stringify([['https://example.com/docs', '_blank', 'noopener,noreferrer']]), `${scheme}: a browser tab: a plain click on a web address opens it in a new tab`, JSON.stringify(opened));
check(await page.locator('.artifact-panel').count() === 0, `${scheme}: a browser tab: and no side panel opens`);
await page.getByRole('link', { name: 'the logo' }).click();
await page.locator('.artifact-panel').waitFor({ timeout: 8000 });
check((await page.evaluate(() => window.__opened)).length === 1, `${scheme}: a file link keeps the side panel and opens no tab`);
await shot(page, `4721-${scheme}-file`);
await page.context().close();
// The app window: the host hands the address to the default browser; window.open is not used.
const posts = [];
page = await open(scheme, 1280, 860, sessions(), { before: async p => {
await p.route('**/api/app/mode', r => r.fulfill({ json: { mode: 'window', reason: null, canQuit: true, appUpdate: 'unavailable' } }));
await p.route('**/api/artifacts/open', r => { posts.push(r.request().postDataJSON()); return r.fulfill({ json: { ok: true } }); });
} });
await page.getByRole('link', { name: 'the docs' }).waitFor({ timeout: 15000 });
await page.evaluate(() => { window.__opened = []; window.open = (...a) => { window.__opened.push(a); return null; }; });
await page.getByRole('link', { name: 'the docs' }).click();
await page.waitForTimeout(400);
check(JSON.stringify(posts) === JSON.stringify([{ action: 'open', url: 'https://example.com/docs' }]), `${scheme}: the app window: the host is asked to open it in the default browser`, JSON.stringify(posts));
check((await page.evaluate(() => window.__opened)).length === 0 && await page.locator('.artifact-panel').count() === 0, `${scheme}: the app window: nothing else happens`);
check(page.problems.length === 0, `${scheme}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4802 */
cases.progress = async () => {
const t = await tuneHost();
await t.mode('ok');
const sessions = [chat('s1', 'Progress', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
const started = new Date(Date.now() - (3 * 60 + 12) * 1000).toISOString();
await page.route(/\/api\/native(\?.*)?$/, async route => {
const r = await route.fetch(); const j = await r.json();
j.jobs = [...(j.jobs ?? []), { id: 'job-x', kind: 'install', model: 'iq2_xs', state: 'running', startedAt: started, summary: 'Preparing', error: null, result: { install: { step: '6/7', label: 'Preparing the model data for the engine (one time)', parts: [{ label: 'Model files', done_gb: 0.6, total_gb: 0.6, state: 'done' }], download: { file: 'model files', done_gb: 0.6, total_gb: 0.6, remaining_gb: 0, percent: 99 } } }, progress: null }];
return route.fulfill({ response: r, json: j });
});
await page.reload();
const card = page.locator('.native-progress');
await card.waitFor({ timeout: 20000 });
await page.waitForTimeout(1500);
check(await card.locator('.spinner').count() === 1, `${tag}: a spinner shows that it is working`);
const alive = () => card.locator('.native-progress__alive').innerText();
const a1 = await alive();
check(/^Still working for 3m \d+s\. You don't need to do anything\.$/.test(a1), `${tag}: it says how long and that nothing is needed`, a1);
await page.waitForTimeout(2200);
const a2 = await alive();
check(a1 !== a2, `${tag}: the time keeps counting`, `${a1} -> ${a2}`);
check(/The buttons below wait until this finishes/.test(await page.locator('.native-wait-note').innerText()), `${tag}: a sentence says why the buttons are greyed out`);
const border = await card.evaluate(el => getComputedStyle(el).borderTopColor);
const other = await page.locator('.st-card:not(.native-progress)').first().evaluate(el => getComputedStyle(el).borderTopColor);
check(border !== other, `${tag}: the card has its own border colour`, `${border} vs ${other}`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await card.scrollIntoViewIfNeeded();
await shot(page, `4802-${tag}`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4798 */
cases.serving = async () => {
const sessions = [chat('s1', 'Serving', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
// Sharing off: one address, no extra line. Sharing on: the app's own address and the one for other devices, with one sentence.
for (const sharing of [false, true]) {
const page = await open(scheme, w, h, sessions, { hash: '#/serving', before: async p => {
if (!sharing) return;
await p.route(/\/api\/serving$/, async route => {
if (route.request().method() !== 'GET') return route.continue();
const r = await route.fetch(); const j = await r.json();
return route.fulfill({ response: r, json: { ...j, localUrl: 'http://127.0.0.1:8079/v1', baseUrl: 'http://100.76.61.73:8080/v1', sharing: { active: true, url: 'http://100.76.61.73:8080/v1' } } });
});
} });
await page.getByText('API base URL', { exact: false }).first().waitFor({ timeout: 15000 });
await page.waitForTimeout(800);
const text = await page.locator('.serving-overview').innerText();
if (sharing) {
check(/API base URL on this computer/.test(text) && text.includes('http://127.0.0.1:8079/v1'), `${tag}: sharing on: the app's own address is shown`);
check(/Address for other devices/.test(text) && text.includes('http://100.76.61.73:8080/v1'), `${tag}: sharing on: and the address for other devices`);
check(await page.locator('.serving-two-addresses').count() === 1 && /its own port/.test(await page.locator('.serving-two-addresses').innerText()), `${tag}: sharing on: one sentence says why they differ`);
const t = page.getByRole('button', { name: 'Copy local network URL' });
await t.click(); await page.waitForTimeout(250);
check((await page.evaluate(() => navigator.clipboard.readText())) === 'http://100.76.61.73:8080/v1', `${tag}: sharing on: the copy button gives the network address`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: sharing on: no sideways scroll`);
await page.locator('.serving-overview').scrollIntoViewIfNeeded();
await shot(page, `4798-${tag}-sharing`);
} else {
check(!/Address for other devices/.test(text) && await page.locator('.serving-two-addresses').count() === 0, `${tag}: sharing off: one address, no extra line`);
}
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4722 */
cases.width = async () => {
const text = '用中文写一段九十字左右的段落,说明本地模型为什么适合处理私人文件,并且不需要把任何内容上传到网络上去,这样可以让人放心。\n\n- 第一条要点同样写得比较长,用来观察列表项在气泡里到底会排到多宽才换行,看看它是否与上面的段落一样。\n- 第二条要点也一样长,看看两条要点是不是排得整齐,不会比段落更宽也不会更窄。\n\nAnd an English paragraph that is long enough to wrap onto at least two lines so that the measure of the prose can be read off it by the test.\n\n1. A numbered point in English that is also long enough to wrap onto a second line in the same bubble as the paragraph above it.\n2. Another numbered point.\n\n| Name | Value |\n| --- | --- |\n| a long cell that is wide | another long cell that is wide too |\n\n```bash\nnpm install a-very-long-package-name another-very-long-package-name yet-another-long-package-name --save-dev --legacy-peer-deps\n```';
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, [chat('s1', 'Width', [user('u1', 'Write'), reply('a1', text)])]);
await page.locator('.st-bubble ul').first().waitFor({ timeout: 15000 });
const m = await page.evaluate(() => {
const b = document.querySelector('.st-msg--assistant .st-bubble'), r = el => Math.round(el.getBoundingClientRect().width);
return { bubble: r(b), p: r(b.querySelector('p')), ul: r(b.querySelector('ul')), ol: r(b.querySelector('ol')), table: r(b.querySelector('.md-table-wrap')), code: r(b.querySelector('.st-code')) };
});
const note = JSON.stringify(m);
if (w > 700) {
check(Math.abs(m.ul - m.p) <= 2 && Math.abs(m.ol - m.p) <= 2, `${tag}: bullets and numbers have the paragraph's width`, note);
check(m.table > m.p + 20 && m.code > m.p + 20, `${tag}: the table and the code block keep the full width`, note);
} else {
check(Math.abs(m.ul - m.p) <= 2 && m.p <= m.bubble, `${tag}: narrow window: everything fits the column`, note);
}
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await shot(page, `4722-${tag}`);
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #3723 */
cases.firstrun = async () => {
const setupStep = dismissed => fetch(`${base}/api/setup/step`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ step: 1, dismissed, flowVersion: 2 }) });
await setupStep(false);
// A computer set to a language Strata does not have: English.
const page = await open('light', 1280, 860, null, { hash: '#/setup', locale: 'sv-SE', keepSetup: true });
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
check(/English/.test(await page.locator('.onboard-language').innerText()), 'Swedish computer: English');
await page.context().close();
// A first start (nothing saved) on a computer set to Chinese opens in Chinese, with a language choice on the first screen.
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, null, { hash: '#/setup', locale: 'zh-CN', keepSetup: true });
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
await page.waitForTimeout(500);
const pick = page.locator('.onboard-language [role=combobox], .onboard-language button').first();
const shown = await pick.innerText();
check(/中文/.test(shown), `${tag}: a computer set to Chinese starts in Chinese`, shown);
check(/[\u4e00-\u9fff]/.test(await page.locator('.guided-onboarding h1').first().innerText()), `${tag}: the first screen is in Chinese`);
const box = await page.locator('.onboard-language').boundingBox();
check(!!box && box.x >= 0 && box.x + box.width <= w && box.y >= 0, `${tag}: the choice is on screen`, JSON.stringify(box));
const brand = await page.locator('.guided-onboarding .onboard-header .brand').first().boundingBox();
check(!brand || box.x >= brand.x + brand.width - 1 || box.x + box.width <= brand.x + 1 || box.y >= brand.y + brand.height || box.y + box.height <= brand.y, `${tag}: the choice does not cover the logo`, JSON.stringify({ brand, box }));
await shot(page, `3723-${tag}-first-zh`);
// Choosing German changes the screen at once, and it is kept.
await pick.click();
await page.getByRole('option', { name: 'Deutsch' }).click();
await page.waitForTimeout(500);
check(/[A-Za-zäöü]/.test(await page.locator('.guided-onboarding h1').first().innerText()) && !/[\u4e00-\u9fff]/.test(await page.locator('.guided-onboarding h1').first().innerText()), `${tag}: choosing Deutsch changes the first screen`);
await shot(page, `3723-${tag}-first-de`);
await page.reload();
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
check(/Deutsch/.test(await page.locator('.onboard-language').innerText()), `${tag}: the choice is kept after a restart, whatever the computer says`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
await setupStep(true);
};
cases.kv = async () => {
const t = await tuneHost();
await t.mode('ok');
const sessions = [chat('s1', 'KV', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
await page.route(/\/api\/native(\?.*)?$/, async route => {
const r = await route.fetch(); const j = await r.json();
j.jobs = [...(j.jobs ?? []), { id: 'job-f', kind: 'load', model: 'iq2_xs', state: 'error', startedAt: new Date().toISOString(), summary: 'Load failed', error: "server_exited (exit code 1): EngineDied: the engine exited before it was ready | the engine process ended: exit code 1: \nthe engine log's last lines:\n strata generate: expert cache auto: 1.92 GiB free, 2048 MiB reserved -> 0 slots\n strata generate: no VRAM is left for the expert cache: it needs at least 332 slots (737 MiB), about 1000 MiB more than this card has free.", result: null, progress: null }];
return route.fulfill({ response: r, json: j });
});
await page.reload();
const expert = page.getByRole('button', { name: 'Expert mode' });
await expert.waitFor({ timeout: 20000 });
await page.waitForTimeout(1500);
await expert.click();
const sel = page.getByRole('combobox', { name: 'Native KV format' });
if (!await sel.isVisible().catch(() => false)) { await page.waitForTimeout(800); if (!await sel.isVisible().catch(() => false)) await page.getByRole('button', { name: /expert mode/i }).first().click(); }
await sel.waitFor({ timeout: 10000 });
const hint = page.locator('.native-kv-hint');
check(/INT8 is the standard/.test(await hint.innerText()), `${tag}: Context memory: one line says what the chosen format is`);
await sel.click();
check(await page.getByRole('option').count() === 3, `${tag}: Context memory: three options`);
const titles = await page.locator('.select__option-label').evaluateAll(els => els.map(e => e.getAttribute('title')));
check(titles.length === 3 && titles.every(x => x && x.length > 20), `${tag}: Context memory: each option carries its line`, JSON.stringify(titles));
await page.getByRole('option', { name: 'Q4' }).click();
await page.waitForTimeout(300);
check(/Q4 uses about half the memory/.test(await hint.innerText()), `${tag}: Context memory: the line follows the choice`);
await hint.scrollIntoViewIfNeeded();
await shot(page, `3723-${tag}-kv`);
const notice = page.locator('.error-notice');
await notice.first().waitFor({ timeout: 10000 });
const said = await notice.first().innerText();
check(/did not fit in your graphics card.s memory/.test(said) && /Context memory to Q4/.test(said), `${tag}: a start that ran out of graphics memory says so in plain words`, said.replace(/\n+/g, ' | ').slice(0, 200));
await notice.first().scrollIntoViewIfNeeded();
await shot(page, `3723-${tag}-vram`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4749 */
cases.helper = async () => {
const agents = [
{ id: 'child-1', name: 'Check the tests', description: 'All 12 tests pass.', status: 'completed', durationMs: 42000, tokens: 1800, updatedAt: Date.now() },
{ id: 'child-2', name: 'Write the notes', description: 'Could not write notes.txt', status: 'failed', durationMs: 9000, tokens: 400, updatedAt: Date.now() },
];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const sessions = [{ ...chat('s1', 'Helpers', [user('u1', 'Do two things'), reply('a1', 'Both helpers are on it.')]), agents }];
const posts = []; let refuse = false;
const page = await open(scheme, w, h, sessions, { before: async p => {
await p.route('**/api/chat/helper', async r => { posts.push(r.request().postDataJSON()); if (refuse) return r.fulfill({ status: 409, json: { error: 'This helper cannot take a message any more. Ask the assistant in the chat to start a new one.' } }); return r.fulfill({ json: { ok: true, messageId: 'm1' } }); });
} });
await page.getByRole('button', { name: /Subagents/ }).first().click();
const rows = page.locator('.subagent-row');
await rows.first().waitFor({ timeout: 15000 });
// The finished helper: queue.
await rows.nth(0).getByRole('button', { name: 'Message this helper' }).click();
const input = rows.nth(0).getByRole('textbox', { name: 'Message to Check the tests' });
await input.fill('use ISO dates in the file you just wrote');
await shot(page, `4749-${tag}-writing`);
await input.press('Enter');
await rows.nth(0).getByRole('status').waitFor({ timeout: 5000 });
check(JSON.stringify(posts[0]) === JSON.stringify({ sessionId: 's1', helperId: 'child-1', message: 'use ISO dates in the file you just wrote', delivery: 'queue' }), `${tag}: a finished helper gets the message as a new turn (queue)`, JSON.stringify(posts[0]));
check(/Sent to Check the tests\./.test(await rows.nth(0).innerText()) && await input.inputValue() === '', `${tag}: the row says it was sent and the box is empty again`);
// A second helper (a failed one is listed too).
await rows.nth(1).getByRole('button', { name: 'Message this helper' }).click();
await rows.nth(1).getByRole('textbox').fill('also add the date');
await rows.nth(1).getByRole('button', { name: 'Send' }).click();
await rows.nth(1).getByRole('status').waitFor({ timeout: 5000 });
check(posts[1]?.delivery === 'queue' && posts[1]?.helperId === 'child-2', `${tag}: the message goes to the helper that was chosen`, JSON.stringify(posts[1]));
// A refusal says so in plain words.
refuse = true;
await rows.nth(0).getByRole('textbox').fill('one more thing');
await rows.nth(0).getByRole('button', { name: 'Send' }).click();
await rows.nth(0).getByRole('alert').waitFor({ timeout: 5000 });
check(/cannot take a message any more/.test(await rows.nth(0).getByRole('alert').innerText()), `${tag}: a refused message says why in plain words`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await shot(page, `4749-${tag}-sent`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4783 */
cases.busy = async () => {
const fresh = (scheme, w, h, whileBusy) => open(scheme, w, h, [chat('s1', 'Busy', [])], { prefs: whileBusy ? { whileBusy } : {} });
const send = async (page, text) => { await page.locator('textarea').first().fill(text); await page.keyboard.press('Enter'); };
const lastReply = page => page.locator('.st-msg--assistant .st-bubble').last();
for (const scheme of ['light', 'dark']) {
const w = 1280, h = 860, tag = scheme;
// Wait (the default): Enter while it answers sends nothing, the message stays in the box, and a note says why.
let page = await fresh(scheme, w, h, null);
await send(page, 'SCRIPT:slow 80');
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
await page.locator('textarea').first().fill('my second message');
await page.keyboard.press('Enter');
await page.waitForTimeout(600);
check(await page.locator('textarea').first().inputValue() === 'my second message', `${tag}: Wait: the message stays in the box`);
check(await page.locator('.parked').count() === 0, `${tag}: Wait: nothing is parked`);
await page.getByText('The assistant is still answering').first().waitFor({ timeout: 4000 }).catch(() => {});
check(await page.getByText('The assistant is still answering').count() > 0, `${tag}: Wait: a note says the assistant is still answering`);
await page.context().close();
// Send after: it waits (shown), and goes when the answer is done, after the whole first answer.
page = await fresh(scheme, w, h, 'queue');
await send(page, 'SCRIPT:slow 80');
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
await page.locator('textarea').first().fill('SCRIPT:echo the second answer');
check(/Enter sends it when the answer is done/.test(await page.locator('.composer__busy-hint').innerText()), `${tag}: Send after: the hint says what Enter will do`);
await page.keyboard.press('Enter');
await page.locator('.parked').waitFor({ timeout: 3000 });
check(/Waiting to send/.test(await page.locator('.parked').innerText()) && /second answer/.test(await page.locator('.parked').innerText()), `${tag}: Send after: the waiting message is shown`);
if (scheme === 'light') await shot(page, `4783-${tag}-waiting`);
await page.locator('.st-msg--assistant').nth(1).waitFor({ timeout: 30000 });
await page.waitForFunction(() => document.querySelectorAll('.parked').length === 0, null, { timeout: 15000 });
const firstText = await page.locator('.st-msg--assistant .st-bubble').first().innerText();
check(/w79/.test(firstText), `${tag}: Send after: the first answer was written to its end`, firstText.slice(-30));
check(/the second answer/.test(await lastReply(page).innerText()), `${tag}: Send after: then the second message was answered`);
await page.context().close();
// Interrupt: the answer stops where it is, what is written stays, and the message goes at once.
page = await fresh(scheme, w, h, 'interject');
await send(page, 'SCRIPT:slow 600');
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
await page.waitForTimeout(800);
await page.locator('textarea').first().fill('SCRIPT:echo interrupted and answered');
check(/Enter stops the answer here and sends it now/.test(await page.locator('.composer__busy-hint').innerText()), `${tag}: Interrupt: the hint says what Enter will do`);
await page.keyboard.press('Enter');
const t0 = Date.now();
await page.locator('.st-msg--assistant').nth(1).waitFor({ timeout: 30000 });
check(Date.now() - t0 < 12000, `${tag}: Interrupt: the new message was answered long before the first answer would have ended`, `${Date.now() - t0} ms`);
const cut = await page.locator('.st-msg--assistant .st-bubble').first().innerText();
check(cut.includes('w0') && !cut.includes('w599'), `${tag}: Interrupt: the first answer stopped where it was and stays`, `${cut.length} characters`);
await page.waitForFunction(() => /interrupted and answered/.test([...document.querySelectorAll('.st-msg--assistant .st-bubble')].at(-1)?.innerText ?? ''), null, { timeout: 15000 }).catch(() => {});
check(/interrupted and answered/.test(await lastReply(page).innerText()), `${tag}: Interrupt: the second message was answered`);
if (scheme === 'light') await shot(page, `4783-${tag}-interrupted`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4785 */
cases.quote = async () => {
const long = 'First point: local models keep your files on your own computer. Second point: nothing has to be uploaded to a service. Third point: you can work without a network connection.';
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, [chat('s1', 'Quote', [user('u1', 'Why run a model locally?'), reply('a1', long), user('u2', 'Thanks, one more question'), reply('a2', 'Ask away.')])]);
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
// A whole turn, from the message menu.
await page.locator('.st-msg--assistant').first().hover();
await page.locator('.st-msg--assistant').first().getByRole('button', { name: 'More response actions' }).click();
await page.getByRole('menuitem', { name: 'Quote' }).click();
await page.locator('.quote').first().waitFor({ timeout: 5000 });
check(/Quoting the assistant/.test(await page.locator('.quote').first().innerText()) && /First point/.test(await page.locator('.quote').first().innerText()), `${tag}: Quote in the message menu puts the turn above the box`);
// Just a passage: select the second sentence and quote it.
await page.evaluate(() => {
const p = document.querySelector('.st-msg--assistant .st-bubble p');
const node = p.firstChild; const at = node.textContent.indexOf('Second point'); const end = node.textContent.indexOf('Third point');
const r = document.createRange(); r.setStart(node, at); r.setEnd(node, end);
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
});
await page.locator('.quote-selection').waitFor({ timeout: 5000 });
const sbox = await page.locator('.quote-selection').boundingBox();
check(!!sbox && sbox.x >= 0 && sbox.y >= 0 && sbox.x + sbox.width <= w, `${tag}: a Quote button appears by the selected words, on screen`, JSON.stringify(sbox));
await page.locator('.quote-selection').click();
check(await page.locator('.quote').count() === 2 && /^Second point: nothing has to be uploaded to a service\.$/.test((await page.locator('.quote .quote__text').nth(1).innerText()).trim()), `${tag}: the second quote is just the selected passage, listed after the first`);
check(await page.locator('.quote-selection').count() === 0, `${tag}: the button goes away`);
await shot(page, `4785-${tag}-quotes`);
// Remove one, quote it again, jump to the message.
await page.locator('.quote').first().getByRole('button', { name: 'Remove this quote' }).click();
check(await page.locator('.quote').count() === 1 && /Second point/.test(await page.locator('.quote').first().innerText()), `${tag}: one quote can be removed without touching the others`);
await page.locator('.quote__body').first().click();
await page.waitForTimeout(400);
// Send an answer: the model gets the fixed form, the transcript shows the quote apart from the reply.
await page.locator('textarea').first().fill('SCRIPT:echo thanks for the quote');
await page.keyboard.press('Enter');
await page.locator('.sent-quote').first().waitFor({ timeout: 15000 });
await page.waitForTimeout(300);
const sent = await page.locator('.st-msg--user').last().innerText();
check(/Second point: nothing has to be uploaded/.test(sent) && /SCRIPT:echo thanks for the quote/.test(sent) && !/Quoting:/.test(sent) && !/「/.test(sent), `${tag}: the sent message shows the quote apart from the reply, without the markup`, sent.replace(/\n+/g, ' | ').slice(0, 160));
const got = worker.requests.at(-1)?.raw ?? '';
check(got.includes('Quoting:\\n「Second point: nothing has to be uploaded to a service.」\\n→ Reply: SCRIPT:echo thanks for the quote'), `${tag}: the model receives the quote in its fixed form`, got.slice(-220));
check(await page.locator('.quote').count() === 0, `${tag}: the quotes are cleared after sending`);
await page.locator('.sent-quote').first().click();
check(await page.locator('.sent-quotes[data-open]').count() === 1, `${tag}: a click opens the rest of a sent quote`);
await shot(page, `4785-${tag}-sent`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4244 */
cases.ask = async () => {
const t = await tuneHost();
await t.mode('ok');
const asked = () => fetch(`${t.origin}/__fixture/asked`).then(r => r.json());
await fetch(`${t.origin}/__fixture/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ on: true }) });
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const before = (await asked()).length;
const page = await open(scheme, w, h, [chat('s1', 'Ask', [])], { origin: t.origin });
await page.locator('textarea').first().fill('Build me a page');
await page.keyboard.press('Enter');
const card = page.locator('.askq');
await card.waitFor({ timeout: 15000 });
const text = await card.innerText();
check(/Which colour should the page be\?/.test(text) && /Which extras do you want\?/.test(text) && /Warm and strong/.test(text) && /It sets the whole theme/.test(text), `${tag}: the question, its detail and the option descriptions are shown`);
check(await page.locator('.askcard').count() === 1 && await page.locator('.approvaldock').count() === 1, `${tag}: it is one card in the dock above the box`);
const send = page.getByRole('button', { name: 'Send answer' });
check(await send.isDisabled(), `${tag}: Send answer waits until every question has an answer`);
await page.getByRole('radio', { name: /Red/ }).click();
await page.getByRole('radio', { name: /Blue/ }).click();
check(await page.getByRole('radio', { name: /Red/ }).getAttribute('aria-checked') === 'false' && await page.getByRole('radio', { name: /Blue/ }).getAttribute('aria-checked') === 'true', `${tag}: a single-select question takes one choice`);
await page.getByRole('checkbox', { name: 'Dark mode' }).click();
await page.getByRole('checkbox', { name: 'Animations' }).click();
check(await send.isEnabled(), `${tag}: Send answer works once every question has an answer`);
await page.waitForTimeout(300);
const sb = await send.boundingBox();
const top = await page.evaluate(({ x, y }) => { const e = document.elementFromPoint(x, y); return e?.closest('button')?.textContent ?? ''; }, { x: sb.x + sb.width / 2, y: sb.y + sb.height / 2 });
check(sb.y >= 0 && sb.y + sb.height <= h && /Send answer/.test(top), `${tag}: the answer buttons are in view and reachable without scrolling the card`, JSON.stringify(sb));
await shot(page, `4244-${tag}`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
const box = await page.locator('.approvaldock').boundingBox();
check(!!box && box.y >= 0 && box.y + box.height <= h + 1, `${tag}: the dock with the card is inside the window`, JSON.stringify(box));
await send.click();
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
const got = (await asked()).slice(before);
check(JSON.stringify(got.at(-1)) === JSON.stringify({ sessionId: 's1', callId: 'call-ask-1', answers: [{ id: 'q1', selected: ['Blue'] }, { id: 'q2', selected: ['Dark mode', 'Animations'] }] }), `${tag}: the answer goes to the host as chosen`, JSON.stringify(got.at(-1)));
check(await page.locator('.askq').count() === 0, `${tag}: the card goes away, and the turn ends`);
// The person's own words, and a skip.
await page.locator('textarea').first().fill('Again');
await page.keyboard.press('Enter');
await card.waitFor({ timeout: 15000 });
await page.getByRole('textbox', { name: 'Type your own answer' }).first().fill('Green, like the logo');
await page.getByRole('checkbox', { name: 'Animations' }).click();
await send.click();
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
const own = (await asked()).slice(before).at(-1);
check(JSON.stringify(own?.answers) === JSON.stringify([{ id: 'q1', selected: [], custom: 'Green, like the logo' }, { id: 'q2', selected: ['Animations'] }]), `${tag}: the person's own words are sent as such`, JSON.stringify(own));
await page.locator('textarea').first().fill('Once more');
await page.keyboard.press('Enter');
await card.waitFor({ timeout: 15000 });
await page.getByRole('button', { name: 'Skip' }).click();
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
check((await asked()).slice(before).at(-1)?.skip === true, `${tag}: Skip is sent as a skip`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
// A /loop runs without anyone there: the question is skipped at once, no card, and the loop is not left waiting.
{
const before = (await asked()).length;
const page = await open('light', 1280, 860, [chat('s1', 'Loop', [])], { origin: t.origin });
await page.locator('textarea').first().fill('/loop 10m Build me a page stop after 1 answer');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => (await (await fetch('/__fixture/asked')).json()).length > 0, null, { timeout: 15000 }).catch(() => {});
const got = (await asked()).slice(before).at(-1);
check(got?.skip === true && got?.callId === 'call-ask-1', 'a loop: the question is skipped at once', JSON.stringify(got));
check(await page.locator('.askq').count() === 0, 'a loop: no question card is shown');
await page.context().close();
}
await fetch(`${t.origin}/__fixture/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ on: false }) });
};
/* ------------------------------------------------------------------------------------------------------------------- #3818 (a probe, no fix) */
cases.select = async () => {
const paras = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. This is a sentence of a long answer that goes on for a while so that the reply overflows the window.`).join('\n\n');
const pick = async (page, fromText, toText) => {
const a = await page.getByText(fromText, { exact: false }).first().boundingBox(), b = await page.getByText(toText, { exact: false }).first().boundingBox();
await page.mouse.move(a.x + 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + b.width - 4, b.y + b.height / 2, { steps: 12 });
await page.mouse.up();
return page.evaluate(() => window.getSelection().toString());
};
const page = await open('light', 1280, 860, [chat('s1', 'Select', [user('u1', 'Write a lot'), reply('a1', paras)])]);
await page.locator('.st-bubble p').first().waitFor({ timeout: 15000 });
await page.locator('.main__body.chat').evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.waitForTimeout(400);
// After the reply has finished: a drag across several paragraphs.
const visible = await page.evaluate(() => [...document.querySelectorAll('.st-bubble p')].filter(p => { const r = p.getBoundingClientRect(); return r.top > 100 && r.bottom < innerHeight - 220; }).map(p => p.textContent.slice(0, 12)));
const first = visible[0], last = visible.at(-1);
const got = await pick(page, first, last);
check(got.includes(first) && got.includes(last) && got.split('\n').filter(Boolean).length >= visible.length, `finished reply: a drag across ${visible.length} paragraphs selects all of them`, `${got.length} characters`);
await page.context().close();
// While a reply streams: select a passage of the older reply, and see whether the selection and the view stay.
const page2 = await open('light', 1280, 860, [chat('s1', 'Select', [user('u1', 'Write a lot'), reply('a1', paras)])]);
await page2.locator('.st-bubble p').first().waitFor({ timeout: 15000 });
await page2.locator('textarea').first().fill('SCRIPT:slow 400');
await page2.keyboard.press('Enter');
await page2.waitForTimeout(1500);
const v2 = await page2.evaluate(() => [...document.querySelector('.st-msg--assistant').querySelectorAll('.st-bubble p')].filter(p => { const r = p.getBoundingClientRect(); return r.top > 100 && r.bottom < innerHeight - 220; }).map(p => p.textContent.slice(0, 12)));
if (v2.length >= 2) {
// The selection is made in code here (a drag moves with the layout and made this probe flaky): words of the older reply, as a drag would select them.
const sel0 = await page2.evaluate(([a, b]) => {
const ps = [...document.querySelector('.st-msg--assistant').querySelectorAll('.st-bubble p')], from = ps.find(p => p.textContent.startsWith(a)), to = ps.find(p => p.textContent.startsWith(b));
const r = document.createRange(); r.setStart(from.firstChild, 0); r.setEnd(to.lastChild, to.lastChild.textContent.length);
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); return s.toString();
}, [v2[0], v2.at(-1)]);
const top0 = await page2.locator('.main__body.chat').evaluate(el => el.scrollTop);
await page2.waitForTimeout(3000);
const sel1 = await page2.evaluate(() => window.getSelection().toString());
const top1 = await page2.locator('.main__body.chat').evaluate(el => el.scrollTop);
console.log(`INFO streaming: selection ${sel0.length} -> ${sel1.length} characters, scrollTop ${Math.round(top0)} -> ${Math.round(top1)}`);
console.log(`INFO before: ${JSON.stringify(sel0.slice(0, 40))} ... ${JSON.stringify(sel0.slice(-40))}
INFO after: ${JSON.stringify(sel1.slice(0, 40))} ... ${JSON.stringify(sel1.slice(-40))}`);
check(sel1 === sel0, 'streaming: a selection in the older reply stays as it was while the new reply is written (a selection inside the paragraph that is being written does not: React replaces its text node on every token)', `${sel0.length} -> ${sel1.length}`);
check(Math.abs(top1 - top0) < 40, 'streaming: the view is not pulled away from the selection', `${Math.round(top0)} -> ${Math.round(top1)}`);
} else console.log('INFO streaming: the older reply was not on screen, nothing to select');
await page2.context().close();
};
/* ------------------------------------------------------------------------------------------------------------------ run */
let ran = 0;
for (const [name, fn] of Object.entries(cases)) {
if (ONLY.length && !ONLY.includes(name)) continue;
console.log(`--- ${name}`);
ran += 1;
try { await fn(); } catch (e) {
check(false, `${name} threw`, String(e?.stack ?? e).split('\n').slice(0, 4).join(' | '));
const last = pages.at(-1);
if (last && !last.isClosed()) {
await last.screenshot({ path: join(SHOTS, `debug-${name}.png`) }).catch(() => {});
console.log('PAGE TEXT:', ((await last.evaluate(() => document.body.innerText).catch(() => '')) || '').slice(0, 600).replace(/\n+/g, ' | '));
console.log('PAGE ERRORS:', last.problems?.join('; '));
}
}
}
check(ran > 0, 'at least one case ran');
await browser.close();
await worker.close();
for (const proc of [child, tuneHostInfo?.proc]) if (proc) { if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true }); else proc.kill('SIGTERM'); }
try { rmSync(home, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ }
process.exit(failed ? 1 : 0);
+1 -1
View File
@@ -101,7 +101,7 @@ records the patch identity, the dependency version and the before/after SHA-256.
Source and already-installed Harness copies are not changed.
- A local route is reasoning-capable (`reasoning: true`) with `compat.thinkingFormat: 'chat-template'`: the request carries `chat_template_kwargs {enable_thinking, reasoning_effort}`, which the Strata server reads (a top-level `enable_thinking` is ignored by it, which is why pi-ai's `qwen` format left the model thinking). The chat's Reasoning menu travels as `thinking` in the stream body; the bridge sends it with `session/selectModel` (`reasoningEffort`) whenever the model or the level changed; a level a route does not have is dropped (never an error). API routes keep the provider default (the menu is disabled for them in the UI).
- `localOutputBudget(context)` is 30 percent of the window, at least 4096 and at most 32768 (9,830 tokens at 32K, 19,660 at 64K). The Harness's automatic compaction reads it as the room a reply reserves. pi-ai sends the window minus the prompt. App-loaded models get `fit_max_tokens: true` so the engine clamps instead of answering 400. Sampling is not set here: the Harness sends none, so the server's own defaults apply.
- `localOutputBudget(context)` is 30 percent of the window, at least 4096 and at most 32768 (9,830 tokens at 32K, 19,660 at 64K). The Harness's automatic compaction reads it as the room a reply reserves (the request header), and it is NOT the ceiling of a request: `plugins/strata-context.mjs` raises what the adapter sends to half the window, at most 64K tokens (`reply-cut.mjs` `replyCap`; pi-ai clamps it to the window minus the prompt), because a thinking model that writes a whole file in one call was cut inside the call at 19,660 and lost it (AGENT-LOST). A reply that is cut anyway is announced in the chat, the agent is asked to go on in smaller pieces (twice at most), and the file being written is streamed to the page as `agent-tool-draft` frames (name, size, lines, tail of the text). App-loaded models get `fit_max_tokens: true` so the engine clamps instead of answering 400. Sampling is not set here: the Harness sends none, so the server's own defaults apply.
## Tasks
+150 -10
View File
@@ -26,10 +26,15 @@ 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';
import { cutNotice, cutNudge } from './reply-cut.mjs';
/** How often one turn asks the agent to carry on after a reply the length limit cut (each time in smaller pieces). */
export const MAX_CUT_RETRIES = 2;
import { buildAnswer, parseQuestions } from './user-questions.mjs';
import { BASE_TOKENS, COUNT_ABOVE_CHARS, FIT_TEXT, countTokens, decideFit, fill, grouped } from './context-fit.mjs';
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, stepSummary, planAsPrompt, editedSteps, planView, researchPrompt, researchWritePrompt, RESEARCH_NUDGE, parseSources } from './plan.mjs';
@@ -122,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
@@ -157,6 +164,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const queues = new Map();
const queueView = chatId => (queues.get(chatId) ?? []).map(({ id, text, at, deliverAt }) => ({ id, text, at, deliverAt }));
const pending = new Map(); // `${sessionId}:${callId}` -> { eventId, sessionId, callId, at }
const questions = new Map(); // `${sessionId}:${callId}` -> the agent's own question waiting for the person: { eventId, sessionId, callId, items, at, clientId } (#4244)
const chatOfSession = sessionId => Object.keys(sessionMap).find(id => sessionMap[id]?.sessionId === sessionId) ?? null;
const idleWaiters = new Set();
const chat = id => { let c = chats.get(id); if (!c) { c = { id, turn: null, route: null, routeGeneration: -1 }; chats.set(id, c); } return c; };
@@ -275,10 +284,23 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
if (!r.callId) return;
pending.set(`${value.agentId}:${r.callId}`, { eventId: value.eventId, sessionId: value.agentId, callId: r.callId, tool: r.toolName, reason: r.reason ?? null, at: Date.now(), clientId: runtime.clientId });
} else if (value.type === 'waterfall' && value.event === 'user-questions/request') {
// The app has no surface for the agent's own questions yet. Declaring that nobody can answer makes
// the tool return an error the model reads and carries on from, instead of a turn that waits forever.
void runtime.gw.answerEvent({ clientId: runtime.clientId, eventId: value.eventId, outcome: 'next' }).catch(() => {});
// The agent of a chat that is running in front of the person asks the person: the question goes to the page (the question card above the message box) and the
// Harness waits for the answer. Anything else (a helper's question, a chat that is stopping, a request with nothing to show) is declared
// unanswerable, which makes the tool return an error the model reads and carries on from, instead of a turn that waits forever.
const r = value.request ?? {};
const chatId = chatOfSession(value.agentId);
const turn = chatId ? chats.get(chatId)?.turn : null;
const items = parseQuestions(r.questions);
// A goal or a plan step runs without anyone there: a question would stall it, so it is declined at once (the model reads "could not be answered" and goes on).
if (!turn || turn.finished || turn.stopping || turn.goalRun || turn.plan || !turn.announceQuestion || !items.length) {
void runtime.gw.answerEvent({ clientId: runtime.clientId, eventId: value.eventId, outcome: 'next' }).catch(() => {});
} else {
const callId = String(r.wait?.callId ?? value.eventId);
questions.set(`${value.agentId}:${callId}`, { eventId: value.eventId, sessionId: value.agentId, callId, items, at: Date.now(), clientId: runtime.clientId });
turn.announceQuestion({ type: 'agent-question', callId, questions: items });
}
} else if (value.type === 'cancel') {
for (const [k, v] of questions) if (v.eventId === value.eventId) { questions.delete(k); chats.get(chatOfSession(v.sessionId))?.turn?.announceQuestion?.({ type: 'agent-question-end', callId: v.callId }); }
for (const [k, v] of pending) if (v.eventId === value.eventId) pending.delete(k);
}
}
@@ -347,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));
@@ -443,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
@@ -471,6 +510,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
async function finishTurn(failure) {
if (turn.finished) return;
turn.finished = true;
for (const [k, v] of questions) if (v.sessionId === turn.sessionId) questions.delete(k);
clearTimeout(turn.settleTimer);
clearInterval(turn.watchdog);
turn.follow?.close();
@@ -479,12 +519,21 @@ 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 */ }
// The turn guard: a turn the app stopped, or one that ended with no words, always ends with one plain message (a plan card carries it itself).
let noteReason = turn.guard ?? null;
if (!noteReason && guard && !failure && !turn.stopping && !turn.goalRun) {
// A reply the length limit cut ends with a sentence that says so (and what was lost), never with silence (AGENT-LOST).
const cutEnd = !failure && !turn.stopping && projector.ended?.kind === 'max-tokens';
if (cutEnd && !noteReason) {
const note = cutNotice({ ...projector.cut(), retrying: false });
extra.push({ type: 'delta', own: true, text: `
${note.text}`, key: note.key, values: note.values, noteReason: 'cut' });
}
if (!noteReason && !cutEnd && guard && !failure && !turn.stopping && !turn.goalRun) {
noteReason = guard.silent({ ended: projector.ended, wrote: projector.emittedAny === true, acted: projector.tools.size > 0, contextTokens: projector.contextTokens });
}
if (noteReason && !extra.some(f => f.type === 'plan' && f.event === 'proposed')) { const note = guardNote(noteReason); if (note) extra.push(note); }
@@ -500,6 +549,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
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.
@@ -601,6 +652,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
case 'turn/start':
turn.parentOpen = true;
if (turn.research) turn.research.waitingStart = false;
turn.cutWaiting = false;
clearTimeout(turn.settleTimer);
// After Stop, a helper's report would start a new turn that nobody asked for.
if (turn.stopping) void runtime.gw.rpc('session/cancel', { request: { sessionId } }).catch(() => {});
@@ -752,6 +804,24 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const current = await goalGet();
if (turn.finished || turn.parentOpen) return;
if (current && current.phase === 'active' && current.activation === 'armed') { turn.settleTimer = after(700, armSettle); return; }
// A reply the length limit cut (AGENT-LOST): the agent lost what it was writing. Say so in the chat and ask it to carry on in smaller pieces, twice at most.
if (turn.cutWaiting) { turn.settleTimer = after(300, armSettle); return; }
if (projector.ended?.kind === 'max-tokens' && !turn.goalRun && !turn.stopping && (turn.cuts ?? 0) < MAX_CUT_RETRIES) {
const cut = projector.cut();
turn.cuts = (turn.cuts ?? 0) + 1;
const note = cutNotice({ ...cut, retrying: true });
emit({ type: 'delta', own: true, text: `
${note.text}
`, key: note.key, values: note.values, noteReason: 'cut' });
for (const f of projector.clearCut()) emit(f);
turn.cutWaiting = true;
try { await runtime.gw.rpc('session/prompt', { request: { requestId: `cut-${randomUUID()}`, sessionId, mode: 'queue', content: [{ type: 'text', text: cutNudge(cut) }] } }); }
catch { turn.cutWaiting = false; finishTurn(null); return; }
turn.settleTimer = after(500, armSettle);
return;
}
// Research: gather (search and read pages), then write the answer in the fixed shape. A step that did not open pages is sent back once.
if (turn.research && !turn.stopping) {
const r = turn.research;
@@ -794,6 +864,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
turn.announceGoal = announceGoal;
turn.announceQueue = frame => emit(frame);
turn.announceQuestion = frame => emit(frame);
const snapshotTimer = {};
await Promise.race([turn.snapshot.promise, new Promise((_, rej) => { snapshotTimer.t = after(20_000, () => rej(new Error('the session stream did not open'))); })])
@@ -1096,6 +1167,47 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
delete sessionMap[chatId]; persist();
}
// -- the agent's own questions ------------------------------------------------------------------------------
/**
* The person's answer to a question the agent asked (#4244), or a skip. The answer is checked against the questions that were asked, then sent
* to the Harness as the result of the waiting `user-questions/request`; a skip declares the question unanswerable, as before, and the model
* carries on.
*/
async function answerQuestion({ chatId, callId, answers, skip = false }) {
const sessionId = sessionMap[chatId]?.sessionId;
const key = `${sessionId}:${callId}`;
const entry = questions.get(key);
if (!entry || !rt?.hp.running) throw new AgentUnavailable('That question was already answered, or the assistant has gone on.', 'question-gone');
const outcome = skip ? 'next' : buildAnswer(entry.items, answers);
await rt.gw.answerEvent({ clientId: entry.clientId ?? rt.clientId, eventId: entry.eventId, outcome });
questions.delete(key);
return { ok: true, skipped: !!skip };
}
// -- a message for a helper ---------------------------------------------------------------------------------
/**
* The person's own words for one helper (#4749), through the Harness's `subagents/prompt`: it delivers one browser-authored message to a
* continuable child through its live parent. "steer" reaches a working helper at its next step; "queue" gives a helper that has finished
* (or is between steps) a new turn. The Harness refuses a helper that cannot take messages (a one-shot one) and a parent that is not live;
* either comes back as a plain sentence.
*/
async function messageHelper({ chatId, helperId, text, delivery = 'queue' }) {
const clean = String(text ?? '').trim().slice(0, 8000);
if (!clean) throw new AgentUnavailable('Type a few words for the helper.', 'helper-empty');
if (!/^[\w.:@-]{1,160}$/.test(String(helperId ?? ''))) throw new AgentUnavailable('That helper is not known.', 'helper-unknown');
const parent = sessionMap[chatId]?.sessionId;
if (!parent || !rt?.hp.running) throw new AgentUnavailable('The assistant is not running in this chat, so it cannot pass a message on. Send a message in the chat first.', 'helper-idle');
try {
const receipt = await rt.gw.rpc('subagents/prompt', { request: { requestId: `helper-${randomUUID()}`, parentSessionId: parent, childSessionId: String(helperId), mode: 'continuable', delivery: delivery === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: clean }] } });
return { ok: true, messageId: receipt?.messageId ?? null, delivery: delivery === 'steer' ? 'steer' : 'queue' };
} catch (e) {
log(`agent · message for a helper refused: ${String(e?.message ?? e).slice(0, 200)}`);
throw new AgentUnavailable(/continuable|one-shot|not.*(found|live|open)|unavailable|closed|ended/i.test(String(e?.message ?? '')) ? 'This helper cannot take a message any more. Ask the assistant in the chat to start a new one.' : 'The message could not be delivered right now. Try again in a moment.', 'helper-refused');
}
}
// -- messages typed while a goal runs ----------------------------------------------------------------------
/** Queue a message for the running goal. It is delivered between steps, or handed back to the page when the goal ends or pauses. */
@@ -1312,6 +1424,34 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
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;
@@ -1333,7 +1473,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** 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, 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. */
+12
View File
@@ -160,3 +160,15 @@ export function isContextOverflow(error) {
const text = [error.message, error.cause?.message, error.failure?.message, error.failure?.code].filter(Boolean).join(' ');
return /request exceeds the context window|leaves no room to answer|context[_ ]length|context window|too many tokens|maximum context/i.test(text);
}
/** The Harness asks for "concise English engineering prose", so a Chinese or Russian chat came back summarised in English (#4750). One sentence is added to the
* summary request's last message: write it in the language the person used. Paths, commands and identifiers stay exact. Messages are not changed in place. */
export const SUMMARY_LANGUAGE_RULE = 'Write the summary in the language the person used in this conversation (English only when they wrote English). The instruction above is in English; that does not change the language of the summary. Keep file paths, commands, error strings, identifiers and numbers exactly as they appear.';
export function withSummaryLanguage(messages) {
if (!Array.isArray(messages) || !messages.length) return messages;
const last = messages[messages.length - 1];
if (!last || last.role !== 'user') return messages;
const content = typeof last.content === 'string' ? last.content + ' ' + SUMMARY_LANGUAGE_RULE
: Array.isArray(last.content) ? [...last.content, { type: 'text', text: SUMMARY_LANGUAGE_RULE }] : null;
return content === null ? messages : [...messages.slice(0, -1), { ...last, content }];
}
@@ -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;
}
+35 -8
View File
@@ -11,6 +11,7 @@ import { toolUpdate, TokenCount, fmtTokens } from './status.mjs';
import { askFor } from './ask.mjs';
import { FIT_TEXT, fill } from './context-fit.mjs';
import { isContextOverflow } from './context-budget.mjs';
import { ToolDraft, cutCalls } from './reply-cut.mjs';
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
const WRITE = new Set(['write', 'edit']);
@@ -222,7 +223,8 @@ export class TurnProjector {
this.asked = new Map(); // approval id -> callId
this.onStatus = null; // (update) => void: what the assistant is doing now (status.mjs); set by the bridge
this.reasoning = new TokenCount();
this.composing = new Map(); // callId -> { name, head, bytes }: a tool call the model is still writing
this.composing = new Map(); // callId -> ToolDraft: a tool call the model is still writing (still here at a cut reply = it was lost)
this.lastOutput = 0; // tokens the LAST model request produced
this.running = []; // callIds of tools that are running, oldest first
}
@@ -289,19 +291,20 @@ export class TurnProjector {
out.push({ type: 'reasoning', text: c.text });
} else if (c.type === 'tool-call-delta' && c.id) {
// The model is writing a tool call. For a big file this is the long silent part: nothing else arrives until it is finished.
const call = this.composing.get(c.id) ?? { name: c.name ?? '', head: '', bytes: 0 };
const call = this.composing.get(c.id) ?? new ToolDraft(c.id, c.name ?? '');
if (c.name) call.name = c.name;
const piece = String(c.argumentsDelta ?? '');
// Writing a tool call (a whole file, say) is decoding too: its tokens are in the engine's output count, so its time belongs in the speed.
this.#produced(f.attemptId, piece, out);
call.bytes += piece.length;
if (call.head.length < 4000) call.head += piece;
call.add(piece);
this.composing.set(c.id, call);
if (call.name && !NO_CARD.has(call.name)) {
const raw = (/"(?:file_path|path|filePath)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.head) ?? /"url"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.head))?.[1];
let target = raw; try { if (raw !== undefined) target = JSON.parse('"' + raw + '"'); } catch { /* a half-written escape: show it as it is */ }
const target = call.target;
const args = target ? { file_path: target, url: target } : {};
this.#status(toolUpdate('preparing', 'preparing-tool', call.name, args, { bytes: call.bytes }, { args: { tool: call.name, verb: toolVerb(call.name, 'running'), target: target ?? '', bytes: call.bytes } }));
// The live data path of a file being written (AGENT-LOST): the name, a growing size and the tail of the text, at most five times a second.
const now = Date.now();
if (now - call.at >= 200) { call.at = now; out.push(call.frame({ scope: toolScope(call.name), verb: toolVerb(call.name, 'running') })); }
}
} else if (c.type === 'usage' && c.usage) {
this.usageSeen = true;
@@ -310,6 +313,7 @@ export class TurnProjector {
const prompt = (Number(c.usage.inputTokens) || 0) + (Number(c.usage.cacheReadTokens) || 0) + (Number(c.usage.cacheWriteTokens) || 0);
this.tokensIn += prompt;
this.tokensOut += Number(c.usage.outputTokens) || 0;
this.lastOutput = Number(c.usage.outputTokens) || 0;
this.contextTokens = prompt + (Number(c.usage.outputTokens) || 0);
out.push({ type: 'context', contextTokens: this.contextTokens, contextMax: this.window || undefined });
}
@@ -333,7 +337,9 @@ export class TurnProjector {
const args = parseArguments(d.arguments);
const card = { ...presentTool({ callId: d.callId, name: d.name, args }), state: 'running' };
this.tools.set(d.callId, { card, calledAt: e.time });
const draft = this.composing.get(d.callId);
this.composing.delete(d.callId);
if (draft && draft.bytes > 0 && !NO_CARD.has(d.name)) out.push(draft.frame({ done: true }));
this.running.push(d.callId);
this.#status(toolUpdate('tool', 'tool', d.name, args, {}, { args: { tool: d.name, verb: card.verb, target: card.target } }));
out.push({ type: 'agent-tool', tool: card });
@@ -419,8 +425,29 @@ export class TurnProjector {
return this.#card(callId, { state: 'denied', result: outcome === 'rejected' ? 'Denied. Nothing ran.' : outcome === 'cancelled' ? 'Cancelled. Nothing ran.' : 'No one could answer this request, so nothing ran.' });
}
/** A reply the model could not finish (the turn ended on the length limit): what it was writing, so nothing is lost without a word. */
cut() {
return { tokens: this.lastOutput, calls: cutCalls(this.composing.values()) };
}
/** The agent was asked to carry on: the calls it lost are not pending any more. */
clearCut() { const frames = this.#dropDrafts(); this.ended = null; return frames; }
/** Frames that take the writing cards of calls that will never be whole off the page (the reply was cut, or the turn ended). */
#dropDrafts() {
const frames = [...this.composing.values()].filter(d => d.bytes > 0 && !NO_CARD.has(d.name)).map(d => d.frame({ done: true, lost: true }));
this.composing.clear();
return frames;
}
/** The terminal frames for a finished turn. */
finish({ model, contextMax, engine = null } = {}) {
finish(options = {}) {
const cutInfo = this.ended?.kind === 'max-tokens' ? this.cut() : null;
const dropped = this.#dropDrafts();
return [...dropped, ...this.#finished(options, cutInfo)];
}
#finished({ model, contextMax, engine = null } = {}, cutInfo = null) {
const reason = this.ended ?? { kind: 'interrupted' };
const durationMs = Date.now() - this.startedAt;
const usage = this.usageSeen
@@ -441,7 +468,7 @@ export class TurnProjector {
case 'completed':
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
case 'max-tokens':
return [{ type: 'done', ...base, ...usage, finishReason: 'length' }];
return [{ type: 'done', ...base, ...usage, finishReason: 'length', cut: cutInfo }];
case 'aborted':
return [{ type: 'done', ...base, ...usage, finishReason: 'aborted', cancelled: true }];
case 'error':
@@ -16,9 +16,13 @@
* request the Harness marks `purpose: 'compaction'` is touched in `llm.stream`. Nothing in the Harness is rewritten: a Harness update
* that renames either leaves compaction as it was, and the test that runs the real Harness fails.
*/
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK } from '../context-budget.mjs';
import { replyCap } from '../reply-cut.mjs';
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK, withSummaryLanguage } from '../context-budget.mjs';
export const name = 'strata-context';
/** The routes the host writes for models on this computer (profile.mjs): `strata-local`, `strata-local-<id>`, and their `~s<budget>` copies. */
const isLocalRoute = provider => typeof provider === 'string' && provider.startsWith('strata-local');
export const inject = ['tokenMeter', 'llm'];
/** Real prompt measurements per session: { real, estimated } for the last answer the model server reported. */
@@ -74,6 +78,47 @@ export function apply(ctx) {
};
}
// 3. A reply that has room to finish (AGENT-LOST, 11-01). The output budget in the profile is what the Harness's compaction sets aside for a
// reply (30 percent of the window; it reads it from the request header), and the same number was also the ceiling of every request: a thinking
// model that wrote a 15K-token file after 9K tokens of thinking was cut inside the tool call at 19,660, the unfinished call was dropped and the
// turn ended with no file and no word. The header (and so compaction) stays as it is; the request that goes to the model server may produce up
// to half the window, at most 64K tokens (replyCap), and pi-ai still clamps it to what the window has left after the prompt. The agent loop
// dispatches through the adapter's `streamWithSnapshot`, after the loop has checked the request against its header, so that is where the ceiling
// is raised, once per adapter, the first time a local route is prepared. A Harness that renames it leaves the ceiling as it was (the test that
// runs the real Harness fails).
if (llm && typeof llm.prepareCall === 'function' && !wrapped.has(llm.prepareCall)) {
const original = llm.prepareCall;
const raised = new WeakSet();
const prepare = async function prepareCall(config, signal, configure) {
try { raiseReplyCeiling(config?.provider, raised); } catch (e) { log('warn', `could not raise the reply ceiling: ${e.message}`); }
return original.call(this, config, signal, configure);
};
wrapped.add(prepare);
llm.prepareCall = prepare;
}
function raiseReplyCeiling(provider, raised) {
if (!isLocalRoute(provider)) return;
const adapter = llm.adapters?.get?.(provider)?.adapter;
if (!adapter || raised.has(adapter) || typeof adapter.streamWithSnapshot !== 'function' || typeof adapter.modelOf !== 'function') return;
raised.add(adapter);
const stream = adapter.streamWithSnapshot;
adapter.streamWithSnapshot = function streamWithSnapshot(options, snapshot) {
let next = options;
try {
if (isLocalRoute(options?.provider)) {
const model = this.modelOf(snapshot, options.provider, options.model);
const asked = options.maxTokens ?? model.maxTokens ?? 0;
// Only the agent's own reply budget is raised: a summary or a title asks for far less and keeps what it asked.
if (model.contextWindow > 0 && asked >= Math.floor(model.contextWindow * 0.25)) {
const cap = replyCap(model.contextWindow, asked);
if (cap > asked) next = { ...options, maxTokens: cap };
}
}
} catch { next = options; }
return stream.call(this, next, snapshot);
};
}
// The host asks, with /strata-context, how much of the window the chat holds right now: the same measured number compaction decides on
// (JSON in the command's text: { tokens, window }). Before a message goes out, and after /compact, so the person is told true figures.
ctx.inject(['commands'], (commandCtx) => {
@@ -108,6 +153,7 @@ export function apply(ctx) {
const refusedForSize = finish => finish?.kind === 'error' && isContextOverflow(finish.failure ?? finish);
async function* summaryStream(options, original) {
options = { ...options, messages: withSummaryLanguage(options.messages) }; // #4750: the summary follows the conversation's language
let window = 0;
try { window = (await llm.resolveModelInfo(options.provider, options.model, options.signal))?.context?.contextWindow ?? 0; } catch { window = 0; }
const messages = options.messages ?? [];
+23 -4
View File
@@ -11,8 +11,8 @@
* reminder tools would run behind its back.
* - `web_search` removed (tool-web with `search:false`): every search backend the Harness has needs a service key
* the app does not have, so the tool could only fail. `web_fetch` stays. (`searchProvider` can switch it back on.)
* - `tool-ask-user` removed: the app has no place to show the agent's own questions, so the tool could only fail;
* the agent asks in plain text instead.
* - `tool-ask-user` stays (it is the group "Questions for you" in Settings -> Tools): the app shows the agent's own questions in a card above
* the message box and sends the answer back (#4244). Switched off, the tool is not offered and the agent asks in plain text.
* - the `planning` group (Harness plan mode and `exit_plan_mode`) removed: the app's Plan mode is the permission
* preset (nothing can change); the Harness's plan mode needs an interactive review the app does not have.
* - `tool-bash` / `tool-pwsh`: exactly one, by operating system (the Harness chooses the same at load).
@@ -29,7 +29,7 @@ export const STANDARD_PLUGIN_IDS = [
'tool-ask-user', 'tool-todo', 'tool-web', 'present', 'tool-plugin-manager',
];
/** What we leave out of it. */
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'tool-ask-user', 'planning'];
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'planning'];
/** Plugins removed from inside the `delegation` group (the group stays: helper agents are a feature). */
export const REMOVED_DELEGATION_IDS = [];
@@ -48,6 +48,24 @@ export const AGENT_PROMPT = [
'Tokens prefixed with @ are files or folders the person pointed at. A failed or refused action changed nothing; say so, and do not claim work you did not do.',
].join('\n\n');
/**
* The one sentence that says which computer this is. The Harness's own runtime context is off (see `includeRuntimeContext` below), so without it
* a model on Windows spent its first steps on `ls`, `uname` and `wc` before trying PowerShell commands (and some ran out of steps first).
*/
export const OS_LINES = {
win32: 'This computer runs Windows and the shell tool is PowerShell: uname, wc and grep do not exist here; use Get-ChildItem, Get-Content, Select-String and Measure-Object. There is no need to check which system this is.',
darwin: 'This computer runs macOS and the shell tool is a Unix shell. There is no need to check which system this is.',
linux: 'This computer runs Linux and the shell tool is a Unix shell. There is no need to check which system this is.',
};
/** AGENT_PROMPT with the sentence for `platform` after its first line. */
export function agentPromptFor(platform = process.platform) {
const line = OS_LINES[platform];
if (!line) return AGENT_PROMPT;
const [first, ...rest] = AGENT_PROMPT.split('\n\n');
return [first, line, ...rest].join('\n\n');
}
/**
* @param {{platform?: string, search?: boolean}} [options]
* @returns {object} the `config` of the `preset-standard` row
@@ -55,7 +73,7 @@ export const AGENT_PROMPT = [
export function standardPreset({ platform = process.platform, search = false, off = new Set(), skillsDir = null, compaction = [] } = {}) {
const windows = platform === 'win32';
const plugins = [
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: AGENT_PROMPT, complete: true, includeRuntimeContext: false } },
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: agentPromptFor(platform), complete: true, includeRuntimeContext: false } },
{ id: 'agent-instructions', name: '@deepseek-ai/dsh-agent-instructions', config: { maxBytes: 65536 } },
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
windows
@@ -88,6 +106,7 @@ export function standardPreset({ platform = process.platform, search = false, of
{ id: 'tool-workflow', name: '@deepseek-ai/dsh-tool-workflow' },
],
},
{ id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' },
{ id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo', config: { allowParallelInProgress: true } },
{ id: 'tool-web', name: '@deepseek-ai/dsh-tool-web', config: { fetch: !off.has('fetch'), search: search && !off.has('search'), searchTimeoutMs: 60000 } },
{ id: 'present', name: '@deepseek-ai/dsh-tool-present' },
+123
View File
@@ -0,0 +1,123 @@
/**
* A reply the model could not finish (AGENT-LOST, 11-01): pure helpers, no I/O.
*
* A model reply has a ceiling (the "output budget"). A thinking model that then writes a whole file in one tool call can spend all of it
* before the call is closed. The Harness drops a tool call that was not closed (its arguments are half a JSON text), the turn ends with
* the reason `max-tokens`, and until now the person saw nothing: no file, no card, no sentence. Here:
*
* - `replyCap(window)` how much one reply may produce (the request's ceiling), much larger than the budget the Harness reserves
* for compaction (profile.mjs `localOutputBudget`): the Harness still clamps it to what the window has left.
* - `ToolDraft` what a tool call that is being written looks like so far: its name, the file it is for, a size, a tail of the text.
* - `cutNotice(...)` the plain sentence for the chat, and `cutNudge(...)` the message that lets the agent carry on in smaller pieces.
*/
/** Half the window, at most 64K tokens, and never less than the budget compaction reserves. The Harness clamps it to the room left (window minus prompt). */
export function replyCap(window, reserved = 0) {
const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : 32768;
return Math.max(Math.floor(reserved) || 0, Math.min(Math.floor(w / 2), 65536));
}
const ESCAPES = { n: '\n', t: '\t', r: '', '"': '"', '\\': '\\', '/': '/', b: '', f: '' };
/** A tail of a JSON string body, read back to text: `\n` becomes a newline, `\"` a quote. A half-written escape at the end is dropped. */
export function unescapeJsonTail(raw) {
let text = String(raw ?? '');
const cut = /\\(u[0-9a-fA-F]{0,3})?$/.exec(text);
if (cut) text = text.slice(0, cut.index);
return text.replace(/\\(u[0-9a-fA-F]{4}|[nrtbf"\\/])/g, (_, e) => (e[0] === 'u' ? String.fromCharCode(parseInt(e.slice(1), 16)) : ESCAPES[e] ?? e));
}
const TARGET = /"(?:file_path|path|filePath)"\s*:\s*"((?:[^"\\]|\\.)*)"/;
const URL_ARG = /"url"\s*:\s*"((?:[^"\\]|\\.)*)"/;
/** The long text argument of the common writing tools; the tail of the arguments is previewed after the first one of these. */
const BODY = /"(?:content|new_string|new_str|text|code|body|command|file_text)"\s*:\s*"/;
/** One tool call being written: the model's streamed arguments, kept small. */
export class ToolDraft {
constructor(id, name = '') {
this.id = id; this.name = name;
this.bytes = 0; this.lines = 0;
this.head = ''; // the first 4,000 characters: the body argument usually starts here
this.where = ''; // the file (or address) the call is for, once seen: a model may write it before or after the text
this.carry = ''; // the last 300 characters, so a name split between two pieces is still seen
this.tail = ''; // the last 2,400 characters: what is being written now
this.at = 0; // when a frame was last sent for it
}
add(piece) {
const text = String(piece ?? '');
if (!text) return;
this.bytes += text.length;
for (let i = text.indexOf('\\n'); i >= 0; i = text.indexOf('\\n', i + 2)) this.lines += 1;
if (this.head.length < 4000) this.head += text.slice(0, 4000 - this.head.length);
this.tail = (this.tail + text).slice(-2400);
if (!this.where) {
const seen = this.carry + text;
const raw = (TARGET.exec(seen) ?? URL_ARG.exec(seen))?.[1];
if (raw !== undefined) { try { this.where = JSON.parse(`"${raw}"`); } catch { this.where = unescapeJsonTail(raw); } }
this.carry = seen.slice(-300);
}
}
get target() { return this.where; }
/** The text of the body so far, only its last `max` characters; empty until the body argument has started. */
preview(max = 1600) {
const start = BODY.exec(this.head);
if (!start) return '';
const bodyAt = start.index + start[0].length; // where the body begins, counted in streamed characters
if (bodyAt >= this.bytes) return '';
const tailAt = this.bytes - this.tail.length; // where the kept tail begins
const body = unescapeJsonTail(this.tail.slice(Math.max(0, bodyAt - tailAt)));
return body.length > max ? body.slice(-max) : body;
}
/** The frame for the page. */
frame({ done = false, ...extra } = {}) {
return { type: 'agent-tool-draft', draft: { id: this.id, name: this.name, target: this.target, bytes: this.bytes, lines: this.lines, preview: done ? '' : this.preview(), ...extra, done } };
}
}
const kb = n => (n >= 10_000 ? `${Math.round(n / 1000)} KB` : `${(n / 1000).toFixed(1)} KB`);
/** Calls the model was still writing when its reply was cut: [{ name, target, bytes }]. */
export function cutCalls(drafts) {
return [...drafts].filter(d => d.bytes > 0).map(d => ({ name: d.name, target: d.target, bytes: d.bytes }));
}
const withTokens = tokens => (tokens > 0 ? ` (${Number(tokens).toLocaleString('en-US')} tokens)` : '');
/** The sentence for the person, with the catalog slots it needs: { text, key, values }. `again` is true when the agent was already asked to carry on. */
export function cutNotice({ calls = [], tokens = 0, again = false, retrying = false } = {}) {
const call = calls.find(c => c.target) ?? calls[0] ?? null;
const file = call?.target ? String(call.target).split(/[\\/]/).filter(Boolean).at(-1) : '';
const what = file || (/^(write|edit)$/.test(call?.name ?? '') ? 'a file' : call?.name);
if (call && retrying) {
const key = 'That reply reached its length limit{0} while writing {1} ({2}), so that write did not happen. I asked the assistant to look at what is on disk and write the rest in smaller parts.';
const values = [withTokens(tokens), what, kb(call.bytes)];
return { key, values, text: fill(key, values) };
}
if (call) {
const key = 'The reply reached its length limit{0} while writing {1} ({2}), so that write did not happen. Say “continue” and it will look at what is on disk and go on in smaller parts.';
const values = [withTokens(tokens), what, kb(call.bytes)];
return { key, values, text: fill(key, values) };
}
if (retrying) {
const key = 'That reply reached its length limit{0} and was cut off. I asked the assistant to carry on.';
return { key, values: [withTokens(tokens)], text: fill(key, [withTokens(tokens)]) };
}
const key = 'The reply reached its length limit{0} and was cut off. Say “continue” to go on.';
return { key, values: [withTokens(tokens)], text: fill(key, [withTokens(tokens)]) };
}
export function fill(template, values) { return String(template).replace(/\{(\d+)\}/g, (_, i) => String(values[Number(i)] ?? '')); }
/** The message sent to the assistant so it carries on. It is not shown as the person's message. */
export function cutNudge({ calls = [], tokens = 0 } = {}) {
const call = calls.find(c => c.target) ?? calls[0] ?? null;
if (!call) {
return 'Your last reply was cut off by the output limit before it was finished. Carry on from where it stopped without repeating what you already wrote, and keep your thinking short.';
}
const file = call.target || 'the file';
return `Your last reply was cut off by the output limit${tokens > 0 ? ` (${Number(tokens).toLocaleString('en-US')} tokens)` : ''} while you were writing ${file} in one tool call, so that call did not run and nothing was changed by it. First look at what is on disk now (list the folder, read the end of the file). Do not delete or start over a file that is already complete. If it is not complete, write what is missing in several smaller parts (about 150 lines or fewer each) with edits or appends, and read the end of the file back when you are done. Keep your thinking short.`;
}
+1
View File
@@ -16,6 +16,7 @@ export const TOOL_GROUPS = [
{ id: 'search', area: 'web', name: 'Search the web', about: 'Lets the assistant search the web when it needs fresh information.', asks: true, web: 'search' },
{ id: 'helpers', area: 'together', name: 'Helper assistants', about: 'Lets the assistant start helpers that work on parts of a task at the same time.', asks: false, plugins: ['delegation'] },
{ id: 'goals', area: 'together', name: 'Goals', about: 'Lets you give a goal with /goal. The assistant keeps working until it is done.', asks: false, plugins: ['command-goal', 'tool-goal'] },
{ id: 'questions', area: 'together', name: 'Questions for you', about: 'Lets the assistant stop and ask you a question, with answers to pick from, when it needs your decision.', asks: false, plugins: ['tool-ask-user'] },
{ id: 'todo', area: 'together', name: 'To-do list', about: 'Lets the assistant keep a checklist while it works on a big task.', asks: false, plugins: ['tool-todo'] },
{ id: 'skills', area: 'together', name: 'Skills', about: 'Lets the assistant use your saved skills when they fit the task.', asks: false, plugins: ['skill-filesystem', 'tool-skill'] },
{ id: 'present', area: 'together', name: 'Show results', about: 'Lets the assistant show a page or file in the preview next to the chat.', asks: false, plugins: ['present'] },
+39
View File
@@ -0,0 +1,39 @@
/**
* The agent's own questions (the Harness's `ask_user_question` tool, #4244). The Harness raises the `user-questions/request` event with
* `{ questions: [{ id, question, detail?, header?, options?: [{ label, description? }], multiSelect?, intent? }], wait?: { callId } }` and wants
* `{ answers: [{ id, selected: string[], custom? }] }` back (AskUserQuestionRequestEvent / AskUserQuestionAnswer of the pinned Harness).
* This file only reads the first into something the page can draw, and checks the second before it goes out.
*/
const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
/** The questions of a request, bounded and plain; an empty list when the request carries nothing that can be shown. */
export function parseQuestions(raw) {
const out = [];
for (const q of Array.isArray(raw) ? raw.slice(0, 6) : []) {
const id = text(q?.id, 120), question = text(q?.question, 800);
if (!id || !question) continue;
const options = (Array.isArray(q.options) ? q.options.slice(0, 12) : [])
.map(o => ({ label: text(o?.label, 200), description: text(o?.description, 400) || undefined })).filter(o => o.label);
out.push({ id, question, detail: text(q.detail, 800) || undefined, header: text(q.header, 80) || undefined, options, multiSelect: q.multiSelect === true });
}
return out;
}
/**
* The answer the person gave, checked against the questions that were asked: every question answered, only the offered labels (or the person's own
* words), one choice for a single-select question. Throws a plain Error with a code on the first thing that does not fit.
*/
export function buildAnswer(questions, given) {
const byId = new Map((Array.isArray(given) ? given : []).map(a => [String(a?.id ?? ''), a]));
const answers = [];
for (const q of questions) {
const a = byId.get(q.id);
const labels = new Set(q.options.map(o => o.label));
const selected = (Array.isArray(a?.selected) ? a.selected : []).map(s => String(s)).filter(s => labels.has(s));
const custom = text(a?.custom, 2000);
if (!selected.length && !custom) throw Object.assign(new Error('Answer every question, or skip them.'), { code: 'question-incomplete' });
if (!q.multiSelect && selected.length > 1) throw Object.assign(new Error('Choose one answer for each question.'), { code: 'question-invalid' });
answers.push({ id: q.id, selected: [...new Set(selected)], ...(custom ? { custom } : {}) });
}
return { answers };
}
+10
View File
@@ -7,6 +7,16 @@ const run=(command,args)=>promisify(execFile)(command,args,{env:externalEnvironm
const extensions=new Set(['.md','.txt','.pdf','.html','.htm','.png','.jpg','.jpeg','.webp','.gif','.svg','.csv','.ts','.tsx','.js','.jsx','.py','.json','.css','.rs','.go','.sh','.mp4','.webm']);
export async function openArtifact(body,{examplesDir,previewsDir,platform=process.platform,launch=run}) {
if(!['open','reveal'].includes(body.action))throw new Error('Choose Open or Show in file explorer.');
if(typeof body.url==='string') {
// #4721: a web address goes to the person's default browser (the app window drops window.open: WebView2 has no new-window handler). http and https only, no shell.
if(body.action!=='open')throw new Error('A web address can only be opened.');
let address;try{address=new URL(body.url);}catch{throw new Error('This is not a web address.');}
if(!['http:','https:'].includes(address.protocol)||body.url.length>4096)throw new Error('Only http and https addresses can be opened.');
if(platform==='darwin')await launch('open',[address.href]);
else if(platform==='win32')await launch('rundll32.exe',['url.dll,FileProtocolHandler',address.href]);
else await launch('xdg-open',[address.href]);
return {ok:true};
}
let file;
if(typeof body.path==='string') {
// Only public example assets can be addressed by path. Never accept host paths.
+1 -1
View File
@@ -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,
};
}
+61 -11
View File
@@ -121,10 +121,18 @@ export class ControlError extends Error {
}
}
/** Run one CLI command of Control. Resolves { code, json, text, stderr }; rejects only if it cannot start or times out. */
export function runCli(control, args, { env = process.env, timeoutMs = 60_000, spawnProcess = spawn, components, acquire = false, onLine = null, signal = null } = {}) {
/**
* Small Control programs that are a module of strata_control and not a `strata` command. They run on Control's interpreter with Control's folder first on the
* path (the folder is the working directory), so they work whatever the interpreter does with the script folder.
*/
const BOOT = {
pictures: 'import os,sys;sys.path.insert(0,os.getcwd());from strata_control import pictures;sys.exit(pictures.main(sys.argv[1:]))',
};
/** Run one CLI command of Control (or, with `boot`, one of its modules). Resolves { code, json, text, stderr }; rejects only if it cannot start or times out. */
export function runCli(control, args, { env = process.env, timeoutMs = 60_000, spawnProcess = spawn, components, acquire = false, onLine = null, signal = null, boot = null } = {}) {
return new Promise((resolveRun, reject) => {
const child = spawnProcess(control.python, [control.cli, ...args], {
const child = spawnProcess(control.python, boot ? ['-c', BOOT[boot], ...args] : [control.cli, ...args], {
cwd: control.dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
env: controlEnvironment(control, { env, acquire, ...(components ? { components } : {}) }),
});
@@ -185,9 +193,9 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.ok) return result.json;
throw failure(result, 'The update check failed.');
},
async update({ feed = env.STRATA_UPDATE_FEED ?? null, engine = true, control: withControl = true, appVersion = env.STRATA_APP_VERSION ?? null, force = false, onEvent = null, signal = null } = {}) {
async update({ feed = env.STRATA_UPDATE_FEED ?? null, engine = true, control: withControl = true, appVersion = env.STRATA_APP_VERSION ?? null, force = false, stopModel = false, onEvent = null, signal = null } = {}) {
const args = ['update', '--output', 'jsonl', ...feedArgs(feed), ...(engine ? [] : ['--no-engine']), ...(withControl ? [] : ['--no-control']),
...(appVersion ? ['--app-version', appVersion] : []), ...(force ? ['--force'] : [])];
...(appVersion ? ['--app-version', appVersion] : []), ...(force ? ['--force'] : []), ...(stopModel ? ['--stop-model'] : [])];
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
if (result.json?.ok) return result.json;
const failed = result.json?.results?.find?.(r => r.status === 'failed');
@@ -205,6 +213,24 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.ok) return result.json;
throw failure(result, 'The engine could not be rolled back.');
},
/** The engines installed on this computer, by family (`strata components engines`): the one list the Terminal prints too. Reads files only. */
async engines() {
const result = await call(['components', 'engines', '--output', 'json'], { timeoutMs: 60_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The installed engines could not be read.');
},
/** Switch to an installed engine (`strata components use`): nothing is downloaded. `root` is only for a DeepSeek engine that lives in more than one place. */
async useEngine({ family, folder }) {
const result = await call(['components', 'use', folder, '--family', family, '--output', 'json'], { timeoutMs: 600_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The engine could not be switched.');
},
/** Delete an installed engine (`strata components remove`); Control refuses the one in use. */
async removeEngine({ family, folder }) {
const result = await call(['components', 'remove', folder, '--family', family, '--output', 'json'], { timeoutMs: 600_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The engine could not be deleted.');
},
async engine({ backend = null } = {}) {
const result = await call(['components', 'engine', '--output', 'json', ...(backend ? ['--backend', backend] : [])], { timeoutMs: 30_000 });
if (result.json) return result.json; // exit 5 still carries the report and the notice
@@ -262,21 +288,45 @@ 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, gb, line, note, drafter, split, mtp_needed, mtp_gb }. 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] : []), '--output', 'json'], { timeoutMs: 30_000 });
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null, noSplit = false, unloading = false } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(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] : []), '--if-needed', '--output', 'jsonl'];
async draftingFetch({ configPath, mode = null, noSplit = false, unloading = false, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--if-needed', '--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.
* Resolves { mmproj, vision, fetched, note, data_dir }; a failure is a ControlError whose `code` is offline | disk | failed | checksum | unsupported | no_reader.
* `onEvent` gets {step, done, total}. This is an explicit step that may reach the network (`acquire`), only for the Load the person pressed.
*/
async picturesFetch({ configPath, engineDir, vision = 'yes', dataDir = null, family = null, hardware = null, onEvent = null, signal = null } = {}) {
const args = ['fetch', '--config', configPath, '--engine-dir', engineDir, '--vision', vision, ...(dataDir ? ['--data-dir', dataDir] : []), ...(family ? ['--family', family] : []),
...(hardware?.vram_gb != null ? ['--vram-gb', String(hardware.vram_gb)] : []), ...(hardware?.make ? ['--make', String(hardware.make)] : []), ...(hardware?.arch != null ? ['--arch', String(hardware.arch)] : [])];
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, boot: 'pictures', 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 picture files could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: result.json?.error?.code ?? 'failed' });
},
async saveProfile(name, configPath) {
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
+42
View File
@@ -0,0 +1,42 @@
import { plainEngineError } from './update-messages.mjs';
/**
* Settings > Engine: the engines installed on this computer, switched and deleted through Control (`strata components engines | use | remove`),
* the same code the Terminal runs. This module only asks, and turns Control's answer into plain words; it never touches an engine folder itself.
* Nothing here downloads: a switch uses what is installed.
*/
const FAMILIES = ['qwen', 'deepseek'];
/** { status, body } for GET /api/engines. Without Control (a source checkout) the list is not available and the page says so. */
export async function listEngines(control) {
if (typeof control?.engines !== 'function') return { status: 200, body: { available: false, families: [], total_bytes: 0 } };
try {
const listing = await control.engines();
return { status: 200, body: { available: true, families: listing.families ?? [], total_bytes: listing.total_bytes ?? 0, serving: listing.serving === true } };
} catch (error) {
return { status: 500, body: { error: "The installed engines couldn't be read.", details: String(error?.message ?? error) } };
}
}
/**
* { status, body } for POST /api/engines/use and /api/engines/remove. `modelsRunning()` says whether the app has a model loaded: a switch then
* answers at once with the one next step (stop it first); a delete of an engine nothing uses is allowed, Control refuses the one in use.
*/
export async function changeEngine(control, action, body, { modelsRunning = () => false } = {}) {
if (typeof control?.useEngine !== 'function') return { status: 501, body: { error: 'Engines can be switched and deleted in the installed Strata app.' } };
const family = body?.family, folder = body?.folder;
if (!FAMILIES.includes(family) || typeof folder !== 'string' || !/^[0-9A-Za-z][\w.+-]*$/.test(folder)) {
return { status: 400, body: { error: 'Choose an engine from the list.' } };
}
if (action === 'use' && modelsRunning()) {
return { status: 409, body: { error: 'A model is running, so nothing was changed. Unload the model in Models, then try again.' } };
}
try {
const result = action === 'use' ? await control.useEngine({ family, folder }) : await control.removeEngine({ family, folder });
return { status: 200, body: { ok: true, ...result } };
} catch (error) {
const plain = plainEngineError(error, action === 'use' ? 'switch' : 'delete');
return { status: plain.message.startsWith('A model is running') ? 409 : 400, body: { error: plain.message, details: plain.details } };
}
}
+6 -3
View File
@@ -43,12 +43,15 @@ export function createEngineStep({ control, signal = null, onChange = () => {} }
return { ...part };
}
const version = engine.offered?.version ?? null;
set({ state: 'downloading', version, name: `Engine ${version ?? ''}`.trim(), total_gb: round((engine.download_bytes ?? 0) / GB), message: 'Downloading the engine for this PC…' });
// An engine already works here: say what is about to change and that it can be undone (#4823), not only "downloading".
const before = engine.installed?.version ?? null;
const change = before && version && before !== version ? `Updating the engine from ${before} to ${version}. The engine you have now is kept, and Settings can go back to it.` : null;
set({ state: 'downloading', version, name: `Engine ${version ?? ''}`.trim(), total_gb: round((engine.download_bytes ?? 0) / GB), message: change ?? 'Downloading the engine for this PC…' });
const result = await control.update({
engine: true, control: false, signal,
onEvent: event => {
if (event.event === 'progress') {
set({ state: 'downloading', percent: event.percent ?? 0, done_gb: round((event.done ?? 0) / GB), total_gb: round((event.total ?? 0) / GB), message: `Downloading the engine for this PC: ${event.percent ?? 0}%` });
set({ state: 'downloading', percent: event.percent ?? 0, done_gb: round((event.done ?? 0) / GB), total_gb: round((event.total ?? 0) / GB), message: `${change ? `Updating the engine from ${before} to ${version}` : 'Downloading the engine for this PC'}: ${event.percent ?? 0}%` });
} else if (event.event === 'phase') {
const state = { verify: 'verifying', test: 'testing', switch: 'switching' }[event.phase];
if (state) set({ state, message: PHASE_TEXT[event.phase] ?? part.message });
@@ -58,7 +61,7 @@ export function createEngineStep({ control, signal = null, onChange = () => {} }
},
});
const done = (result.results ?? []).find(r => r.component === 'engine');
set({ state: 'done', percent: 100, done_gb: part.total_gb, version: done?.installed_after?.version ?? version, message: 'The engine for this PC is ready.' });
set({ state: 'done', percent: 100, done_gb: part.total_gb, version: done?.installed_after?.version ?? version, message: change ? `The engine was updated from ${before} to ${version}. Settings can go back to the one you had.` : 'The engine for this PC is ready.' });
} catch (error) {
const plain = plainUpdateError(error, 'engine');
note = `Your model is ready. The engine for this PC couldn't be downloaded. ${plain.next}`;
+9 -2
View File
@@ -25,7 +25,12 @@ export function windowsFolderScript(prompt) {
'$dialog.ShowNewFolderButton = $true',
// READY tells the host the window is about to open; a host that never sees it gives up and asks for the path in words (QA F06).
'[Console]::Out.WriteLine("READY"); [Console]::Out.Flush()',
'if ($dialog.ShowDialog($owner) -eq "OK") { [Console]::Out.Write($dialog.SelectedPath) }',
// The dialog starts at the Desktop folder. Where that folder does not exist (it was deleted, a OneDrive move left a dead path, the profile was
// redirected) ShowDialog throws "Unable to retrieve the root folder" and no folder could be chosen at all: start at This PC instead, and when even
// that fails say NODIALOG so the app asks for the path.
'$answer = $null',
'try { $answer = $dialog.ShowDialog($owner) } catch { try { $dialog.RootFolder = [System.Environment+SpecialFolder]::MyComputer; $answer = $dialog.ShowDialog($owner) } catch { [Console]::Out.WriteLine("NODIALOG"); [Console]::Out.Flush() } }',
'if ($answer -eq "OK") { [Console]::Out.Write($dialog.SelectedPath) }',
'$owner.Close()',
].join('; ');
}
@@ -88,11 +93,13 @@ export async function chooseFolder(prompt, options = {}) {
try {
const done = windowsDialog ? await windowsDialog(shell, windowsFolderArgs(prompt)) : await run(shell, windowsFolderArgs(prompt));
if (done.noDialog) return { unavailable: true, failed: true, reason: 'The folder window did not open. Type the folder path instead.' };
if (/^NODIALOG\s*$/m.test(String(done.stdout ?? ''))) return { unavailable: true, failed: true, reason: 'The folder window could not open. Type the folder path instead.' };
const answer = answerOf(done.stdout).trim();
return answer ? { path: cleanChosenPath(answer) } : { cancelled: true };
} catch (error) {
if (missing(error)) return { unavailable: true, reason: 'Windows PowerShell was not found.' };
throw new Error('The folder chooser could not open.');
// The window opened or tried to and ended with an error: the person is asked for the path instead of being left with an error and no way on.
return { unavailable: true, failed: true, reason: 'The folder window could not open. Type the folder path instead.', detail: `exit ${error?.code ?? '?'}` };
}
}
return chooseLinuxFolder(prompt, { run, portal });
+104 -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';
@@ -74,6 +77,7 @@ import { installBundledEngine, cleanupAfterInstall } from './bundled-engine.mjs'
import { systemLocale } from './region.mjs';
import { createShellNotifier, DEFAULT_PORT, parsePortNotice, readShellPrefs, writeShellPrefs } from './shell-prefs.mjs';
import { findControl, createControl, componentsDirectory } from './control-runtime.mjs';
import { listEngines, changeEngine } from './engine-library.mjs';
import { forbidden as acquisitionForbidden } from './acquisition.mjs';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { nativeGateway } from './native-gateway.mjs';
@@ -117,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. */
@@ -942,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 });
@@ -953,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';
@@ -984,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);
@@ -1010,6 +1062,7 @@ async function api(req, res, path, method, url) {
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
if(path==='/api/native/engine-restart/dismiss'&&method==='POST')return json(res,200,native.dismissRestart());
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
if(path==='/api/native/logs/save'&&method==='POST'){
/* Diagnostics to a file: the engine messages the app kept, failed operations, and the tail of each model's server log under Control.
@@ -1193,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.
@@ -1305,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());
@@ -1354,6 +1404,15 @@ async function api(req, res, path, method, url) {
try { let at = componentsDirectory(); while (at && !existsSync(at) && dirname(at) !== at) at = dirname(at); const f = statfsSync(at); info.freeBytes = Number(f.bavail) * Number(f.bsize); } catch { /* unknown: no warning */ }
return json(res, 200, info);
}
if (path === '/api/engines' && method === 'GET') {
const { status, body } = await listEngines(control);
return json(res, status, body);
}
if ((path === '/api/engines/use' || path === '/api/engines/remove') && method === 'POST') {
const { status, body } = await changeEngine(control, path.endsWith('/use') ? 'use' : 'remove', await readBody(req), { modelsRunning: () => native.state().instances.length > 0 });
if (status === 200) Promise.resolve(native.refresh?.(true)).catch(() => { /* the next poll reads it */ });
return json(res, status, body);
}
if (path === '/api/engine/job' && method === 'GET') {
// The newest engine download / file install, read without waiting for the native snapshot to connect (the first-run card needs it before that).
const kinds = ['update-engine', 'install-engine-file'];
@@ -1603,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') {
@@ -1914,6 +1973,18 @@ async function api(req, res, path, method, url) {
try { return json(res, 200, agent.queueMessage({ chatId: String(body.sessionId ?? ''), text: body.message })); }
catch (e) { return error(res, e.code === 'queue-idle' ? 409 : 400, e.message); }
}
// The person's answer to a question the agent asked, or a skip (#4244).
if (path === '/api/chat/answer' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, await agent.answerQuestion({ chatId: String(body.sessionId ?? ''), callId: String(body.callId ?? ''), answers: body.answers, skip: body.skip === true })); }
catch (e) { return error(res, e.code === 'question-gone' ? 409 : 400, e.message); }
}
// A message for one helper (#4749).
if (path === '/api/chat/helper' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, await agent.messageHelper({ chatId: String(body.sessionId ?? ''), helperId: body.helperId, text: body.message, delivery: body.delivery })); }
catch (e) { return error(res, e.code === 'helper-idle' || e.code === 'helper-refused' ? 409 : 400, e.message); }
}
if (path === '/api/chat/queue/cancel' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, agent.cancelQueued({ chatId: String(body.sessionId ?? ''), id: String(body.id ?? '') })); }
@@ -1966,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);
@@ -1995,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(); });
@@ -2211,7 +2305,7 @@ async function staticFile(req, res, path) {
if (!existsSync(DIST)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(
`<!doctype html><meta charset="utf-8"><title>Strata host</title>
`<!doctype html><meta charset="utf-8"><title>Strata</title>
<body style="font:16px/1.6 -apple-system,system-ui;background:#0e1113;color:#eef1f3;padding:48px">
<h1 style="margin:0 0 8px">Strata host is running</h1>
<p style="color:#8e979f">No built web app found at <code>${DIST}</code>.</p>
+1 -1
View File
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
import { basename, dirname, extname, isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { readGGUF } from './gguf.mjs';
const ARCHITECTURES=new Set(['qwen2','qwen2moe','qwen3','qwen3moe','qwen3next','qwen35','qwen35moe','llama','gemma','gemma2','gemma3','phi3']);
const ARCHITECTURES=new Set(['qwen2','qwen2moe','qwen3','qwen3moe','qwen3next','qwen35','qwen35moe','qwen4exp','llama','gemma','gemma2','gemma3','phi3']);
const QUANTS={0:'F32',1:'F16',2:'Q4_0',3:'Q4_1',7:'Q8_0',8:'Q5_0',9:'Q5_1',10:'Q2_K',11:'Q3_K_S',12:'Q3_K_M',13:'Q3_K_L',14:'Q4_K_S',15:'Q4_K_M',16:'Q5_K_S',17:'Q5_K_M',18:'Q6_K',19:'IQ2_XXS',20:'IQ2_XS',21:'Q2_K_S',22:'IQ3_XS',23:'IQ3_XXS',24:'IQ1_S',25:'IQ4_NL',26:'IQ3_S',27:'IQ3_M',28:'IQ2_S',29:'IQ2_M',30:'IQ4_XS',31:'IQ1_M',32:'BF16'};
const clean=value=>String(value??'').replace(/[\x00-\x1f\x7f]/g,' ').trim().slice(0,160);
function fullPath(input){if(typeof input!=='string'||!input.trim()||input.includes('\0'))throw new Error('Choose a model file or enter its full path.');const path=input.trim().replace(/^~(?=$|[\\/])/,homedir());if(!isAbsolute(path))throw new Error('Enter a full path.');try{return realpathSync(path);}catch{throw new Error('This file or folder is unavailable. Check its location and permissions.');}}
+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';
+219 -43
View File
@@ -7,12 +7,12 @@ 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, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv, speedSwitchRows } from './native-settings.mjs';
import { 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';
import os from 'node:os';
import { runtimeVision } from './native-vision.mjs';
import { markPictures, runtimeVision } from './native-vision.mjs';
import { dsEngineArgs, dsUserArgs, dsArgsNote, withoutVisionFlag } from './native-ds-args.mjs';
import { componentsDirectory, installerEnvironment, controlEnvironment, sourceServerEnvironment } from './control-runtime.mjs';
import { createEngineStep } from './engine-step.mjs';
@@ -27,17 +27,21 @@ 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;}}
/** The plain words for "no usable engine": the model still downloads, but loading needs the engine (owner decision 2026-10-18). */
export const ENGINE_NOT_RECOGNISED='Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → Engine.';
export const ENGINE_NOT_RECOGNISED='Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → About.';
export function createNativeControl({directory,requests=()=>[],changed=()=>{},spawnProcess=spawn,fetcher=fetch,modelsDirectory,nativeDataDirectory=value=>value,beforeLoad=async()=>{},hardwareProbe=()=>[],control=null,ready=null,appUpdater=null,defaultRepo=process.env.STRATA_NATIVE_REPO??resolve(ROOT,'../../Strata')}={}){
mkdirSync(directory,{recursive:true});
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. */
@@ -63,17 +67,19 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const updateLog=line=>{try{console.log(`update · ${line}`);}catch{/* no console */}};
const NO_INSTALL='Installing, updating, tuning with calibration or rolling back model files and engines happens from the Terminal (strata install), never while the app runs.';
let defaultId=null,timer=null,closing=false,polling=false;
/* Models the engine update stopped (with the person's yes): kept so the app can offer to start them again with the same settings. */
let stoppedForUpdate=null;
const emit=()=>{const value=state();writeFileSync(join(directory,'native-jobs.json'),JSON.stringify(value.jobs),{mode:0o600});changed(value);};
const append=(instanceId,line)=>{logs.push({at:new Date().toISOString(),instanceId,line:String(line).slice(0,2000)});if(logs.length>400)logs.shift();};
function diskSpace(path){try{const s=statfsSync(path);return {free:Number(s.bavail)*Number(s.bsize),total:Number(s.blocks)*Number(s.bsize)};}catch{return null;}}
/* 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;
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===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,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}};}
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 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)}));}
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb});
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb,engineVersion:snapshot.engine?.version??snapshot.installed?.engine?.version??null});
/* A start that ends with `server_exited` carries the engine's last log line with it; the app turns the code into words and
keeps this text under "Details" (src/lib/plain-errors.ts). */
function exitCodeText(code){const n=Number(code);if(!Number.isFinite(n)||n===0)return '';const u=n<0?n+2**32:n;return u>=0x80000000?`exit code 0x${u.toString(16).toUpperCase().padStart(8,'0')}`:`exit code ${u}`;}
@@ -88,7 +94,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_.-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,contextMax:configContextLimit(cfg)??undefined,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,drafting:cfg.drafting??'auto',/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
/* DeepSeek reads pictures with the engine's own tower, which is part of the pack (pack_gguf writes the tower's geometry into ds4.txt when the picture file was there): a pack with it is "prepared", a text-only pack (made with --vision off, as the app did before 0.2.9) is not, and the Models screen then offers the one missing shared file. null for every other model. */
const dsPictures=cfg=>{if(cfg?.arch!=='deepseek41')return null;const a=Array.isArray(cfg.args)?cfg.args:[];const at=a.indexOf('--pack');if(at<0||typeof a[at+1]!=='string')return false;try{const t=readFileSync(join(a[at+1],'ds4.txt'),'utf8');return /image_token_id/.test(t)&&/vision[.]num_hidden_layers/.test(t);}catch{return false;}};
/* DeepSeek's picture switch is a pair, --vision on|off, in the saved arguments: set it to the person's choice at every load (on only where the pack carries the tower). */
const dsVisionPair=(a,value)=>{if(!value)return a;const at=a.indexOf('--vision');return at<0?[...a,'--vision',value]:a.map((x,i)=>i===at+1?value:x);};
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_.-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* The folder this model's own run config keeps its prepared data in: the one that holds its packs/<tag> (or mtp/rt). It is the real data folder of the model, wherever the app's models folder is; a model in the person's own GGUF folder has its pack and draft layer here, not in the app's folder. A layout that only looks like <data>/models/<tag> says nothing: no pack, no folder. */const flagValue=flag=>{const i=cfg.args?.indexOf(flag)??-1;return i>=0&&typeof cfg.args[i+1]==='string'?cfg.args[i+1]:undefined;};const holder=(path,child,name)=>{if(!path||basename(dirname(path)).toLowerCase()!==child||(name&&basename(path).toLowerCase()!==name))return undefined;return dirname(dirname(path));};const runDataFolder=holder(flagValue('--pack'),'packs')??holder(flagValue('--mtp'),'mtp','rt');/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,runDataFolder,contextMax:configContextLimit(cfg)??undefined,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,contextChosen:cfg.ui_context_chosen===true,visionPrepared:dsPictures(cfg)??(!!cfg.vision&&entry.picturesMissing!==true),/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:(dsPictures(cfg)??(!!cfg.vision&&entry.picturesMissing!==true))?'yes':'off',draftLayer:cfg.mtp!==false,drafting:cfg.drafting??'auto',/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
/* The settings file Strata Control and the installer read (control-runtime.mjs sets STRATA_SETTINGS_FILE to it): the app's data folder is written there. */
function controlSettingsFile(){return process.env.STRATA_SETTINGS_FILE||join(dirname(componentsDirectory()),'app-settings.json');}
@@ -122,10 +132,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const engine=await control.engine();
const installer=await mcpCall('strata_status',{hardware:true}).catch(error=>({error:error.message}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0,missingFiles:m.missing_files}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),missing:missingNames(m.missing,disk[m.id]?.missingFiles),images:'off',port:undefined,last_used:null}));
const onlyPictures=m=>m.component_status==='missing_or_invalid'&&Array.isArray(m.missing)&&m.missing.length>0&&m.missing.every(p=>p?.part==='vision/mmproj')&&Array.isArray(m.issue_codes)&&m.issue_codes.length===m.missing.length&&m.issue_codes.every(c=>/^COMPONENT_(MISSING|INVALID)$/.test(c));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:(m.component_status==='present'||onlyPictures(m))&&!(disk[m.id]?.partsMissing),/* Only the picture file is gone (run config has the picture section, the file does not exist): the model is not "missing files". It loads, and the Load fetches the picture file again. */picturesMissing:m.component_status!=='present'&&onlyPictures(m),missing:missingNames(m.missing,disk[m.id]?.missingFiles),images:'off',port:undefined,last_used:null}));
const cores=os.cpus().length;
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null,updateAvailable:engine.update_available??null},
installed:{strata_folder:list.install_dir,data_folder:null,engine:engine.engine?{version:engine.engine.version,backend:engine.engine.backend,folder:engine.engine.root}:{folder:null,version:null},models,usage:installer.installed?.usage??null},
hardware:installer.hardware?{...installer.hardware,cpu:{...(installer.hardware.cpu??{}),cores:installer.hardware.cpu?.cores??cores},gpus:installer.hardware.gpus??[]}:{cpu:{cores},ram_gb:Math.round(os.totalmem()/1024**3),gpus:[]},
recommendation:installer.recommendation??null,installerError:installer.error??null,install:installer.install??null,running:null};
@@ -141,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=false;}}const markImages=c=>c&&Array.isArray(c.families)?{...c,families:c.families.map(f=>({...f,imagesCapable:!!f.images,images:!!f.images&&visionReader,sizes:(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:!!s.images&&visionReader}))}))}:c;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();}
@@ -157,11 +168,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
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;if(packaged&&typeof choice==='string'&&choice!=='auto'&&typeof control.draftingPlan==='function'){try{const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:snapshot.installed?.data_folder??null});if(d.needed)return {...result,drafting:{needed:true,gb:d.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,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}};}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;}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir','drafter'];/* One drafter per run: when the choice means DFlash2 the installer does not fetch the MTP layer's ~5 GB (the drafter is fetched by the load); choosing the MTP later fetches the layer once. */if(args.drafter===undefined&&args.drafting!==undefined||args.drafter===undefined&&dflash2Served===true){const d=resolveDrafter(args.drafting,args.family,dflash2Served,dflash2Split);if(d==='dflash2')args={...args,drafter:'dflash2'};}const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};/* A model that is installed keeps working in the data folder its own run config names (where its pack and draft layer are), whether its files are under it or in the person's own folder: that is never changed or written to the settings from here. Only a folder taken from the app's models folder is the person's choice, and the one the installer is told about. */const own=args.data_dir===undefined?existing?.runDataFolder:undefined;const data=own??nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir&&!own)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined&&args.family!=='deepseek41'?{vision:'no'}:{}),confirm};}
function begin(kind,run,metadata={}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or cancel the current engine operation first.');const job={id:randomUUID(),kind,state:'running',startedAt:new Date().toISOString(),summary:'Starting…',result:null,error:null,...metadata};for(const [id,old] of jobs)if(jobs.size>=12&&old.state!=='running')jobs.delete(id);jobs.set(job.id,job);emit();void run(job).then(result=>{if(job.state==='running'){Object.assign(job,{state:'done',summary:result?.summary??'Complete',result,finishedAt:new Date().toISOString()});}}).catch(error=>{if(job.state==='running')Object.assign(job,{state:'error',error:error.message,summary:error.message,...(error.details?{details:error.details}:{}),finishedAt:new Date().toISOString()});}).finally(()=>{if(job.kind.startsWith('update')||job.kind==='rollback')disconnect();lastRead=0;emit();void refresh(true);});return state().jobs.find(j=>j.id===job.id);}
async function watchInstall(job,current){job.summary=current.step??'Resuming preparation status…';job.result={install:current};while(job.state==='running'){await new Promise(r=>setTimeout(r,1500));const result=await tool('strata_install');job.summary=result.summary;job.result=result;emit();if(!result.install?.running){if(result.install?.exit_code!==0)throw new Error(result.install?.error??'Preparation did not finish. Retry to reuse completed files.');return {summary:'Model preparation complete. Load it from Models.'};}}}
@@ -195,7 +206,9 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const note=engineStep?.summaryNote();
const finalResult=engineStep?job.result:result;
if(note)return {...finalResult,summary:note};
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision,...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
/* A context the person picked is kept with a marker (ui_context_chosen), so only an old saved 32K, which has no marker, is read as the old default. */
if(args.contextChosen===true){try{const id=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;rememberSetting(record(id).path,'ui_context_chosen',true);}catch{/* the install itself is done */}}
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision,...(args.visionUnavailable===true?{visionUnavailable:true}:{}),...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
return finalResult;
}
}
@@ -267,16 +280,18 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const sized=gb>40?Math.max(300,600+(gb-40)*8):300;
return Math.trunc(Math.max(300,Math.min(ceiling,Math.max(ready,sized))));
}
function buildConfig(args,config){
const vision=runtimeVision(config,args.vision);
const visionPreferences=args.vision!==undefined?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
function buildConfig(args,config,drafter=null){
/* `visionUnavailable`: this engine has no picture reader, so the run goes without pictures but nothing is saved: the model's own setting stays what the person had (it used to save "off", and the model stayed off after the engine got the reader). */
const noReader=args.visionUnavailable===true;
const vision=ownEngine(config)?undefined:runtimeVision(config,noReader?'no':args.vision); /* DeepSeek has its own picture tower: its picture setting is the --vision flag setup wrote */
const visionPreferences=args.vision!==undefined&&!noReader?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
/* The engine options the person set by hand (user_args, and what they wrote into "args" since the last write) go over everything the app generates, and an
explicit GPU-memory reserve (the Expert setting, user_args, a value they wrote) is never replaced by the safety default (#3215 #3270 #3216). */
const values0=args.values??{},cleared='vram_reserve_mib' in values0&&(values0.vram_reserve_mib===null||values0.vram_reserve_mib===''),base0=cleared?{...config,vram_reserve_mib:undefined,[USER_KEY]:setUserFlag(config[USER_KEY],'--vram-reserve-mib',null)}:config;
let mine=captureUserArgs(base0);const asked=cleared?null:reserveChoice({...base0,[USER_KEY]:mine},snapshot.hardware,values0).asked;
if(asked!==null)mine=setUserFlag(mine,'--vram-reserve-mib',asked);
const resources=args.resourceProfile&&!ownEngine(config)?resourceSettings({...base0,[USER_KEY]:mine,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model),values0):null;
const cfg={...config,...visionPreferences,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
const cfg={...config,...visionPreferences,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
/* The app's suggested cards only fill a gap: a model whose settings file already names its GPUs ("gpu": [1, 0], with "gpu_order": "as_given") keeps them (#3256). A card the person picked in the screen always wins. */
const namedInFile=((Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles)&&!setupPickedOneCard(config,snapshot.hardware);
/* Roles chosen in the screen (Main / Helper / Off per card, gpu-roles.mjs): the card list is the roles' mirror - Helpers first, the Main card LAST, Off cards left out - with "gpu_roles" beside it. Every card Off is allowed: the server says plainly what the engine can do. */
@@ -289,9 +304,10 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
/* The Qwen engine's --vision stands alone, so it goes (with a picture word after it, if an older file has one). The DeepSeek engine's is a pair, `--vision on|off`, that setup wrote and the engine reads: it stays whole (taking out only the name left `off` behind: `--eos 1 off`, "unknown option off", #4358 #4359 #4505 #4507). */
if(!cfg.vision&&!ownEngine(config)&&Array.isArray(cfg.args))cfg.args=withoutVisionFlag(cfg.args);
if(ownEngine(config)&&Array.isArray(cfg.args)&&args.vision!==undefined)cfg.args=dsVisionPair(cfg.args,args.vision==='no'?'off':dsPictures(cfg)?'on':null);
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
const parallelChoice=packaged&&!ownEngine(config)?chooseParallel({config:cfg,values:args.values??{},hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
const parallelChoice=packaged&&!ownEngine(config)?chooseParallel({config:cfg,values:args.values??{},drafter,hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
let parallelNote=null,dsNote='';
/* The agent asks for long answers (a thinking model can use most of the window): let the engine clamp the output to what is left of the context instead of refusing the request. */
if(packaged&&!ownEngine(config)&&cfg.fit_max_tokens===undefined)cfg.fit_max_tokens=true;
@@ -304,32 +320,123 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
else delete cfg.ds_experts;
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. */
/* 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;
/* 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). */
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 control.draftingPlan({configPath:planFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked}),...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{})});}catch{return null;}
finally{if(temporary){try{rmSync(temporary,{force:true});}catch{/* best effort */}}}
}
/* 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)}:{})});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, and the job says so. The choice itself is remembered in the model's settings file. */
async function prepareDrafter(job,args,cfg,configFile){
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,{noSplit=false}={}){
const asked=args.values?.drafting;
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
if(!packaged||typeof control.draftingFetch!=='function'||!cfg.drafting||cfg.drafting==='auto'||cfg.drafting==='mtp'||cfg.drafting==='off')return;
/* 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. */
if(!packaged||typeof control.draftingFetch!=='function'||asked===undefined||drafts?.needed!==true)return;
const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});const probe=join(work,'drafting-'+randomUUID()+'.json');
try{
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
job.engineAbort??=new AbortController();
await control.draftingFetch({configPath:probe,mode:cfg.drafting,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='The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';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 */}}
}
/* 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
to the model's run config is the picture section. A restart, a tuning run or any other load that does not ask for pictures never downloads. */
const PICTURES_WORDS={download:'Downloading the picture files (0.9 GB, once)…',check:'Checking the picture files…',loading:'Loading the model…',
offline:'No internet connection: the model is loaded without picture reading. Load it again when you are online.',
disk:'There is not enough free disk space for the picture files, so the model is loaded without picture reading.',
failed:'The picture files could not be downloaded, so the model is loaded without picture reading. Try again later.',
gone:'The picture files are missing, so the model is loaded without picture reading.'};
const PLAIN_PICTURE_CODES=['offline','disk','failed','checksum','unsupported','no_reader'];
const askedPictures=value=>['yes','gpu','cpu'].includes(value);
const pictureFileGone=config=>!config.vision?.mmproj||!existsSync(resolve(repo(),String(config.vision.mmproj)));
/* The card the picture reader will be chosen for: the cards the model runs on, the one with the most memory (setup's rule is the card's memory: the CPU reader under 24 GB). */
function pictureCard(config){const make=snapshot.hardware?.engine_vendor,named=Array.isArray(config.gpu)?config.gpu:config.gpu===undefined||config.gpu===null?null:[config.gpu];const cards=(snapshot.hardware?.gpus??[]).filter(g=>g.usable!==false&&(!make||!g.vendor||g.vendor===make)&&(!named||named.includes(g.index)));const best=[...cards].sort((a,b)=>(Number(b.vram_gb)||0)-(Number(a.vram_gb)||0))[0];return best?{vram_gb:Number(best.vram_gb)||0,make:best.vendor??make??null,arch:best.arch??null}:null;}
async function ensurePictures(job,args,configFile,config,{strict=false,missing=false}={}){
const keep={config,args,note:null};
if(!packaged||ownEngine(config)||args.visionUnavailable===true)return keep;
const prepared=!!config.vision,asked=askedPictures(args.vision);
/* Control says the picture file is gone (missing) or, when pictures were asked for, this look at the disk says so; a load that asked for nothing never second-guesses Control. */
if(prepared&&!missing&&!(asked&&pictureFileGone(config)))return keep;
if(!asked){const none={...args,vision:'no',visionUnavailable:true};return prepared?{config,args:none,note:PICTURES_WORDS.gone}:keep;}
if(typeof control.picturesFetch!=='function')return keep;
job.abort=new AbortController();job.summary=PICTURES_WORDS.download;job.details=undefined;emit();
try{
const engine=await control.engine();const engineDir=engine?.engine?.root;
if(!engineDir)throw Object.assign(new Error('This engine has no picture reader.'),{code:'no_reader'});
const family=snapshot.catalog?.families?.find(f=>f.sizes?.some(s=>s.id===args.model))?.family??null;
const here=snapshot.installed?.models?.find(m=>m.model===args.model);
let fallbackData=null;try{fallbackData=here?.runDataFolder??here?.dataFolder??nativeDataDirectory(preferences.downloadDirectory??modelsDirectory?.())??null;}catch{fallbackData=here?.runDataFolder??here?.dataFolder??null;}
const got=await control.picturesFetch({configPath:configFile,engineDir,vision:args.vision,dataDir:fallbackData,family,hardware:pictureCard(config),signal:job.abort.signal,
onEvent:event=>{if(event?.event!=='progress'||job.state!=='running')return;const total=Number(event.total)||0,done=Number(event.done)||0;
if(/^Checking/i.test(String(event.step))){job.summary=PICTURES_WORDS.check;}
else{job.summary=PICTURES_WORDS.download;if(total>0)job.result={install:{download:{file:'picture reader',done_gb:done/1e9,total_gb:total/1e9,remaining_gb:Math.max(0,total-done)/1e9,percent:Math.min(99,Math.floor(100*done/total))}}};}
emit();}});
if(job.state!=='running')return keep;
const next={...config,vision:got.vision};
writeModelConfig(configFile,next);
if(got.note)append(args.model,`Picture files: ${got.note}`);
job.result=null;job.summary=PICTURES_WORDS.loading;job.details=undefined;emit();
return {config:next,args,note:null};
}catch(error){
job.result=null;
if(job.state!=='running')return keep;
const code=error?.code,details=error?.output?.error?.details;
append(args.model,`Picture files: ${error?.message??error}${details?` (${details})`:''}`);
if(strict)throw Object.assign(new Error(PLAIN_PICTURE_CODES.includes(code)?String(error.message):'The picture files could not be downloaded. Try again.'),{details:details?[details]:undefined});
job.summary=PICTURES_WORDS.loading;job.details=undefined;emit();
return {config,args:{...args,vision:'no',visionUnavailable:true},note:code==='offline'?PICTURES_WORDS.offline:code==='disk'?PICTURES_WORDS.disk:PICTURES_WORDS.failed};
}
}
/* "Download picture-reading files": the same step without a load. */
async function picturesOnly(job,args){
await refresh(true);if(job.state!=='running')return;
const {path:configFile}=recordFile(args.model);const config=checkedSettingsConfig(readFileSync(configFile,'utf8'));
if(!packaged)throw new Error('The picture files are downloaded by the installed app.');
if(ownEngine(config))throw new Error('This model reads pictures with its own files.');
const {entry}=recordFile(args.model);
if(config.vision&&!entry.picturesMissing&&!pictureFileGone(config))return {summary:'The picture files are ready.'};
await ensurePictures(job,{...args,vision:askedPictures(args.vision)?args.vision:'yes'},configFile,config,{strict:true,missing:entry.picturesMissing===true});
if(job.state!=='running')return;
await refresh(true);
return {summary:'The picture files are ready.'};
}
async function loadEngine(job,args){
await refresh(true);if(job.state!=='running')return;const {entry,path:configFile}=recordFile(args.model);const config=checkedSettingsConfig(readFileSync(configFile,'utf8'));if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
await refresh(true);if(job.state!=='running')return;const {entry,path:configFile}=recordFile(args.model);let config=checkedSettingsConfig(readFileSync(configFile,'utf8'));if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
if(externalRunning().length)throw new Error('A Strata server is already running outside this UI. Stop it before loading here, or connect its API in Settings → Models.');
if(args.multiple&&instances.size>=8)throw new Error('Unload an instance before adding another. Up to eight native instances can run here.');
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config);
/* 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),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});}}
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);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};
@@ -344,29 +451,47 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(!args.multiple){if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish or cancel native requests before switching models.');for(const id of [...instances.keys()])await unload(id);}else if(matching)await unload(matching.id);
if(job.state!=='running')return;
writeFileSync(configPath,JSON.stringify(cfg,null,2),{mode:0o600});
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,resourceProfile:cfg.ui_resource_profile??null,model:args.model,label:friendlyModelName(snapshot.catalog,args.model)??entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null};
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,resourceProfile:cfg.ui_resource_profile??null,model:args.model,label:friendlyModelName(snapshot.catalog,args.model)??entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null,...(picturesNote?{picturesNote}:{})};
/* 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
@@ -381,17 +506,20 @@ 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);
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||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,...(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:''}`,instanceId:id};}}catch{}
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));
}
if(job.state!=='running'){await unload(id);return;}
@@ -401,7 +529,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;}
@@ -418,10 +548,10 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
shell's signed Tauri updater. Models are not part of any of it. */
async function componentProgram(job,args={}){
if(!COMPONENT_KINDS.includes(job.kind)||typeof control.update!=='function')throw new Error(NO_INSTALL);
if(instances.size)throw new Error('Unload native models before changing engine files.');
if(instances.size&&!(job.kind==='update-engine'&&args.stopModel===true))throw new Error('Unload native models before changing engine files.');
job.abort=new AbortController();
const subject=job.kind==='update-strata'?'Strata':'engine';
try{return await componentSteps(job,subject,args);}catch(error){updateLog(`${job.kind} failed: ${String(error?.message??error).slice(0,400)}`);throw error?.details!==undefined&&error.message?error:plainError(error,subject);}
try{return await componentSteps(job,subject,args);}catch(error){updateLog(`${job.kind} failed: ${String(error?.message??error).slice(0,400)}`);throw error?.details!==undefined&&error.message?error:plainError(error,subject,args?.firstRun===true?'Continue':undefined);}
}
async function componentSteps(job,subject,args={}){
const onEvent=event=>{
@@ -433,7 +563,13 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
updateLog(`${job.kind} started (app ${thisAppVersion()??'unknown'})`);
if(job.kind==='install-engine-file'){if(typeof args.path!=='string'||!args.path)throw new Error('Choose the engine file first.');let done;try{done=await control.installEngineFile(args.path,{onEvent,signal:job.abort.signal});}finally{try{const own=resolve(directory,'incoming-engine')+sep;if(resolve(args.path).startsWith(own))rmSync(args.path,{force:true});}catch{/* the copy in the host's own folder; a leftover is harmless */}}return {summary:updateSummary(done)==='Everything is already up to date.'?(done.results?.[0]?.note??'Nothing was changed.'):updateSummary(done),result:done};}
if(job.kind==='rollback'){const done=await control.rollbackEngine();return {summary:'The previous engine is back. Your models and settings are unchanged.',result:done};}
if(job.kind==='update-engine'){const done=await control.update({engine:true,control:false,onEvent,signal:job.abort.signal});return {summary:updateSummary(done),result:done};}
if(job.kind==='update-engine'){
/* A model is loaded and the person said yes to stopping it (the dialog names it): Control downloads while it serves, stops it cleanly, updates, and leaves it stopped.
Whatever the result, the models it stopped are kept so the app offers to start them again, on the new engine or, after a failed update, on the old one. */
const gate=args.stopModel===true&&instances.size?updateGate(job):null;
try{const done=await control.update({engine:true,control:false,stopModel:!!gate,onEvent:gate?e=>{onEvent(e);gate.event(e);}:onEvent,signal:job.abort.signal});gate?.finish(true);return {summary:updateSummary(done),result:done};}
catch(error){gate?.finish(false);if(gate?.refusal)throw Object.assign(new Error(gate.refusal),{details:`Running outside this app: ${gate.outside.join(', ')}`});throw error;}
}
const done=await control.update({engine:false,control:true,onEvent,signal:job.abort.signal});
updateLog(`Control step finished: ${updateSummary(done)}`);
const notes=[updateSummary(done)];
@@ -446,7 +582,47 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}
return {summary:notes.filter(Boolean).join(' '),result:done};
}
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
/* The engine update with a model loaded (strata update --stop-model --output jsonl). The `models` events say what Control is doing to the models; this turns them into the app's own
state: the model leaves the list when it is stopped, and a model the app did not start is never stopped (the update is ended before anything is downloaded). */
function updateGate(job){
const mine=new Map([...instances.values()].filter(i=>i.profile).map(i=>[i.profile,i])),stopped=[],gate={refusal:null,event(e){
if(e.event!=='models')return;
const models=Array.isArray(e.models)?e.models:[],label=m=>mine.get(m.profile)?.label??m.name??'The model';
if(e.phase==='consent'){
const other=models.filter(m=>!mine.has(m.profile));
if(other.length){gate.refusal='A model that was not started from this app is running, so nothing was changed. Stop it, then update the engine again.';gate.outside=other.map(label);job.abort.abort();return;}
job.step='getting-ready';job.models=models.map(label);job.summary=`Getting the new engine ready. ${job.models.join(' and ')} keeps running until it is ready.`;
}
else if(e.phase==='stopping'){for(const m of models){const i=mine.get(m.profile);if(i)i.status='unloading';}job.step='stopping';job.models=models.map(label);job.summary=`Stopping ${job.models.join(' and ')}. Answers in progress finish first.`;}
else if(e.phase==='stopped'){for(const m of models){const i=mine.get(m.profile);if(i&&instances.get(i.id)===i){instances.delete(i.id);stopped.push(i);if(defaultId===i.id)defaultId=instances.keys().next().value??null;}}job.step='stopped';job.models=models.map(label);job.summary=`${job.models.join(' and ')} stopped. Updating the engine…`;}
emit();
},finish(updated){stoppedForUpdate=stopped.length?{updated,at:new Date().toISOString(),instances:stopped}:null;emit();}};
return gate;
}
/* "Start again with the same settings": the stopped model's saved profile (the run file it was started with) is started again; the engine in use is whichever Control has primary now. */
async function restartStopped(job){
const offer=stoppedForUpdate;if(!offer?.instances?.length)throw new Error('There is no stopped model to start again.');
stoppedForUpdate=null;emit();
const labels=[];
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();
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});
const deadline=Date.now()+300000;let ready=false;
while(!ready&&Date.now()<deadline&&job.state==='running'){try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(2000)});ready=response.ok;}catch{/* not yet */}if(!ready)await new Promise(resolve=>setTimeout(resolve,1200));}
if(!ready)throw new Error('Loading took more than five minutes. Check the engine log and free memory.');
instance.status='ready';instance.phase='idle';ensurePoll();emit();labels.push(instance.label);
}catch(error){
await control.stop(instance.profile).catch(()=>{});
instance.status='error';instance.error=humanStartError(error);emit();throw new Error(`${instance.label} did not start again. ${instance.error}`);
}
}
return {summary:`${labels.join(' and ')} ${labels.length>1?'are':'is'} running again with the same settings.`};
}
function dismissRestart(){stoppedForUpdate=null;emit();return state();}
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'||x.component==='dsv41'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
function execute(cmd,args,cwd){return new Promise((resolve,reject)=>{let output='';const process=spawnProcess(cmd,args,{cwd,stdio:['ignore','pipe','pipe']});process.stdout.on('data',c=>{output+=c;if(output.length>100000)process.kill();});process.stderr.on('data',c=>append('update',c.toString()));process.on('error',reject);process.on('exit',code=>code===0?resolve(output):reject(new Error('Git could not update this installation. Open diagnostics.')));});}
/* Every write of a model's run config keeps the person's options: those they wrote into "args" or "user_args" since the file was last written are read from the file itself, go over what the caller built, and "args_written" is refreshed. */
function writeModelConfig(path,next){const mine=mergeUserArgs(captureUserArgs(readJson(path)),next[USER_KEY]??[]);next=stampedUserArgs(next,mine);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);}
@@ -468,7 +644,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const next={...current.config,...values,ui_resource_profile:args.profile};
writeModelConfig(current.path,next);
job.summary='Applying your resource preference and loading the model…';emit();
const loaded=await loadEngine(job,{model:args.model,multiple:false,vision:current.config.vision?args.vision:'no'});
const loaded=await loadEngine(job,{model:args.model,multiple:false,vision:current.config.vision?args.vision:'no',...(args.visionUnavailable===true?{visionUnavailable:true}:{})});
if(job.state!=='running'||!loaded?.instanceId)return;
job.summary='Checking your model’s speed…';emit();
const measured=await benchmark(job,{instanceId:loaded.instanceId});
@@ -491,7 +667,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const room=args.resourceProfile==='work';
return autotuner.start(job,job.kind==='recheck'?'recheck':'tune',{configPath:path,presetId:args.presetId,mode:args.mode,use:args.use,goal:room?'room':'speed',leaveRam:room?20:0,allowSmallDifferences:args.allowSmallDifferences===true,name:args.name},{onProgress:p=>{job.summary=p.text??job.summary;emit();}});
}
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','install-engine-file','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available&&!(packaged&&COMPONENT_KINDS.includes(kind)))throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
function operation(kind,args={}){if(!['install','load','pictures','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','install-engine-file','remove','restart-models'].includes(kind))throw new Error('Choose a supported native operation.');if(kind==='restart-models'&&!packaged)throw new Error('Choose a supported native operation.');if(!snapshot.available&&!(packaged&&(COMPONENT_KINDS.includes(kind)||kind==='restart-models')))throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='pictures'?picturesOnly(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):kind==='restart-models'?restartStopped(job):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
/* ---- The models folder card (0.2.2): what a picked folder means, and the three choices. The copying is Control's (strata models move). ---- */
const currentModelsFolder=()=>preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder)??null;
const moveFile=()=>join(directory,'models-move.cancel');
@@ -591,7 +767,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,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,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){
+2 -2
View File
@@ -1,11 +1,11 @@
/** 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;
const observe=value=>{const usage=value.usage??value.response?.usage;if(usage){tokensIn=usage.prompt_tokens??usage.input_tokens??tokensIn;tokensOut=usage.completion_tokens??usage.output_tokens??tokensOut;}if(value.timings){tps=value.timings.predicted_per_second??tps;promptTps=value.timings.prompt_per_second??promptTps;}};
const observe=value=>{/* Anthropic's stream carries the prompt count in message_start (message.usage) and only the output count in message_delta (usage): both are read (#4678). */for(const usage of [value.message?.usage,value.usage??value.response?.usage])if(usage){tokensIn=usage.prompt_tokens??usage.input_tokens??tokensIn;tokensOut=usage.completion_tokens??usage.output_tokens??tokensOut;}if(value.timings){tps=value.timings.predicted_per_second??tps;promptTps=value.timings.prompt_per_second??promptTps;}};
try{
const response=await fetcher(`http://127.0.0.1:${instance.port}${path}`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${instance.key}`},signal:abort.signal,body:JSON.stringify({...body,model:instance.apiModelId,strata_mcp:false})});
if(!response.ok){const content=await response.text();serving.finish(entry.id,{durationMs:Date.now()-started,error:`Engine returned ${response.status}`});res.writeHead(response.status,{'content-type':response.headers.get('content-type')??'application/json'});res.end(content);return;}
+16 -3
View File
@@ -19,6 +19,8 @@
*/
export const DEFAULT_SLOTS = 4;
/** What the person is told when DFlash2 is chosen and nobody picked a number of requests (strata_control/drafting.py ONE_REQUEST_NOTE). */
export const DFLASH2_NOTE = 'DFlash2 answers one request at a time, so Strata set this model to one request at a time.';
/** The most the engine's batch window holds without grouped MTP. */
export const MAX_SLOTS = 8;
/**
@@ -46,7 +48,11 @@ export const FIRST_CARD_OTHER_GB = 6.9;
* phaseA-tests/notes-021-settings.md). With "Draft layer (MTP)" off in Expert settings (`"mtp": false`) the engine does not load it, so
* the card has that much more cache and the slots may use it.
*/
export const MTP_VRAM_GB = 1.0;
export const MTP_VRAM_GB = 1.08;
/** Graphics memory of DFlash2 alone, with the MTP layer not loaded beside it (RESULTS-DF2MEM.md: 890 MiB; the MTP layer 1082 MiB). Fit, plan and slot estimates use the drafter that is chosen. */
export const DFLASH2_VRAM_GB = 0.89;
/** The same with the converted pack (the selector's codebooks as Q4_0 rows, 715 MiB; strata_control/drafting.py DRAFTER_MIB "dflash2_selq4"): `capabilities.dflash2Selq4` says it is the one in use. */
export const DFLASH2_SELQ4_VRAM_GB = 0.72;
export const NEXT_CARD_OTHER_GB = 2.0;
/**
* `--batch-mtp` lets each slot verify draft tokens. Where it pays and where it does not (all measured):
@@ -95,9 +101,10 @@ 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): 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 }) {
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS, drafter = null }) {
const facts = engineFacts(config);
const slot = slotGb({ context: facts.context, kv: facts.kv, family: facts.family });
const chosen = values.parallel !== undefined && values.parallel !== null ? Number(values.parallel)
@@ -105,15 +112,21 @@ 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 (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).
if (!cards.length) return { slots: PARALLEL_POLICY === 'fixed' ? requested : 1, batchMtp: false, explicit: false, requested, note: null, slotGb: slot };
const total = cards.reduce((a, b) => a + b, 0);
const oneCard = facts.gpus.length === 1 && cards.length === 1;
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - (facts.mtp ? 0 : MTP_VRAM_GB)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - MTP_VRAM_GB + (facts.mtp ? MTP_VRAM_GB : 0)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
let slots = requested;
if (PARALLEL_POLICY === 'fit') while (slots > 1 && slots * slot > CACHE_LOSS_SHARE * soloCache) slots -= 1;
const note = slots >= requested ? null
+43 -13
View File
@@ -1,4 +1,5 @@
import {USER_KEY,flagValue} from './native-user-args.mjs';
import {parseEngineVersion} from './engine-version.mjs';
/** The engine's own default reserve, and the one setup writes for pictures read on the GPU: neither is a choice of the person. */
const ENGINE_DEFAULT_RESERVE_MIB=700;
@@ -75,28 +76,57 @@ export function resourceSettings(config,hardware,profile,model={},values={}){
/* A reserve the person set is theirs, 0 included: no floor and no share of the card (#3215 #3270). Without one the card gets the safety default (defaultReserves). */
const reserve=asked!==null?asked:defaults[profile];
remove('--vram-reserve-mib');args.push('--vram-reserve-mib',String(reserve));
// RAM: "leave 20% for my work" keeps the model's RAM within 80% of the PC. A model whose experts do not fit there (the
// setup rule: experts plus 10 GB) keeps only that many GiB of them in RAM and reads the rest from the model files
// (--resident-budget-gib, which implies --mmap-experts). Full performance takes the budget out again.
// RAM: "leave 20% for my work". An engine that knows the setting (RAM_KEEP_MIN_ENGINE and newer) is asked to keep W GiB of the RAM free, W = ramKeepGib(RAM), as the env STRATA_RAM_KEEP_GIB: it decides from
// the RAM that is free at its start, not from the PC's total, using the full RAM copy of the experts (the arena) only if W GiB stay free after it and otherwise keeping the hottest experts in a RAM copy
// that leaves max(W, its own safety headroom) free. An env variable and not the flag --ram-keep-gib, so that an engine without the setting never meets an argument it does not know. The engine in use
// is the one the Engine tab selected (its version, model.engineVersion, or the folder named in the run config's exe); an older engine, or one whose version cannot be read, gets the rule of 0.2.9
// unchanged, which every engine understands: a RAM budget of 80% of the PC (--resident-budget-gib, which implies --mmap-experts) when the model's experts do not fit there (the setup rule: experts plus
// 10 GB), and a STRATA_RESIDENT_HEADROOM_GIB of 20% of the RAM. Full performance (and `strata serve`) pass nothing: the whole system, the arena whenever it fits; either rule is taken out again.
if(baseline.ramBudget)remove('--resident-budget-gib');
let env=null; // the engine's own "leave RAM free" setting travels in the run config's env
if(baseline.ramHeadroom&&config.env){env={...config.env};delete env.STRATA_RESIDENT_HEADROOM_GIB;}
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;let ramBudget=false;
if(profile==='work'&&ram>0&&arena>0&&!['--resident-experts','--mmap-experts','--resident-budget-gib'].some(f=>args.includes(f))&&ram*.8<arena+10){
args.push('--resident-budget-gib',String(Math.max(8,Math.min(Math.floor(ram*.8)-10,Math.floor(arena/1.073741824)))));ramBudget=true;
let env=null;
const dropEnv=name=>{if(config.env&&name in config.env){env={...(env??config.env)};delete env[name];}};
if(baseline.ramHeadroom)dropEnv('STRATA_RESIDENT_HEADROOM_GIB');
if(baseline.ramKeep)dropEnv('STRATA_RAM_KEEP_GIB');
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;let ramKeep=false,ramBudget=false,ramHeadroom=false;
// The person's own RAM choice wins: a RAM mode or --ram-keep-gib in their args, and STRATA_RAM_KEEP_GIB or STRATA_RESIDENT_HEADROOM_GIB of theirs in the env (the engine reads those first).
const mineEnv=Boolean((env??config.env)?.STRATA_RAM_KEEP_GIB);
const modes=['--resident-experts','--mmap-experts','--resident-budget-gib','--ram-keep-gib'];
if(profile==='work'&&ram>0&&arena>0&&!modes.some(f=>args.includes(f))){
if(engineKnowsRamKeep(engineVersionOfExe(config?.exe)??model?.engineVersion)){
if(!mineEnv){env={...(env??config.env??{}),STRATA_RAM_KEEP_GIB:String(ramKeepGib(ram))};ramKeep=true;}
}else if(ram*.8<arena+10){
args.push('--resident-budget-gib',String(Math.max(8,Math.min(Math.floor(ram*.8)-10,Math.floor(arena/1.073741824)))));ramBudget=true;
if(!(env??config.env)?.STRATA_RESIDENT_HEADROOM_GIB){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
}
}
// The budget caps what the engine keeps in RAM. "Leave 20%" is also asked of the engine itself: at its start it leaves 20% of the RAM
// available for other programs (it keeps fewer experts if the PC is busy), and it said nothing about this before.
let ramHeadroom=false;
if(ramBudget&&!(config.env?.STRATA_RESIDENT_HEADROOM_GIB)){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom}};
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom,ramKeep}};
}
/** The RAM, in GiB, "Leave 20% for my work" asks the engine to keep free: a fifth of the PC's RAM rounded up, never under 5 or over 8
* (5 at 24 GB, 7 at 32 GB, 8 at 64 GB and above). The engine adds it to the arena's need and, when the arena does not fit, leaves it free beside the RAM copy. It is sent as the env variable STRATA_RAM_KEEP_GIB. */
export const ramKeepGib=ram=>Math.min(8,Math.max(5,Math.ceil((Number(ram)||0)*.2)));
/** The first engine version that reads STRATA_RAM_KEEP_GIB / --ram-keep-gib (the setting was added in 0.1.56, the engine of the 0.2.9 update; the released 0.2.9 engine is 0.1.55). An engine older than this, or one whose version cannot be read, gets the
* rule of 0.2.9 instead. Lower it (or raise the engine's version) only together with the release that carries the setting. */
export const RAM_KEEP_MIN_ENGINE='0.1.56';
/** True when the engine with this version string knows the setting. Unknown or unreadable: false (the 0.2.9 rule works on every engine, an ignored env variable would keep no room). */
export function engineKnowsRamKeep(version){
const have=parseEngineVersion(version),need=parseEngineVersion(RAM_KEEP_MIN_ENGINE);
if(!have||!need)return false;
for(let i=0;i<4;i++){const a=have[i]??0,b=need[i]??0;if(a!==b)return a>b;}
return true;
}
/** The version in the folder a run config's exe names (.../engine/0.1.55-windows-x86_64-cuda-cuda13/strata.exe), or null (the exe "component:strata-engine" means the active engine, whose version the caller gives). */
export const engineVersionOfExe=exe=>{const m=/engine[\\/](\d+(?:\.\d+)+)-/.exec(String(exe??''));return m?m[1]:null;};
/* One automatic second try after a start that died. Returns null when there is nothing safer to try (the caller then reports the first
failure as it was) or {config, words, detail}: the same run config with ONE safer memory setting, a sentence for the user and the
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';
+13 -3
View File
@@ -8,9 +8,9 @@ export const NATIVE_FIELDS=[
{id:'aliases',label:'API model aliases',group:'Serving',type:'array',hint:'Comma-separated names clients may use for this model.'},
{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',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',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster. Auto (recommended) lets Strata pick from what it measured on cards like yours; today that is the MTP. DFlash2 is for Qwen3.8-Flash-Next only and downloads a 1.2 GB drafter once. If DFlash2 cannot run, the MTP is used and Details says why. Off turns the draft layer off.'},
{id:'batchMtp',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:'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; 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 1.2 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.'},
{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.'},
{id:'engine_silence_s',label:'Engine stall timeout (seconds)',group:'Recovery',type:'number',min:10,max:3600},
@@ -87,6 +87,16 @@ export function setSpeedSwitch(env,id,on){
export function speedSwitchRows(env,context){return SPEED_SWITCHES.filter(s=>speedSwitchApplies(s.id,context)).map(s=>({...s,on:speedSwitchIsOn(env,s.id)}));}
/** 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;
/** 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){
const c=String(choice??'auto').toLowerCase();if(c==='off'||c==='mtp')return c;
const name=String(model??'');if(!(served===true&&(DRAFTING_FAMILIES.includes(name.toLowerCase())||DFLASH2_MODEL.test(name))))return 'mtp';
return split===true&&c!=='dflash2'?'mtp':'dflash2';
}
/** The engine arguments without the draft layer: --mtp <folder or count>, --spec-min-p <p> (its confidence threshold) and --batch-mtp. --spec stays: prompt lookup still uses its windows. Same rule as strata_control/draft_layer.py. */
export function withoutDraftLayer(args){
const out=[];const list=(args??[]).map(String);
+14
View File
@@ -8,3 +8,17 @@ export function runtimeVision(config,requested){
if(mode==='cpu'&&config.vision.cpu_exe){const {cpu_exe,...rest}=config.vision;return {...rest,exe:cpu_exe,gpu:false};} // AMD: the CPU reader program, not the HIP one with the GPU hidden
return {...config.vision,...(mode==='cpu'?{gpu:false}:mode==='gpu'?{gpu:true}:{})};
}
/**
* The catalog with each picture-capable size marked as ready (`images`) or not (`imagesCapable` stays true either way). The Qwen engines
* read pictures with a separate program (strata-vision) that has to sit in the engine folder; DeepSeek-V4.1-Flash (`multi_file`, its own
* engine) carries its own picture tower, so it is ready whatever that folder holds (#4772: it was told to update an engine that was fine).
*/
export function markPictures(catalog,visionReader){
if(!catalog||!Array.isArray(catalog.families))return catalog;
const ready=(s)=>!!s.images&&(s.multi_file===true||visionReader===true);
return {...catalog,families:catalog.families.map(f=>{
const sizes=(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:ready(s)}));
return {...f,imagesCapable:!!f.images,images:sizes.length?sizes.some(s=>s.images):!!f.images&&visionReader===true,sizes};
})};
}
+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 });
}
+42
View File
@@ -0,0 +1,42 @@
// 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 */ }
}
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;
}
+2 -2
View File
@@ -15,14 +15,14 @@ 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',
'elastic', 'elastic_idle_s', 'elastic_mib', 'min_free_vram_mib', 'vram_segment_mib', 'slot_save_path', 'telemetry_read_key', 'batch_telemetry',
'reasoning_effort', 'reasoning_close_retry', 'reasoning_loop_recovery', 'repeat_stop_tokens', 'tool_call_recovery', 'codex_compaction_cache',
'codex_thread_titles', 'expert_profile_save', 'expert_profile_save_every', 'images', 'vision', 'mmproj', 'draft_vocab', 'ui_resource_profile',
'ui_vision_enabled', 'component', 'native', 'uma', 'remote_expert_opt', 'diffusion_model', 'llm', 'vae', 'quant', 'type', 'url', 'timeout_s',
'ui_vision_enabled', 'ui_context_chosen', 'component', 'native', 'uma', 'remote_expert_opt', 'diffusion_model', 'llm', 'vae', 'quant', 'type', 'url', 'timeout_s',
]);
const BOOLS = new Set(['open_browser', 'prevent_sleep', 'elastic', 'env_to_user', 'gpus_asked', 'batch_telemetry', 'tool_call_recovery', 'reasoning_loop_recovery']);
+10 -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;
@@ -39,6 +39,8 @@ export function state() {
revision,
running,
baseUrl: sharing.active ? sharing.url : baseUrl(),
// The address on this computer, the one the app's own port serves, kept apart from the sharing address (#4798).
localUrl: baseUrl(),
sharing,
tunnel,
port,
@@ -83,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;
}
+1 -1
View File
@@ -218,7 +218,7 @@ export async function start({ modelId, modelPath, port = 8080, config, hardware,
const binary = await resolveBinary();
if (!binary) {
state.status = 'error';
state.error = 'No llama-server binary found. Download the engine from Settings → Engine.';
state.error = 'No llama-server binary found. Download the engine from Settings → About.';
throw new Error(state.error);
}
if (!existsSync(modelPath)) {
+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(); });
+30 -5
View File
@@ -20,8 +20,9 @@ export function looksOffline(error) {
}
export const OFFLINE_MESSAGE = "No internet connection. Try again when you're online.";
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata". */
export function plainUpdateError(error, subject = 'engine') {
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata".
* `retry` names the button of a screen that has no Settings page yet (the first-run card's Continue): the next step then says that button. */
export function plainUpdateError(error, subject = 'engine', retry = undefined) {
const raw = String(error?.message ?? error ?? '');
const code = error?.code ?? '';
const exit = error?.exitCode ?? null;
@@ -43,9 +44,18 @@ export function plainUpdateError(error, subject = 'engine') {
} else if (/could not be read|interrupted|timed out|timeout|ECONN|ENOTFOUND|fetch failed|could not reach|server answered|not answer within/i.test(raw)) {
message = "Couldn't reach the update server.";
next = `Check your internet connection, then press ${button} in Settings.`;
} else if (/free space|not enough/i.test(raw)) {
} else if (/free space|not enough|no space left|disk is full/i.test(raw)) {
message = 'There is not enough free disk space for the update.';
next = `Free some disk space, then press ${button} in Settings.`;
} else if (/could not be moved into place|could not be unpacked|not accepted after the switch/i.test(raw)) {
// The files were downloaded and checked; Windows or a virus scanner would not let Strata put them in place (#4853). Not a download problem.
const long = /longer than Windows allows|path.*too long/i.test(raw);
message = long
? "The engine's folder is too deep for Windows, so it could not be put in place."
: 'The engine was downloaded and checked, but Windows or a security program would not let Strata put it in place.';
next = long
? `Open Details, then follow the folder advice there, and press ${button} in Settings.`
: `Wait a minute, then press ${button} in Settings. If it happens again, pause your virus scanner's live scanning for a few minutes while it runs.`;
} else if (/no engine for this PC|has no engine|no package/i.test(raw)) {
message = "There isn't an engine for this PC's graphics card yet.";
next = 'Check for updates again later.';
@@ -56,11 +66,26 @@ export function plainUpdateError(error, subject = 'engine') {
message = `The update didn't pass its safety check, so it was not installed. ${keep}`;
next = 'Try again later.';
}
if (retry) next = next.split(`${button} in Settings`).join(retry).replace(/in Settings/g, '').replace(/ {2,}/g, ' ').replace(/ ([.,])/g, '$1');
return { message: `${message} ${next}`.trim(), headline: message, next, details: raw || undefined };
}
/** Plain words for a switch or a delete in Settings > Engine: { message, details }. Control's own sentence for "the engine in use" is already plain and is kept. */
export function plainEngineError(error, action = 'switch') {
const raw = String(error?.message ?? error ?? '');
const exit = error?.exitCode ?? null;
let message = action === 'delete' ? "The engine wasn't deleted. Nothing else was changed." : "The engine wasn't switched. Nothing was changed.";
if (exit === 7 || /is serving|model is running/i.test(raw)) message = 'A model is running, so nothing was changed. Unload the model in Models, then try again.';
else if (/engine in use/i.test(raw)) message = 'This is the engine in use. Switch to another one first, then delete it.';
else if (/running program is using|something is using it/i.test(raw)) message = 'A program is still using this engine, so it was not deleted. Unload your models and try again.';
else if (/not installed|already been deleted/i.test(raw)) message = 'That engine is already gone. The list has been refreshed.';
else if (/another update is already running/i.test(raw)) message = 'An update is running. Wait for it to finish, then try again.';
else if (exit === 5 || /can no longer be used|does not fit|cannot run/i.test(raw)) message = "This engine can't run on this computer, so nothing was changed.";
return { message, details: raw || undefined };
}
/** An Error carrying the plain message and, behind it, the technical reason. */
export function plainError(error, subject) {
const { message, details } = plainUpdateError(error, subject);
export function plainError(error, subject, retry = undefined) {
const { message, details } = plainUpdateError(error, subject, retry);
return Object.assign(new Error(message), { details, code: error?.code });
}
+2 -1
View File
@@ -64,7 +64,8 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
const installedEngine = engine.installed?.version ?? null;
const offered = engine.offered?.version ?? null;
const engineView = { installedVersion: installedEngine, latestVersion: offered ?? undefined, canInstall: engine.status === 'available' || engine.status === 'install' };
if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
if (engine.status === 'available' && engine.installed_locally) Object.assign(engineView, { status: 'available', message: 'A newer engine is already on this computer. Update switches to it; nothing is downloaded.' });
else if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
else if (engine.status === 'install') Object.assign(engineView, { status: 'available', message: `This PC needs an engine to run models. Press Update engine to download it (${mib(engine.download_bytes)}).` });
else if (engine.status === 'current' || engine.status === 'failed-before' || engine.status === 'skipped') Object.assign(engineView, { status: 'current', message: 'The engine is up to date.', details: engine.note });
else if (engine.status === 'wrong-make') Object.assign(engineView, { status: 'unavailable', message: engine.note ?? 'Nothing was changed: the update was for another make of graphics card.', details: (engine.reasons ?? []).join('\n') || undefined });
+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;
+7 -4
View File
@@ -9,7 +9,7 @@ import { Sidebar } from './Sidebar';
import { CommandPalette } from './CommandPalette';
import { ShortcutsDialog } from './Shortcuts';
import { FolderPathDialog } from '../components/FolderPathDialog';
import { handleAppShortcut, runCommand } from '../lib/commands';
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';
@@ -111,6 +111,9 @@ function ConnectionBar() {
* way out. Everything else on this screen is the conversation.
*/
function FocusBar() {
// The hints are chords, not decorations: on Windows the modifier is Ctrl and
// Alt is spelled out, because ⌘ and ⌥ are not on that keyboard.
const mac = MOD === '⌘';
const native = useNative(), nativeCurrent = native?.instances.find(i => i.id === native.defaultId);
const library = useStore((s) => s.library);
const monitor = useStore((s) => s.monitor);
@@ -139,11 +142,11 @@ function FocusBar() {
{/* A chord reads as one key: the two caps sit tight together, with
the space before the label doing the separating. */}
<span className="focusbar__chord">
<kbd>⌥↑</kbd>
<kbd>⌥↓</kbd>
<kbd>{mac ? '⌥↑' : 'Alt+↑'}</kbd>
<kbd>{mac ? '⌥↓' : 'Alt+↓'}</kbd>
</span>{tr("turns")}</span>
<span>
<kbd>⌘↓</kbd>{tr(" latest")}</span>
<kbd>{mac ? '⌘↓' : 'Ctrl+↓'}</kbd>{tr(" latest")}</span>
<span>
<kbd>{tr("Esc")}</kbd>{tr(" leave")}</span>
</span>
+6 -1
View File
@@ -136,7 +136,7 @@ export function CommandPalette({ open }: {
<input ref={inputRef} className="st-input st-input--bare" style={{ flex: 1 }} placeholder={tr("Search chats, screens or actions…")} aria-label={tr("Search commands")} aria-activedescendant={flat[cursor] ? `command-${flat[cursor].id}` : undefined} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}/>
<kbd className="kbd">{tr("esc")}</kbd>
</div>
<div style={{ overflowY: 'auto', padding: 8 }}>
<div className="palette__list">
{tr(flat.length === 0 ? (<div className="small muted" style={{ padding: 18 }}>{tr("Nothing matches “{0}”. Try > for commands, # for conversations, or @ for workspaces.", [query])}</div>) : (grouped.map(([group, items]) => (<div key={group} style={{ marginBottom: 6 }}>
<div className="navgroup__label" style={{ padding: '8px 12px 4px' }}>
{tr(group)}
@@ -167,6 +167,11 @@ export function CommandPalette({ open }: {
}))}
</div>))))}
</div>
<div className="palette__foot">
<span className="palette__hint"><kbd className="kbd">Enter</kbd>{tr("Open")}</span>
<span className="spacer"/>
<span className="palette__hint"><kbd className="kbd">esc</kbd>{tr("Close")}</span>
</div>
</div>
</>);
}
+16 -7
View File
@@ -7,10 +7,11 @@ 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';
import { cx, relTime } from '../lib/format';
import { cx, relTime, relTimeShort } from '../lib/format';
import { useMedia } from '../lib/responsive';
import { recentChats, recentChatDescription } from '../lib/recent-chats';
import type { Session } from '../lib/types';
@@ -18,7 +19,7 @@ import { MOD, runCommand } from '../lib/commands';
import { bindingFor, bindingKeys, useBindings } from '../lib/shortcut-bindings';
import { Btn, Dialog, Icon, IconBtn, type IconName } from '../components/ui';
import { Brand } from '../components/Brand';
import { addWorkspace, deleteSession, go, isUnread, pickWorkspace, renameSession, removeWorkspace, renameWorkspace, selectSession, setActiveWorkspace, setNavOpen, togglePin, toggleRail, setTheme, useStore } from '../lib/store';
import { addWorkspace, deleteSession, go, isUnread, liveChatIds, pickWorkspace, renameSession, removeWorkspace, renameWorkspace, selectSession, setActiveWorkspace, setNavOpen, togglePin, toggleRail, setTheme, useStore } from '../lib/store';
export function Sidebar({ containerRef }: {
containerRef: RefObject<HTMLElement | null>;
}) {
@@ -37,6 +38,9 @@ export function Sidebar({ containerRef }: {
const workspaces = useStore(s => s.workspaces);
const activeWorkspace = useStore(s => s.activeWorkspace);
const activeSession = useStore(s => s.activeSession);
// The chats that have a turn running now (the board reads the same list). The count is what makes this redraw when one starts or ends.
const liveCount = useStore(s => s.liveChats);
const working = new Set(liveCount ? liveChatIds() : []);
const [query, setQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const searchInput = useRef<HTMLInputElement>(null);
@@ -65,6 +69,10 @@ export function Sidebar({ containerRef }: {
const modelQuant = nativeSecond ?? current?.quant ?? tr('View model versions');
const phase = nativeCurrent ? (nativeCurrent.status === 'ready' ? nativeCurrent.phase : nativeCurrent.status) : monitor?.phase ?? 'idle';
const status = phase === 'unavailable' ? 'Status unavailable' : phase === 'error' ? 'Error' : phase === 'loading' ? 'Loading' : !current && !nativeCurrent ? 'No model loaded' : phase === 'reading' ? 'Reading prompt' : phase === 'thinking' ? 'Thinking' : phase === 'generating' ? 'Generating' : 'Ready';
// The model is busy with a chat that is not the one in front of the person: the chip says which one (a title, or how many).
const awayChats = [...working].filter(id => !(location.route === 'chat' && id === activeSession));
const awayTitle = awayChats.length === 1 ? sessions.find(s => s.id === awayChats[0]) : undefined;
const workingLine = awayChats.length === 1 && awayTitle ? tr("Working · {0}", [sessionTitle(awayTitle)]) : awayChats.length > 1 ? tr("{0} chats working", [awayChats.length]) : null;
const menuSession = sessions.find(s => s.id === menuId);
const q = query.trim().toLowerCase();
const matching = sessions.filter(s => (!s.archived || q || includeArchived) && (!q || `${sessionTitle(s)} ${s.messages.map(m => m.content).join(' ')}`.toLowerCase().includes(q))).sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || b.updatedAt.localeCompare(a.updatedAt));
@@ -92,7 +100,7 @@ export function Sidebar({ containerRef }: {
<div className={cx('ws__row', activeWorkspace === w.id && 'is-active')}><button className="ws__name" aria-expanded={expanded} onClick={() => { setActiveWorkspace(w.id); setFolders(f => ({ ...f, [w.id]: !expanded })); }}><Icon name="chevron" size={14} className={cx('ws__chev', expanded && 'is-open')}/><Icon name="folder" size={17}/><span>{w.name}</span></button><IconBtn name="pencil" title={tr("Rename {0}", [w.name])} onClick={() => { setWsName(w.name); setNavOpen(false); setRenameWsId(w.id); }}/>{tr(w.id !== 'root' ? <IconBtn name="x" title={tr("Remove {0} from sidebar", [w.name])} onClick={() => { setNavOpen(false); setRemoveWorkspaceId(w.id); }}/> : null)}</div>
{tr(expanded ? <div className="navsessions">
{tr(shown.map(s => <div key={s.id}>
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}><span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{tr(relTime(s.updatedAt))}</span>)}{tr(isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} data-working={working.has(s.id) || undefined} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}>{tr(working.has(s.id) ? <span className="navsession__working" role="img" aria-label={tr("Working")} title={tr("Working")}><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle className="navsession__track" cx="8" cy="8" r="6"/><circle className="navsession__arc" cx="8" cy="8" r="6" pathLength="100"/></svg></span> : null)}<span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{relTimeShort(s.updatedAt)}</span>)}{tr(!working.has(s.id) && isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
</div>))}
{tr(!shown.length ? <p className="small muted ws__empty">{tr(q ? 'No matching chats' : 'No chats yet')}</p> : null)}
{tr(!q && rows.length > shown.length ? <button className="ws-view small" onClick={() => { setNavOpen(false); setRecentOpen(true); }}>{tr("View all chats")}</button> : null)}
@@ -102,7 +110,7 @@ export function Sidebar({ containerRef }: {
</div> : null}
</div>
<div className="nav__foot">
<button className="enginechip" data-state={status === 'Ready' ? 'ready' : ['Reading prompt', 'Thinking', 'Generating', 'Loading'].includes(status) ? 'busy' : 'off'} onClick={() => go('models')} title={tr("{0} \u00B7 {1} \u00B7 {2}", [modelName, modelQuant, tr(status)])} aria-label={tr("{0} \u00B7 {1}", [modelName, modelQuant])}><span className="enginechip__family"><Icon name={modelFamilyIcon(current?.family)} size={20}/><span className="enginechip__dot"/></span><span className="enginechip__text nav__rail-hide"><span className="enginechip__title">{modelName}</span><span className="enginechip__sub">{modelQuant}</span></span></button>
<button className="enginechip" data-state={status === 'Ready' ? 'ready' : ['Reading prompt', 'Thinking', 'Generating', 'Loading'].includes(status) ? 'busy' : 'off'} onClick={() => go('models')} title={tr("{0} \u00B7 {1} \u00B7 {2}", [modelName, workingLine ?? modelQuant, tr(status)])} aria-label={tr("{0} \u00B7 {1}", [modelName, modelQuant])}><span className="enginechip__family"><Icon name={modelFamilyIcon(current?.family)} size={20}/><span className="enginechip__dot"/></span><span className="enginechip__text nav__rail-hide"><span className="enginechip__title">{modelName}</span><span className="enginechip__sub">{workingLine ?? modelQuant}</span></span></button>
<NavLink route="skills" label={tr("Skills")} icon="skills" current={location.route}/>
<NavLink route="monitor" label={tr("Monitor")} icon="activity" current={location.route}/>
<NavLink route="models" label={tr("Models")} icon="models" current={location.route === 'tuning' ? 'models' : location.route}/>
@@ -155,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);
@@ -190,5 +199,5 @@ function MoreMenu({ current }: {
const [quitOpen, setQuitOpen] = useState(false);
const appMode = useAppMode();
const ref = useRef<HTMLButtonElement>(null);
return <><button ref={ref} className="navitem nav__more" aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
return <><button ref={ref} className="navitem nav__more" title={tr("More — Serving and Settings")} aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} aria-current={['serving', 'settings'].includes(current) ? 'page' : undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
}
+63
View File
@@ -0,0 +1,63 @@
/** The card that asks the person to approve or deny one action of the assistant, and the dock that keeps it in view. */
import { useMemo } from 'react';
import { tr } from '../lib/i18n';
import { Btn, Icon } from './ui';
import { askQuestion, askSub, askIcon } from '../lib/ask';
import { resolveTool, useStore } from '../lib/store';
import { pendingApprovals } from '../lib/approvals';
import { QuestionCard } from './QuestionCard';
import type { AgentAsk } from '../lib/agent-ask';
import type { ToolCall } from '../lib/types';
const NO_ASKS: AgentAsk[] = [];
export const SCOPE: Record<ToolCall['scope'], {
label: string;
tone: 'plain' | 'info' | 'warn';
}> = {
read: { label: 'Reads', tone: 'plain' },
write: { label: 'Writes', tone: 'warn' },
shell: { label: 'Runs a command', tone: 'warn' },
connected: { label: 'Connected tool', tone: 'info' },
};
/** The one place the Approve and Deny buttons live. Chat shows it in the dock above the box, not in the middle of a long reply (#4725). */
export function ApprovalCard({ messageId, tool: t }: {
messageId: string;
tool: ToolCall;
}) {
const scope = SCOPE[t.scope];
if (t.ask)
return <section className="askcard" data-state={t.state} aria-label={tr(askQuestion(t.ask))}>
<div className="askcard__top"><span className="askcard__icon"><Icon name={askIcon(t.ask)} size={18}/></span>
<div className="askcard__text"><p className="askcard__q">{askQuestion(t.ask)}</p>{askSub(t.ask) ? <p className="askcard__why" title={askSub(t.ask)}>{askSub(t.ask)}</p> : null}</div>
<div className="askcard__acts"><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div></div>
<details className="askcard__details"><summary><Icon name="chevron" size={11}/>{tr("Details")}</summary><pre>{t.preview}</pre></details>
</section>;
return <section className="toolcard toolcard--compact" data-state={t.state} aria-label={t.name}>
<details className="toolcard__disclosure">
<summary><Icon name={t.scope === 'shell' ? 'bolt' : 'tool'} size={17}/><span className="toolcard__title">{tr(t.verb ?? t.name)}</span><span className="toolcard__scope xs muted">{tr(scope.label)}</span><Icon name="chevron" className="toolcard__chevron" size={15}/></summary>
<div className="toolcard__body"><code className="toolcard__preview">{t.preview}</code><p className="small muted">{tr(t.effect)}</p>{t.result ? <p className="small">{t.result}</p> : null}</div>
</details>
{t.target ? <code className="toolcard__what" title={t.target}>{t.target}</code> : null}
<div className="toolcard__quick-actions"><span className="xs muted">{tr("Needs your approval")}</span><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" icon="check" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div>
</section>;
}
/**
* Keeps an approval where the person is looking. A reply can run to many screens; the card used to sit above its text, so a
* person reading the end of it never saw that the assistant had stopped to ask. The dock sits directly above the message box and
* does not scroll with the conversation. Where the card was, the conversation says that it is waiting.
*/
export function ApprovalDock({ sessionId }: {
sessionId: string | undefined;
}) {
const session = useStore(s => s.sessions.find(x => x.id === sessionId));
const items = useMemo(() => pendingApprovals(session), [session]);
const asks = useStore(s => (sessionId ? s.agentAsks[sessionId] : undefined)) ?? NO_ASKS;
if (!items.length && !asks.length)
return null;
return <section className="approvaldock" role="group" aria-label={tr("Waiting for your answer")} data-count={items.length}>
{asks.map(a => <QuestionCard key={a.callId} sessionId={sessionId!} ask={a}/>)}
{items.map(({ messageId, tool }) => <ApprovalCard key={tool.id} messageId={messageId} tool={tool}/>)}
</section>;
}
+2 -1
View File
@@ -17,7 +17,8 @@ export function ArtifactCard({ artifact: a, onPreview }: {
setMenu(false);
setError('');
if (!canReveal && url && action === 'open') {
window.open(url, '_blank', 'noopener,noreferrer');
// The app window has no handler for a new window, so window.open does nothing there: the host opens the address in the default browser (#4721).
api.post('/api/artifacts/open', { action: 'open', url }).catch(() => { window.open(url, '_blank', 'noopener,noreferrer'); });
return;
}
setBusy(true);
+30 -10
View File
@@ -1,14 +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 { Markdown } from './Markdown';
import { CopyBtn, CopyMenuItem } from './CopyBtn';
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[];
@@ -19,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) {
@@ -60,17 +67,30 @@ 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);
useEffect(() => {
if (current) { hadCurrent.current = true; setLeaving(false); return; }
if (!hadCurrent.current || !inline) return;
hadCurrent.current = false;
if (matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.dataset.motion === 'reduced') return;
setLeaving(true);
// The leaving shell takes the 460 ms step of the motion scale (liquid.css); it is removed just after.
const timer = setTimeout(() => setLeaving(false), 520);
return () => clearTimeout(timer);
}, [!!current, inline]);
if (!current)
return null;
return leaving ? <aside className="artifact-panel" data-leaving="true" aria-hidden="true"/> : null;
const url = current.url ? safePreviewUrl(current.url) : null;
const viewControls = current.kind === 'markdown' ? <Seg ariaLabel={tr("Markdown view")} value={mode} options={[{ value: 'preview', label: 'Read' }, { value: 'source', label: 'Source' }]} onChange={setMode}/> : current.kind === 'image' ? <PreviewControls label={tr("Image")} zoom={imageSize} onZoom={setImageSize}/> : null;
const actions = <><div className="preview-file-actions__inline">{tr(current.kind === 'code' ? <IconBtn name="copy" title={tr("Copy code")} disabled={loading || !!error} onClick={() => void navigator.clipboard.writeText(text)}/> : null)}{tr(url ? <a className="st-btn st-btn--icon" href={url} target="_blank" rel="noreferrer" aria-label={tr("Open outside preview")} title={tr("Open outside preview")}><Icon name="link" size={17}/></a> : null)}<IconBtn name="refresh" title={tr("Reload preview")} onClick={() => setRevision(v => v + 1)}/></div><button ref={fileMenuRef} className="st-btn st-btn--icon preview-file-actions__more" aria-label={tr("Preview file actions")} title={tr("Preview file actions")} aria-haspopup="menu" aria-expanded={fileMenu} onClick={() => setFileMenu(v => !v)}><Icon name="ellipsis"/></button></>;
const actionMenu = fileMenu ? <FloatingMenu anchor={fileMenuRef.current} title={tr("Preview file actions")} onClose={() => setFileMenu(false)}>{tr(current.kind === 'code' ? <button role="menuitem" className="menu__item" disabled={loading || !!error} onClick={() => { void navigator.clipboard.writeText(text); setFileMenu(false); }}><Icon name="copy"/>{tr("Copy code")}</button> : null)}{tr(url ? <a role="menuitem" className="menu__item" href={url} target="_blank" rel="noreferrer" onClick={() => setFileMenu(false)}><Icon name="link"/>{tr("Open outside preview")}</a> : null)}<button role="menuitem" className="menu__item" onClick={() => { setRevision(v => v + 1); setFileMenu(false); }}><Icon name="refresh"/>{tr("Reload preview")}</button></FloatingMenu> : null;
const actions = <><div className="preview-file-actions__inline">{tr(current.kind === 'code' ? <CopyBtn text={text} label="Code" title="Copy code" disabled={loading || !!error}/> : null)}{tr(url ? <a className="st-btn st-btn--icon" href={url} target="_blank" rel="noreferrer" aria-label={tr("Open outside preview")} title={tr("Open outside preview")}><Icon name="link" size={17}/></a> : null)}<IconBtn name="refresh" title={tr("Reload preview")} onClick={() => setRevision(v => v + 1)}/></div><button ref={fileMenuRef} className="st-btn st-btn--icon preview-file-actions__more" aria-label={tr("Preview file actions")} title={tr("Preview file actions")} aria-haspopup="menu" aria-expanded={fileMenu} onClick={() => setFileMenu(v => !v)}><Icon name="ellipsis"/></button></>;
const actionMenu = fileMenu ? <FloatingMenu anchor={fileMenuRef.current} title={tr("Preview file actions")} onClose={() => setFileMenu(false)}>{tr(current.kind === 'code' ? <CopyMenuItem text={text} label="Code" disabled={loading || !!error} onClose={() => setFileMenu(false)}>Copy code</CopyMenuItem> : null)}{tr(url ? <a role="menuitem" className="menu__item" href={url} target="_blank" rel="noreferrer" onClick={() => setFileMenu(false)}><Icon name="link"/>{tr("Open outside preview")}</a> : null)}<button role="menuitem" className="menu__item" onClick={() => { setRevision(v => v + 1); setFileMenu(false); }}><Icon name="refresh"/>{tr("Reload preview")}</button></FloatingMenu> : null;
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 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>)}
<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}-${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>;
+1 -1
View File
@@ -20,7 +20,7 @@ function NativeChatModelPicker() {
const [open, setOpen] = useState(false), [error, setError] = useState('');
const ref = useRef<HTMLButtonElement>(null);
const current = native?.instances.find(i => i.id === native.defaultId);
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span></button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span>{tr(current?.id === i.id ? <Icon name="check" size={16} className="menu__check"/> : null)}</button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
}
function LegacyChatModelPicker() {
const native = useNative();
+1 -1
View File
@@ -39,7 +39,7 @@ export function ChatToolbar({ session, items, selected, onSelect, onClose, onClo
return <header className="chat-toolbar" style={previews && !expanded && previewWidth ? { gridTemplateColumns: `minmax(0,1fr) ${previewWidth}px` } : undefined} data-timeskip={selected?.startsWith('time-skip:') || undefined} data-preview={previews || undefined} data-expanded={expanded && previews || undefined}>
<div className="chat-toolbar__conversation"><h1 title={title}>{title}</h1><ChatModelPicker /><button ref={agentsRef} className="chat-agents" title={tr(count ? `${count} subagents running · View overview` : 'View subagent overview')} aria-label={tr(count ? `${count} subagents running — show overview` : 'Subagents — show overview')} aria-haspopup="dialog" aria-expanded={agents} data-working={count > 0 || undefined} onClick={() => setAgents(v => !v)}><Icon name="experts" size={17}/>{tr(count > 0 ? <span className="agent-live-dot"/> : null)}<span className="chat-agents__label">{tr("Subagents")}</span>{tr(session?.agents?.length ? <span className="chat-agents__count">{tr(count || session.agents.length)}</span> : null)}<Icon name="chevron" className={agents ? 'is-open' : ''} size={13}/></button><span className="spacer"/><button ref={menuRef} className="st-btn st-btn--icon" title={tr("Chat options")} aria-label={tr("Chat options")} aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(v => !v)}><Icon name="ellipsis"/></button>{tr(!selected && (items.length || extraTabs.length) ? <IconBtn name="panel" title={tr("Open previews")} onClick={() => onSelect(extraTabs.at(-1)?.id ?? items[0].id)}/> : null)}</div>
{tr(previews ? <div className="chat-toolbar__previews"><ArtifactTabs extraTabs={extraTabs} onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/><div className="preview-window-actions"><div id="preview-file-actions" className="preview-file-actions"/><IconBtn name={expanded ? 'contract' : 'expand'} title={tr(expanded ? 'Exit full screen preview' : 'Full screen preview')} onClick={onExpand}/><IconBtn name="panel" title={tr("Collapse preview sidebar")} onClick={onClose}/></div></div> : null)}
{tr(agents ? <Popover anchor={agentsRef.current} title={tr("Subagents")} detail={session?.agents?.length ? <>{tr(count)}{tr(" running \u00B7 ")}{tr(session.agents.filter(a => a.status === 'completed').length)}{tr(" completed \u00B7 ")}{tr(session.agents.length)}{tr(" total")}</> : undefined} width={460} onClose={() => setAgents(false)}>{tr(session?.agents?.length ? <>{tr(session.agents.map(a => <SubagentRow key={a.id} agent={a} now={now}/>))}</> : <div className="subagents-empty"><Icon name="experts" size={24}/><strong>{tr("No subagents recorded")}</strong><p className="small muted">{tr("Subagents that run in this conversation appear here, with their progress, token usage and elapsed time. Completed runs stay visible.")}</p></div>)}</Popover> : null)}
{tr(agents ? <Popover anchor={agentsRef.current} title={tr(session?.agents?.length ? "Subagents" : "No subagents recorded")} detail={session?.agents?.length ? <>{tr(count)}{tr(" running \u00B7 ")}{tr(session.agents.filter(a => a.status === 'completed').length)}{tr(" completed \u00B7 ")}{tr(session.agents.length)}{tr(" total")}</> : undefined} width={460} onClose={() => setAgents(false)}>{tr(session?.agents?.length ? <>{tr(session.agents.map(a => <SubagentRow key={a.id} agent={a} now={now} sessionId={session && !session.showcase ? session.id : undefined}/>))}</> : null)}</Popover> : null)}
{tr(menu ? <FloatingMenu anchor={menuRef.current} title={tr("Chat options")} onClose={() => setMenu(false)}><button role="menuitem" className="menu__item" disabled={!!remote} title={tr(remote ? "API models use their provider’s response settings." : undefined)} onClick={() => { setMenu(false); setSettings(true); }}><Icon name="sliders"/>{tr("Response settings")}</button><button role="menuitem" className="menu__item" disabled={!session} onClick={() => { if (session)
downloadSessionLog(session); setMenu(false); }}><Icon name="download"/>{tr("Download session log")}</button></FloatingMenu> : null)}
<SamplingDrawer open={settings} onClose={() => setSettings(false)}/>
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useEffect, useRef, useState } from 'react';
import { Btn, ErrorNotice, Dialog, Icon, IconBtn } from './ui';
import { Btn, ErrorNotice, Dialog, Icon } from './ui';
import { CopyBtn } from './CopyBtn';
import { api } from '../lib/client';
import { setCloudflare, useStore } from '../lib/store';
import type { TunnelState } from '../lib/types';
@@ -84,7 +85,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
</>}>
<div className="tunnel-wizard">
{tr(!active ? <ol className="tunnel-progress" aria-label={tr("Connection progress")}>{tr(steps.map((label, i) => <li key={label} data-current={step === i || undefined} data-complete={step > i || undefined}><button disabled={i >= step || pending} aria-current={step === i ? 'step' : undefined} onClick={() => { setError(''); setStep(i); }}><span>{tr(step > i ? <Icon name="check" size={13}/> : i + 1)}</span><small>{tr(label)}</small></button></li>))}</ol> : null)}
{tr(active ? <section className="tunnel-stage tunnel-stage--status" key={tunnel?.status} aria-live="polite"><span className="tunnel-stage__symbol"><Icon name={tunnel?.status === 'connected' ? 'check' : 'clock'} size={26}/></span><h3 className="h3">{tr(tunnel?.status === 'connected' ? 'You’re connected' : tunnel?.status === 'stopping' ? 'Stopping the tunnel…' : 'Connecting to Cloudflare…')}</h3>{tr(tunnel?.url ? <><p className="small muted">{tr("Use this address and your Strata API key in your other app.")}</p><div className="install-command"><code>{tunnel.url}</code><IconBtn name="copy" title={tr("Copy tunnel URL")} onClick={() => void navigator.clipboard.writeText(tunnel.url!)}/></div><p className="xs muted">{tr(tunnel.mode === 'quick' ? 'Temporary address · turn off streaming in your client.' : `Your Cloudflare route must point to ${tunnel.originUrl}.`)}</p></> : <p className="small muted">{tr("This usually takes a few seconds. You can close this window while it connects.")}</p>)}</section> : <section className="tunnel-stage" key={step} aria-labelledby="tunnel-step-title">
{tr(active ? <section className="tunnel-stage tunnel-stage--status" key={tunnel?.status} aria-live="polite"><span className="tunnel-stage__symbol"><Icon name={tunnel?.status === 'connected' ? 'check' : 'clock'} size={26}/></span><h3 className="h3">{tr(tunnel?.status === 'connected' ? 'You’re connected' : tunnel?.status === 'stopping' ? 'Stopping the tunnel…' : 'Connecting to Cloudflare…')}</h3>{tr(tunnel?.url ? <><p className="small muted">{tr("Use this address and your Strata API key in your other app.")}</p><div className="install-command"><code>{tunnel.url}</code><CopyBtn text={tunnel.url!} label="Tunnel URL" title="Copy tunnel URL"/></div><p className="xs muted">{tr(tunnel.mode === 'quick' ? 'Temporary address · turn off streaming in your client.' : `Your Cloudflare route must point to ${tunnel.originUrl}.`)}</p></> : <p className="small muted">{tr("This usually takes a few seconds. You can close this window while it connects.")}</p>)}</section> : <section className="tunnel-stage" key={step} aria-labelledby="tunnel-step-title">
<h3 ref={heading} id="tunnel-step-title" className="h3" tabIndex={-1}>{tr(titles[step])}</h3>
{tr(step === 0 ? <><p className="small muted">{tr("Give your local API an HTTPS address that another app can reach.")}</p><div className="tunnel-choices" role="radiogroup" aria-label={tr("Cloudflare connection type")}>{tr([{ value: 'quick' as const, title: 'Free temporary URL', detail: 'No account or domain needed. Good for a quick connection.', icon: 'bolt' as const }, { value: 'domain' as const, title: 'My own domain', detail: 'A stable address on a domain you manage with Cloudflare.', icon: 'link' as const }].map(choice => <button key={choice.value} role="radio" aria-checked={mode === choice.value} tabIndex={mode === choice.value ? 0 : -1} onKeyDown={e => { if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
@@ -93,7 +94,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
} }} onClick={() => setMode(choice.value)}><Icon name={choice.icon} size={20}/><span><strong>{tr(choice.title)}</strong><small>{tr(choice.detail)}</small></span><span className="tunnel-choice-dot">{tr(mode === choice.value ? <Icon name="check" size={13}/> : null)}</span></button>))}</div></> : null)}
{tr(step === 1 ? <div className="tunnel-automatic" data-ready={installed || undefined} aria-live="polite" aria-busy={pending}><span className="tunnel-stage__symbol"><Icon name={installed ? 'check' : error ? 'warning' : 'refresh'} size={28}/></span><p className="small muted">{tr(installed ? 'Your Cloudflare connector is installed and ready.' : error ? 'Automatic setup needs another try.' : 'Strata downloads and sets up the Cloudflare connector for you.')}</p>{tr(installed ? <>{tr(tunnel?.connector ? <p className="xs muted">{tr("Using {0}", [tunnel.connector.summary])}</p> : null)}{tr(other ? <p className="xs muted" role="status">{tr(other.others.length === 1 && !other.own.length ? "cloudflared is already running for another tunnel (PID {0})." : other.own.length ? "Strata’s tunnel for this address is already running (PID {0})." : "cloudflared is already running for other tunnels (PIDs {0}).", [(other.own.length ? other.own : other.others).join(", ")])}</p> : null)}</> : error ? <Btn onClick={() => setRetry(v => v + 1)}>{tr("Retry setup")}</Btn> : <span className="small muted" role="status">{tr("Setting up\u2026")}</span>)}</div> : null)}
{tr(step === 2 ? <><p className="small muted">{tr(keyReady ? "Only apps with your Strata API key can use this connection." : "Without an API key, anyone who finds the link can use your model.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready. You can also continue without one, but anyone who finds the link can then use your model.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key (recommended)")}</Btn></div>)}</> : null)}
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><IconBtn name="copy" title={tr("Copy tunnel service URL")} disabled={!tunnel?.originUrl} onClick={() => void navigator.clipboard.writeText(tunnel!.originUrl)}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><CopyBtn text={tunnel?.originUrl ?? ''} label="Tunnel service URL" title="Copy tunnel service URL" disabled={!tunnel?.originUrl}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr(keyReady ? "API key required" : "No API key: anyone with the link can use it")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
</section>)}
{tr(error || tunnel?.error ? <ErrorNotice message={error || tunnel?.error || 'Connection failed'} next={tr("Check your internet connection, then retry.")}/> : null)}
@@ -0,0 +1,15 @@
import { tr } from '../lib/i18n';
import { cx } from '../lib/format';
import { Icon } from './ui';
import { useSelectedPreviewId } from '../lib/code-preview';
import type { PreviewArtifact } from '../lib/artifacts';
/** The preview control of a code block: an icon button like Copy, on its left. It shows both states: closed, and open (filled, "Close preview"). */
export function CodePreviewBtn({ artifact, onPreview, onClose }: { artifact: PreviewArtifact; onPreview: (a: PreviewArtifact) => void; onClose?: (id: string) => void }) {
const open = useSelectedPreviewId() === artifact.id;
const label = tr(open ? 'Close preview' : 'Open preview');
return <button type="button" className={cx('st-btn st-btn--icon', open && 'is-open')} data-open={open || undefined} aria-pressed={open} aria-label={label} title={label}
onClick={() => (open && onClose ? onClose(artifact.id) : onPreview(artifact))}>
<Icon name="panel" size={19}/>
</button>;
}
@@ -45,6 +45,25 @@ export function ComposerControls({ session, onPreview, onAttach, onError, onSlas
const remote = useStore(s => s.remoteModel), providers = useStore(s => s.providers);
const apiModel = providers?.connections.find(c => c.id === remote?.connectionId)?.models.find(m => m.id === remote?.modelId);
const [menu, setMenu] = useState(false), [preferences, setPreferences] = useState<'mode' | 'thinking' | null>(null), [reading, setReading] = useState(false);
// The mode menu prints 1–3 beside its rows. They were decoration: make those
// keys pick the mode while the menu is open, and leave modified keys to the app.
useEffect(() => {
if (preferences !== 'mode')
return;
const onKey = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey)
return;
const option = modes[Number(event.key) - 1];
if (!option)
return;
event.preventDefault();
setChatPreferences(session?.id, { mode: option.value });
setPreferences(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [preferences, session?.id]);
const [urlOpen, setUrlOpen] = useState(false), [url, setUrl] = useState(''), [error, setError] = useState('');
const files = useRef<HTMLInputElement>(null), folder = useRef<HTMLInputElement>(null), previewFile = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null), modeTrigger = useRef<HTMLButtonElement>(null), thinkingTrigger = useRef<HTMLButtonElement>(null);
+31
View File
@@ -0,0 +1,31 @@
/** The Copy controls of the app, with one shared behaviour: the control itself shows that the copy worked (#4748). */
import { createContext, useContext } from 'react';
import { tr } from '../lib/i18n';
import { cx } from '../lib/format';
import { COPIED_MENU_MS, useCopy } from '../lib/use-copy';
import { Icon } from './ui';
type Source = string | (() => string | Promise<string>);
/** A round icon button that copies `text`: the icon turns into a check mark for a moment, and a screen reader hears "Copied". */
export function CopyBtn({ text, label, title, disabled, size = 19 }: { text: Source; label: string; title: string; disabled?: boolean; size?: number }) {
const { copied, copy } = useCopy();
return <button type="button" className={cx('st-btn st-btn--icon', copied && 'is-copied')} data-copied={copied || undefined} title={tr(copied ? 'Copied' : title)} aria-label={tr(title)} disabled={disabled} onClick={() => void copy(text, label)}>
<Icon name={copied ? 'check' : 'copy'} size={size}/>
<span className="sr-only" role="status">{copied ? tr('Copied') : ''}</span>
</button>;
}
/** What closes the menu a menu item is in; Chat's message menu provides it. */
export const MenuCloseContext = createContext<() => void>(() => {});
/** A menu item that copies `text`: it says "Copied" for a moment, and then the menu closes. */
export function CopyMenuItem({ text, label, children, disabled, onClose }: { text: Source; label: string; children: string; disabled?: boolean; onClose?: () => void }) {
const { copied, copy } = useCopy(COPIED_MENU_MS);
const close = useContext(MenuCloseContext);
return <button role="menuitem" className="menu__item" data-copied={copied || undefined} disabled={disabled} onClick={e => {
// The menu would close on this click before the person sees that it worked.
e.stopPropagation();
void copy(text, label).then(ok => { setTimeout(onClose ?? close, ok ? COPIED_MENU_MS : 0); });
}}><Icon name={copied ? 'check' : 'copy'}/>{tr(copied ? 'Copied' : children)}</button>;
}
+71 -6
View File
@@ -1,10 +1,14 @@
import { useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { tr } from '../lib/i18n';
import { fileAction, fileSize, fileTypeLabel, previewOf, rawUrlOf, type ActivityGroup, type Deliverable } from '../lib/deliverables';
import { deliveryFolder, dirOf, fileAction, fileSize, fileTypeLabel, previewOf, rawUrlOf, rootOf, type ActivityGroup, type Deliverable } from '../lib/deliverables';
import { useChatFolder } from '../lib/chat-folder';
import type { PreviewArtifact } from '../lib/artifacts';
import { draftSize, groupFromDraft } from '../lib/draft-view';
import type { ToolDraft } from '../lib/types';
import { pushToast } from '../lib/store';
import { plainFailureText } from '../lib/plain-errors';
import { FloatingMenu } from './FloatingMenu';
import { CopyMenuItem } from './CopyBtn';
import { Icon } from './ui';
const problem = (title: string, e: unknown) => pushToast('error', tr(title), plainFailureText(e instanceof Error ? e.message : String(e)).message);
@@ -36,7 +40,7 @@ export function FileCard({ file, sessionId, onPreview }: { file: Deliverable; se
{tr(menu ? <FloatingMenu anchor={more.current} title={tr("File actions")} width={220} onClose={() => setMenu(false)}>
<button role="menuitem" className="menu__item" onClick={() => { setMenu(false); void showOf(file, sessionId, onPreview, file.kind); }}><Icon name="panel"/>{tr("Open in sidebar")}</button>
<button role="menuitem" className="menu__item" onClick={() => void run(() => fileAction.open(sessionId, file.path, 'folder'), 'The folder was not opened')}><Icon name="folder"/>{tr("Show in folder")}</button>
<button role="menuitem" className="menu__item" onClick={() => void run(async () => { await navigator.clipboard.writeText(await fileAction.fullPath(sessionId, file.path)); pushToast('info', tr('Path copied')); }, 'The path was not copied')}><Icon name="copy"/>{tr("Copy path")}</button>
<CopyMenuItem text={() => fileAction.fullPath(sessionId, file.path)} label="Path" onClose={() => setMenu(false)}>Copy path</CopyMenuItem>
<button role="menuitem" className="menu__item" onClick={() => void run(async () => { await fileAction.attach(sessionId, file.path); pushToast('info', tr('Attached to your next message'), file.name); }, 'The file was not attached')}><Icon name="attach"/>{tr("Attach as context")}</button>
</FloatingMenu> : null)}
</div>;
@@ -56,6 +60,35 @@ export function EditedFiles({ files, sessionId, onPreview }: { files: Deliverabl
</div>;
}
/**
* A run that made many files (379 after one stopped agent run) as one compact block instead of a card per file: where the result was delivered, the folder the
* chat works in, and the count ("379 files") that opens the full list. The list is closed by default, scrolls inside the block, and every row is a button.
*/
export function FilesSummary({ files, answer, sessionId, onPreview }: { files: Deliverable[]; answer: string; sessionId: string; onPreview: (a: PreviewArtifact) => void }) {
const [open, setOpen] = useState(false), id = useId(), folder = useChatFolder(sessionId);
const delivery = deliveryFolder(files, answer);
const rootPath = folder?.path ?? rootOf(files[0]), rootName = folder?.name ?? (rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? '');
const added = files.reduce((n, f) => n + f.added, 0), removed = files.reduce((n, f) => n + f.removed, 0);
const openFolder = (path: string) => void fileAction.open(sessionId, path, 'app').catch(err => problem('The folder was not opened', err));
const places: { key: string; label: string; name: string; full: string; open: string }[] = [];
if (delivery) places.push({ key: 'delivered', label: 'Delivered to', name: delivery, full: rootPath ? `${rootPath}/${delivery}` : delivery, open: delivery });
if (rootPath) places.push({ key: 'working', label: delivery ? 'Working folder' : 'Delivered to', name: rootName, full: rootPath, open: rootPath });
return <section className="fsum" aria-label={tr("Files")}>
{places.length ? <ul className="fsum__places">{places.map(p => <li key={p.key}>
<Icon name="folder" size={16}/><span className="fsum__label">{tr(p.label)}</span><span className="fsum__name" title={p.full}>{p.name}</span>
<button type="button" className="fsum__open" aria-label={tr("Open {0}", [p.name])} onClick={() => openFolder(p.open)}>{tr("Open")}</button>
</li>)}</ul> : null}
<button type="button" className="fsum__toggle" aria-expanded={open} aria-controls={id} onClick={() => setOpen(v => !v)}>
<Icon name="chevron" size={14} className="fsum__chev"/><span>{tr('{0} files', [files.length.toLocaleString()])}</span>{added || removed ? <Plusminus added={added} removed={removed}/> : null}
</button>
{open ? <ul className="fsum__list" id={id}>{files.map(f => { const dir = dirOf(f.path); return <li key={f.path}>
<button type="button" className="fsum__file" title={f.path} onClick={() => void showOf(f, sessionId, onPreview, f.kind)}>
<span className="fsum__type">{fileTypeLabel(f)}</span><span className="fsum__fname">{f.name}</span>{dir ? <span className="fsum__dir">{dir}</span> : null}<span className="fsum__size">{fileSize(f.size)}</span>
</button>
</li>; })}</ul> : null}
</section>;
}
/**
* The saved skills an answer used, as one quiet line under the answer in the same style as the activity line ("Ran 2 commands, read a file"):
* "Used 2 skills: Weekly report, Brand voice"; opened, each skill with its one-line description.
@@ -79,13 +112,45 @@ export function ActivityLine({ group, sessionId, onPreview }: { group: ActivityG
const picture = group.files.find(f => f.kind === 'image');
// The host's sentence may end with the numbers ("… +225 −0"): they are drawn as figures instead.
const sentence = group.line.replace(/\s*\+\d+\s*[−-]\s*\d+\s*$/, '');
return <div className="act" data-open={open || undefined}>
// While the run is open this is the live step: the host's own words for it when it sends them, else the line it already had.
const live = !group.closed;
return <><div className="act" data-open={open || undefined} data-live={live || undefined}>
<button type="button" className="act__line" aria-expanded={open} disabled={!group.files.length} onClick={() => setOpen(v => !v)}>
<span>{sentence}</span>{group.added || group.removed ? <Plusminus added={group.added} removed={group.removed}/> : null}
<span>{live && group.live?.label ? group.live.label : sentence}</span>{live && group.live?.bytes ? <span className="act__meta">{draftSize(group.live.bytes)}{group.live.lines && group.live.lines > 1 ? <> {"\u00B7"} {tr("{0} lines", [group.live.lines])}</> : null}</span> : null}{group.added || group.removed ? <Plusminus added={group.added} removed={group.removed}/> : null}
{group.files.length ? <Icon name="chevron" size={14} className="act__chev"/> : null}
{!group.closed ? <span className="act__live" aria-hidden="true"/> : null}
</button>
{picture ? <img className="act__thumb" src={rawUrlOf(sessionId, picture.path)} alt="" loading="lazy" onClick={() => void showOf(picture, sessionId, onPreview)}/> : null}
{open ? <ul className="act__files">{group.files.map(f => <li key={f.path}><button type="button" onClick={() => void showOf(f, sessionId, onPreview, f.kind)}><Icon name="file" size={14}/>{f.name}{f.added || f.removed ? <Plusminus added={f.added} removed={f.removed}/> : null}</button></li>)}</ul> : null}
</div>;
</div>
{live && group.live?.output ? <p className="act__output" title={group.live.output}>{group.live.output}</p> : null}
{live && group.live?.tail !== undefined ? <div className="act__tail" aria-hidden="true">{group.live.tail.split('\n').filter(Boolean).map((line, i) => <span key={i}>{line}</span>)}</div> : null}
</>;
}
/** The value, at most every `ms` (a file arrives about five times a second; the eye needs two). A cleared value is shown at once. */
function useThrottled<T>(value: T, ms: number, cleared: (v: T) => boolean): T {
const [shown, setShown] = useState(value), last = useRef(0), timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
clearTimeout(timer.current);
if (cleared(value)) { setShown(value); return; }
const wait = Math.max(0, last.current + ms - Date.now());
timer.current = setTimeout(() => { last.current = Date.now(); setShown(value); }, wait);
return () => clearTimeout(timer.current);
}, [value, ms]);
return shown;
}
/**
* The files (or commands) the model is writing right now, from `message.drafts`: each is one open activity line with the file's name, its growing size
* and the last lines of its text. It draws nothing while there is nothing being written, and takes the place of the ordinary card when the call is whole.
*/
export function DraftLines({ drafts, sessionId, onPreview }: { drafts: ToolDraft[] | undefined; sessionId: string; onPreview: (a: PreviewArtifact) => void }) {
const shown = useThrottled(drafts, 350, v => !v?.length);
if (!drafts?.length || !shown?.length) return null;
return <>{shown.map(d => {
const file = d.target ? d.target.split(/[\\/]/).filter(Boolean).at(-1) : '';
const verb = tr(d.verb ?? (d.scope === 'shell' ? 'Running a command' : 'Writing a file'));
return <ActivityLine key={d.id} group={groupFromDraft(d, file ? `${verb} · ${file}` : verb)} sessionId={sessionId} onPreview={onPreview}/>;
})}</>;
}
Loaded 100 of 1056 files, more files were not shown because too many files have changed in this diff. Show more