Compare commits

...
387 Commits
Author SHA1 Message Date
Niko1221 96ca4383c7 Merge branch 'f-dsv41-tune' into f-024-rel 2026-10-24 03:17:28 +02:00
Niko1221 2ecdc2e511 launch: an installed DeepSeek model whose config names a gone engine, with no package in this install, is not 'already installed': setup runs (feed download, or one plain line with the next step) instead of the start failing on a missing file; tests 2026-10-24 02:58:01 +02:00
Niko1221 5353097c90 DeepSeek quality numbers from the final-tip scores (H100, 4,995 positions): 2.00 KL 0.170 ppl +15.5% (code +8.6%, agent +5.3%), 2.25 KL 0.107 ppl +7.3% (code +3.2%, agent +2.2%); README x7, DEEPSEEK41.md, release notes, setup and fit text 2026-10-24 02:57:14 +02:00
Niko1221 9011bd107a launch: DeepSeek runs on this install's own engine package, not the exe path baked into an installed model's config (which can come from another Strata install on the computer or name a package folder an update replaced); tests 2026-10-24 02:56:16 +02:00
Niko1221 52b104b1ac IQ1_M quality from the H100 (KL 0.217, ppl 3.80, +21%; code +8.9%, agent +10.2%, weaker on plain prose) in setup, the fit text, README x7, DEEPSEEK41.md and the release notes 2026-10-24 02:37:08 +02:00
Niko1221 00a2cfa079 launch: a reason joined after 'The engine could not be installed.' starts with a capital; test 2026-10-24 02:36:17 +02:00
Niko1221 6eaf32075c Merge f-024-maya2 (6c6267f33) into f-024-rel: Windows batch submit, flow pump, Stop during a long prompt, lane-free, opt-in switches (all in src/dsv41)
Five conflicts, resolved keeping every f-024-rel behaviour (slots pin, long-prompt skip, HIPK, ZC fix) and maya2's:
- ds4_main.cpp, request struct: both fields kept (pin=N from f-024-slots and dspark_cum from maya2).
- ds4_main.cpp, request parser: both keys kept (pin, dspark_cum/spec_cum).
- ds4_main.cpp, prompt feed loop: slots' pin_at (the read stops at N for the shared-start checkpoint 3) and maya2's Stop handling (checkpoint 1 before the prompt, set_cancel, the stop check at the top of each feed) combined: the loop checks Stop first, then sizes the feed with the pin cut (n is no longer const).
- ds4_model.cu, forward chunk loop (two places): the skip's 'run.runs(k, il)' (an early chunk skipped past the yoco layer) kept, and maya2's halt_now() / halt_end(di, err) check added after it, so a skipped layer does no halt work.
win_submit (ds4_kernels.cu) is compiled to a no-op outside _WIN32 (#if defined(_WIN32) ... else (void) s), so it is already out of the Linux CUDA and Linux HIP builds.
2026-10-24 02:35:40 +02:00
Niko1221 698a87d999 Merge branch 'f-024-tui' into f-024-rel 2026-10-24 02:34:57 +02:00
Niko1221 6c6267f33c dsv41: Maya-port defaults from the owner-PC A/Bs (ON: win_submit, flow pump, Stop, lane-free; OFF with switch: timer, Engram readers, unbuffered Engram, cold prefetch, DSpark cum)
Owner PC (RTX 5070 12 GB, Ryzen 5 7600, official 2.00 pack, shipped profile-2.00.bin, 3 alternating pairs, medians, off = every switch off):
 4K prompt, off 92.64 tok/s: win_submit 99.12 (+7.0%, 3/3 pairs), flow pump 97.37 (+5.1%, 3/3), 64 Engram readers 96.64 (+4.3%, 3/3),
   unbuffered Engram 85.56 (-7.6%, 0/3), all on 86.84 (-6.3%).
 code decode, off 4.51: timer 4.51 (1/3), 64 readers 4.41 (2/3), unbuffered 4.67 (2/3, one pair 3.83), all 4.57.
 mmap decode, off 1.95: cold-page prefetch 1.71 (-12%, 0/3).
 Cold (no profile) agreed on win_submit and flow pump (+4.5%, +2.7%, 2/2) and showed the timer +12%, readers + unbuffered +22% on decode (2/2, off arm noisy).
 Identity: --vram-experts 0, off vs all IDENTICAL (4K prompt, code decode); text equal to off in every warm arm.
 Stop during a long prompt (H100, 8K and 16K): STOP answered in 0.12 / 0.16 s, the conversation before the prompt kept, the next request identical.
 DSpark cum cut: H100 code 20.13 tok/s off vs 19.60 at 0.5, no gain.
ON: DS4_WIN_SUBMIT, DS4_FLOW_PUMP_WAIT, Stop, STRATA_DS4_LANE_FREE (a no-op on the physical-core lane).
OFF, switch turns it on: DS4_TIMER_MS=1, DS4_ENGRAM_THREADS=64, DS4_EG_DIRECT=1, DS4_IQ_PF_COLD=1, STRATA_DS4_DSPARK_CUM=0.5.
2026-10-24 02:29:57 +02:00
Niko1221 cec75af6ae launch: the Save screen shows DeepSeek's own tuning length (5-10 / 10-15 min) like the Tuning screen; walk test: save text, run paths, error paths 2026-10-24 02:29:04 +02:00
Niko1221 8ac2a8385e tests: the 1.75 bpw set also runs with --tp 1 on 4 AMD cards; release notes: set up again on 4+ AMD cards 2026-10-24 02:24:31 +02:00
Niko1221 894dc24c6c Merge branch 'f-024-mgpu' into f-024-rel 2026-10-24 02:23:57 +02:00
Niko1221 c4dcde7145 dsv41 iq test: IQ1_S set gate_up (f16) tolerance 1.5e-2 (data-order dependent, 7.1e-3..1.08e-2, same on the older build); fix a stale comment in ds4_model.cu (IQ1_S/IQ1_M/IQ4_NL have device kernels) 2026-10-24 02:18:45 +02:00
Niko1221 67d5f44bf8 dsv41 plan: four or more AMD cards run the IST-DAS sets with --tp 1 (8K prompt 370 against 196 tokens a second on 4 x R9700) 2026-10-24 02:17:25 +02:00
Niko1221 6a35eb6c5f release notes: three sets everywhere, the DeepSeek engine is separate, Mac Control 0.1.49 2026-10-24 02:15:32 +02:00
Niko1221 12d0a4a051 Merge f-dsv41-tune (c5e0e1482) into f-024-rel: the third set IST-DAS 1.75 bpw (IQ1_M) in setup, fit and the launch list
One conflict, docs/DEEPSEEK41.md (both sides edited the three-set paragraph and the file table). Resolution: f-024-rel's text kept (IQ1_M first, the model-card sentence, 533 GB for all three, the 'not a model to pick' Engram row), with the setup worker's sentence that setup recommends 1.75 where 2.00 is tight in memory or disk added to the 1.75 entry.
2026-10-24 02:09:11 +02:00
Niko1221 c5e0e14823 DeepSeek: IST-DAS 1.75 bpw (IQ1_M), the smallest official set, as model ist-das-1.75bpw: pins at ISTA-DASLab cb02422c (the 9 earlier files are unchanged there), fit numbers, setup recommends it where 2.00 is tight in memory or disk (warn, never refuse), catalog/TUI text, docs, tests 2026-10-24 02:08:43 +02:00
Niko1221 9bb00346a2 release notes: R9700 prompt numbers from the r97 command (4 cards 8K 505, 32K 818; kernels +36% / +49%) 2026-10-24 02:04:13 +02:00
Niko1221 672612ac1c DeepSeek tables: the IQ1_M (1.75 bpw, ist-das-1.75bpw) row first, 239 GB; quality 'being measured' plus ISTA-DASLab's model-card figure; README + 6 translations, DEEPSEEK41.md, release notes (placeholder until the KL is in) 2026-10-24 02:03:56 +02:00
Niko1221 f4e7685ccf Merge f-024-rel into f-024-tui 2026-10-24 01:59:28 +02:00
Niko1221 267a2f4462 launch: a Runner that cannot be built is shown on the run screen as a plain failure instead of crashing the screens; tests: launch TUI walk (every screen, every key, Qwen and DeepSeek, 80x24 and wide, run paths, error paths) 2026-10-24 01:59:28 +02:00
Niko1221 eec705cc98 release notes: the prompt-skip agreement at the final tip, 99.5% of 799 positions 2026-10-24 01:58:45 +02:00
Niko1221 b0d5f6e57c Merge branch 'f-024-slots' into f-024-rel 2026-10-24 01:52:42 +02:00
Niko1221 f2413d77e9 Merge branch 'f-dsv41-tune' into f-024-rel 2026-10-24 01:52:27 +02:00
Niko1221 4f8770d13e release notes: R9700 prompt numbers with the new kernels (4 cards 8K about 320, 32K about 750) 2026-10-24 01:52:08 +02:00
Niko1221 984f52c827 Merge branch 'f-024-rel' into f-dsv41-tune 2026-10-24 01:51:58 +02:00
Niko1221 aebdc298f9 Merge f-024-hipk (cdf8b98f5) into f-024-rel: gfx12 WMMA i-quant prompt tiles for DeepSeek V4.1 (HIP only, default on for gfx12, STRATA_DS4_HIPK=0 off)
One conflict, src/dsv41/ds4_model.cu (forward_chunks): f-024-rel has the long-prompt skip (kv_only layers), f-024-hipk added DS4_PROF stage marks around the same lines.
Resolution: the f-024-rel code kept as it is, and hipk's three prof_mark(d, 5/6/7) calls added at the same places (before the q/kv gemms, before the snapshot, after the kv_only return). They are no-ops with DS4_PROF unset.
2026-10-24 01:51:58 +02:00
Niko1221 fd5823ab83 Merge branch 'f-dsv41-cuk-zcfix' into f-024-rel 2026-10-24 01:48:40 +02:00
Niko1221 d50da4e3a1 release notes: the run-to-run difference is near-tied words, not only expert placement 2026-10-24 01:48:18 +02:00
Niko1221 624a226d13 dsv41 store zero-copy: clear the device's zc_tab entry when VRAM takes an expert over (a stale pointer into a reused slot made zc_stage copy another expert's blob into the plan: garbage, NaN routing, illegal access) 2026-10-24 01:44:40 +02:00
Niko1221 fa3252ad5d dsv41 serve: with slots off the loop is as before (no anchor copy, same continuation test on an empty engine) 2026-10-24 01:43:35 +02:00
Niko1221 9797056e4d Merge branch 'f-024-rel' into f-024-slots
# Conflicts:
#	serve/test_dsv41.py
2026-10-24 01:43:15 +02:00
Niko1221 e2361d80d2 DeepSeek-V4.1: slot keep and auto-pin default off (switches STRATA_DS4_SLOT_KEEP=1, STRATA_DS4_AUTO_PIN=1) 2026-10-24 01:39:15 +02:00
Niko1221 afd24ea6ae launch: the last lines suggest 'strata launch --stop' (and --save-preset NAME to keep the settings) when no preset was saved; --preset "<name>" only when one exists; tests 2026-10-24 01:27:07 +02:00
Niko1221 4ebba26767 pack_gguf: an Engram file ('engram' or 'deepseek41-engram') is never taken for a model shard or for the model's metadata; real header fixture and tests; setup test: the first release's files are packed as they are with no shard download 2026-10-24 01:25:23 +02:00
Niko1221 598372c3fd setup: repin the DeepSeek 2.00/2.25 sets to ISTA-DASLab's final files (flat repository 0d8f49fb, IQ2_XXS = 2.00 bpw, IQ2_XS = 2.25 bpw, DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf); the first release's local files (model-2.00bpw-..., engram-IQ4_NL.gguf, in 2.00bpw/ folders) stay accepted by their old size and SHA-256 and are never downloaded again; tests 2026-10-24 01:24:14 +02:00
Niko1221 2e477ecd40 release notes: the 99.75% is agreement between skip on and off over 799 positions 2026-10-24 01:21:59 +02:00
Niko1221 0e121c7a13 DeepSeek table: the IQ4_NL Engram table row (README and 6 translations, DEEPSEEK41.md, release notes); notes: long-prompt skip and the 32,768 thinking budget 2026-10-24 01:21:32 +02:00
Niko1221 415e618085 Merge branch 'f-dsv41-maya' into f-024-rel 2026-10-24 01:20:31 +02:00
Niko1221 dfe29bce61 serve: DeepSeek-V4.1 thinks at most 32768 tokens when neither the request nor the config names a budget
Same default as Maya #363. For the deepseek41 family only: Service.reasoning_budget() answers 32768 when the request has
no reasoning_budget_tokens / thinking_budget_tokens and the config has no reasoning_budget_tokens at all (a config value,
0 included, wins; a request's 0 turns it off). The existing wrap-up (#123) closes the thinking at the budget, and the
answer-room clamp still cuts it to leave the answer its share of max_tokens. Thinking off and other families unchanged.
Tests in serve/test_dsv41.py: the default, a request's 0 and own value, the config's value and 0, another family, and a
5000-token cap that is wrapped up and answered. serve suite: 1027 tests OK (15 skipped).
2026-10-24 01:20:04 +02:00
Niko1221 f51b80c222 README: R9700 numbers in the DeepSeek block (11 tokens/s on one card, 4x about 31, 55 to 69 with DSpark); the same block in the six translated READMEs; release notes: one-card R9700 row 2026-10-24 01:19:04 +02:00
Niko1221 17c585c402 Merge branch 'f-024-hsamsg' into f-024-rel 2026-10-24 01:18:51 +02:00
Niko1221 41e804d93c launch: the hsa_exception sentence ignores hsaKmt lines, and a real cause in the same log (out of memory, no permission on the card) still wins; tests for both 2026-10-24 01:18:16 +02:00
Niko1221 524ff90e1a docs: the final DeepSeek quants (ISTA-DASLab repository) in the release notes and DEEPSEEK41.md 2026-10-24 01:17:00 +02:00
Niko1221 86834afedc Merge branch 'f-024-hsamsg' into f-024-rel 2026-10-24 01:16:46 +02:00
Niko1221 fa77717a57 README: DeepSeek V4.1-Flash (ISTA-DASLab IQ2_XXS / IQ2_XS) in the model list, Terminal only 2026-10-24 01:16:46 +02:00
Niko1221 60d847ba33 launch: a GPU runtime start failure (hsa_exception) says the card was not ready, with a next step; the runtime text stays in the log 2026-10-24 01:16:19 +02:00
Niko1221 a3f76dfd63 Merge branch 'f-dsv41-tune' into f-024-rel 2026-10-24 01:13:51 +02:00
Niko1221 b4418e30df serve: DeepSeek-V4.1 with a short max_tokens (48-1023) caps the thinking at half of it, so a text block always arrives
/v1/messages and /v1/chat/completions with max_tokens 200-600 and the thinking on spent all of max_tokens on the
thinking: the 0.1.45 answer room starts at max_tokens 1024 and a client that gave no budget of its own got no text block.
For the deepseek41 family only, with no budget from the client or the config, the thinking is now capped at max_tokens / 2
for max_tokens from 48 up to 1023 (the existing wrap-up, </think> and the answer follow in the other half). A client's
own reasoning_budget_tokens / budget_tokens wins, "answer_room": false turns it off, thinking off is unchanged, other
families (Qwen) are untouched. Maya #363 closes the thinking in the engine instead; the server's #123 budget already does
that for every engine, so only the short-cap gap needed closing.

Tests (serve/test_dsv41.py SmallCap, fake engine that thinks without end): max_tokens 200, 400 and 600 on both APIs return a
non-empty text block with the thinking wrapped up at half; thinking off answers directly; a client budget wins; a tool
call under a short cap still parses; another family keeps the old behaviour. serve suite: 1025 tests OK (15 skipped).
2026-10-24 01:11:34 +02:00
Niko1221 8a7102ec4d dsv41: a long prompt skips layers 20-39 on all but its last 2,668 rows (STRATA_DS4_PROMPT_SKIP, on by default)
Layers 20-39 of DeepSeek-V4.1-Flash read a prompt's earlier tokens only through layer 20's compressed KV and index
keys, and each looks back one 128-token window more than the layer before. So for the answer and for the rings the
decode reads they need their own work only on the last (40 - 20) x 127 + 128 = 2,668 rows. A prompt of ~3,700 or more
new tokens on one GPU now runs its earlier chunks through layers 0-19 plus layer 20's kv_only part (the hyper-connection
pre-norm, the compressor latent, the index keys, the compressed KV) and nothing of the later layers' attention or
experts; the chunk the cut falls in runs layers 20-39 from the cut row on; later chunks run every layer. The expert flow
plans nothing for the skipped (chunk, layer)s. STRATA_DS4_PROMPT_SKIP: 1 (default) the skip, 2 only the last 128 rows
(DeepSeek's own serving; answers shift a little), 0 off. Same approach as Maya #318.

H100 emulation (64 GB cgroup, 12 GiB VRAM, store 41 GB, 3.6 GB/s SSD cap): 16K prompt 69.3 s -> 51.5 s (236 -> 318
tok/s, two alternating pairs), 4K 32.5 s -> 30.7 s; 800 teacher-forced positions after a 4,200-token skipped prompt: KL
0.00029 against the full work, top-1 against the text 88.11% against 87.98%, ppl 1.5110 against 1.5111.

--score gets DS4_SCORE_PREFIX=N (the segments joined, the first N tokens read as one prompt with the last row's logits,
the rest scored) so the skip can be scored; tools/dsv41/test_prompt_skip.py runs it against a pack when one is named.
The Maya notice file now says later Maya changes are covered by the same notice.
2026-10-24 01:11:34 +02:00
Niko1221 be26cb7995 DeepSeek-V4.1: slots on by default (2 GB cap, one week), docs 2026-10-24 01:10:21 +02:00
Niko1221 736a4136e1 serve: DeepSeek-V4.1 engine gets the slot switch and a slot folder in Strata's data folder (other families untouched) 2026-10-24 01:09:06 +02:00
Niko1221 1d31e2a1e4 tools/dsv41/slot_keep_test.py: two agents taking turns, fresh reads vs shared start vs slots vs restart vs folder cap 2026-10-24 00:59:52 +02:00
Niko1221 b8f9c63ae7 serve: DeepSeek-V4.1 marks the system prompt and tools as the shared start (pin=N) so a sub-agent goes back to it
Only the deepseek41 family; STRATA_DS4_AUTO_PIN=0 switches it off, a client's own strata_prefix wins, Qwen is untouched.
2026-10-24 00:59:52 +02:00
Niko1221 cdc3a26577 dsv41: keep a conversation another request displaces and take it back at its checkpoints (STRATA_DS4_SLOT_KEEP), shared-start checkpoint (pin=N)
A prompt that does not continue the conversation in the model first sets that one aside (position, window rings,
compressor partial groups, each KV source's compressed KV and index keys, its checkpoints), then takes back the kept
conversation it does continue, in RAM or from the slot folder, also across restarts. The latest checkpoint the prompt
still agrees with is used (end of the last prompt, before it, the anchor of the first prompt of a run, and the shared
start the server marks with pin=N). The .meta format and the prefix matching are in ds4_slotmeta.hpp with a GPU-free
test (ds4_slot_test). STRATA_DS4_REUSE=0 reads every request from token 0 (reference for tests). Off unless
STRATA_DS4_SLOT_KEEP=1.
2026-10-24 00:59:52 +02:00
Niko1221 cdf8b98f54 Merge f-024-rel d914211e6 into f-024-hipk 2026-10-24 00:58:00 +02:00
Niko1221 5be4964c2b launch for DeepSeek: the tune text says what it really does and its result lists what was timed (or that nothing could be compared); the plan names an installed DeepSeek engine ('installed, 0.1.49') instead of saying setup will download it; preset test; tests 2026-10-24 00:56:08 +02:00
Niko1221 d914211e64 release notes 0.2.4: the manual DeepSeek engine command 2026-10-24 00:41:47 +02:00
Niko1221 71c674dcdb DeepSeek engine by hand: --dsv41-package FILE (setup and strata launch) or a file put in engine-dsv41/; the package is checked (DeepSeek engine for this system, signed manifest, every file's hash) before it is installed; plain steps in the missing-engine message and docs; tests 2026-10-24 00:41:28 +02:00
Niko1221 0d38517888 dsv41 prompts: expert copies keep going while the host waits for the routing (flow_pump on the busy stream); DS4_FLOW_PUMP_WAIT=0 as before 2026-10-24 00:40:48 +02:00
Niko1221 e51e84e22a dsv41 CPU lane: two cores left free beside the host thread when the lane would take every logical CPU; STRATA_DS4_LANE_FREE=0 as before 2026-10-24 00:40:42 +02:00
Niko1221 cffd240834 dsv41 serve: Stop ends a long prompt's reading at a layer/chunk and keeps the conversation before it (set_cancel / cancelled) 2026-10-24 00:40:09 +02:00
Niko1221 062fd4a95e release notes 0.2.4: the DeepSeek engine is a separate download (automatic and by hand), Qwen engines unchanged 2026-10-24 00:38:34 +02:00
Niko1221 f73ae5a62e dsv41 DSpark: cumulative cut on the drafts (product of confidences >= 0.5); STRATA_DS4_DSPARK_CUM, request key dspark_cum, 0 off 2026-10-24 00:38:21 +02:00
Niko1221 e6e14f2714 dsv41 CPU lane: prefetch only the IQ experts' pages not in RAM (os::prefetch_cold); DS4_IQ_PF_ALL=1 as before 2026-10-24 00:38:01 +02:00
Niko1221 a6cbc3621e dsv41 Windows: Engram rows read unbuffered from their unmapped file; DS4_EG_DIRECT=0 as before, DS4_EG_CHECK compares against a buffered read 2026-10-24 00:37:44 +02:00
Niko1221 aee5db5eb1 dsv41 Windows: 64 Engram reader threads (each ReadFile is synchronous, so the threads are the queue depth); DS4_ENGRAM_THREADS overrides 2026-10-24 00:37:14 +02:00
Niko1221 611c1701d3 dsv41 Windows: 1 ms timer resolution while the engine runs; DS4_TIMER_MS=0 leaves it 2026-10-24 00:37:04 +02:00
Niko1221 5ec845f6bc dsv41 Windows: queued GPU work submitted at once on NVIDIA too (win_submit), after every flag hand-off; DS4_WIN_SUBMIT=0 turns it off 2026-10-24 00:36:40 +02:00
Niko1221 a23b5f209c dsv41: gfx12 WMMA expert tiles on by default (STRATA_DS4_HIPK=0 turns them off) 2026-10-24 00:35:47 +02:00
Niko1221 94413c7057 dsv41: gfx12 WMMA prompt tiles for the i-quant expert sets (STRATA_DS4_HIPK=1, default off) 2026-10-24 00:35:38 +02:00
Niko1221 8fda835383 dsv41: DS4_PROF expert-kernel timers on both set paths 2026-10-24 00:35:37 +02:00
Niko1221 4278304914 dsv41: finer DS4_PROF for the chunked prompt (stages, expert kernels by type) 2026-10-24 00:35:37 +02:00
Niko1221 75c57be831 release notes 0.2.4: Thanks names IST-DAS and Unsloth only 2026-10-24 00:34:09 +02:00
Niko1221 2dbb263b02 launch/setup for DeepSeek: setup installs the screens packages (textual) and launch goes on in plain lines without them; the Tuning screen shows the DeepSeek tuner's real length and what it tries; the Advanced list knows strata-dsv41's own flags; the model menu counts the disk (download + preparation) in 'fits'; docs: tuning and the separate engine package; tests 2026-10-24 00:18:48 +02:00
Niko1221 955c709d96 Merge branch 'f-024-dsml' into f-024-rel 2026-10-24 00:12:55 +02:00
Niko1221 70efffb5b6 serve: a DeepSeek-V4.1 pack that ships the V4-Flash chat template (tool_calls, no space) is rendered with the V4.1 template, so tool calls come back as real calls; STRATA_DEBUG prints the raw ids; test 2026-10-24 00:07:13 +02:00
Niko1221 027691b33d release notes 0.2.4: RTX 3060 row 6-9 tokens/s 2026-10-24 00:06:34 +02:00
Niko1221 00ce92068a setup: banner names the chosen family; the user's own home (root's included) is allowed as a data folder; a missing DeepSeek engine package is said plainly (supported cards), the developer hint only with --verbose; tests 2026-10-24 00:05:11 +02:00
Niko1221 18022ceaf3 launch: the plan says 'drafting DSpark' for DeepSeek, not MTP; test 2026-10-23 23:48:59 +02:00
Niko1221 1904c0261b dsv41 tune: with the drafter on, also try a draft block of 3 against the pack's own 2026-10-23 23:47:48 +02:00
Niko1221 642e76948e setup: shared DeepSeek files (drafter, Engram) are also looked for two folders above the set's own folder (strata launch passes .../off/2.00bpw); test 2026-10-23 23:47:30 +02:00
Niko1221 1d0a8051d5 setup: DeepSeek's engine comes from the signed feed's dsv41 member (strata_control/dsv41_engine.py: same download, resume and verification as an engine update) into engine-dsv41/, beside the engine; the flat-zip route is dropped; tests 2026-10-23 23:46:46 +02:00
Niko1221 9f04da3afc Merge branch 'f-024-rel' into f-dsv41-tune 2026-10-23 23:37:40 +02:00
Niko1221 134f24f199 setup: DeepSeek's engine is its own package (engine-dsv41/ beside the engine, never inside it): downloaded with resume and a SHA-256 check from the --prebuilt base, found before the old all-in-one place; launch's note says so; tests 2026-10-23 23:37:19 +02:00
Niko1221 6655733d5e feed: a separate dsv41 member for the DeepSeek engine packages (Feed.dsv41, pack-engine --prefix, build-feed --dsv41); Qwen engine list untouched; tests 2026-10-23 23:35:00 +02:00
Niko1221 f9f297bf90 Merge branch 'f-dsv41-tune' into f-024-rel 2026-10-23 23:33:46 +02:00
Niko1221 ec5b2bdc4e dsv41 win: --dspark-block N (at most N drafts a round) 2026-10-23 23:33:40 +02:00
Niko1221 a2aeb4ecb1 launch: a real short tune for DeepSeek-V4.1-Flash (tools/dsv41/tune.py: the drafter on or off, CPU threads; gains over 5% only, written into the launch config; any failure leaves the standard settings with a plain line); the launch no longer forces --tune skip for it; tests 2026-10-23 23:31:51 +02:00
Niko1221 3feaa2aa3f Merge branch 'f-dsv41-setup' into f-dsv41-tune 2026-10-23 23:30:35 +02:00
Niko1221 8ea7fef827 Terminal 0.2.4 / Control and engine 0.1.49: version bump for the DeepSeek V4.1-Flash release (app stays 0.2.3, Mac stays 0.1.48); release notes 2026-10-23 23:29:00 +02:00
Niko1221 36a94857a1 dsv41_fit: remove a duplicated constants block; one unit (tokens a second) 2026-10-23 23:28:05 +02:00
Niko1221 28ce98bf16 Merge f-dsv41-rel (0be62ccad) into v0.2.3.1: DeepSeek V4.1-Flash on top of the released 0.2.3 line
f-dsv41-rel was based on the 0.2.2-era tree (merge-base 1f727f4b5), so the merge brings it onto 7ddc17a38 without dropping 0.2.3 / 0.2.3.1.
Conflict resolutions (0.2.3.1's behaviour kept everywhere that is not DeepSeek):
- packaging/package_engine.py: build() keeps 0.2.3.1's check_reader / allow_stale_vision / allow_no_reader and adds require_dsv41; both the
  picture-reader check and the strata-dsv41 presence check run; the CLI call passes both sets of arguments.
- packaging/ENGINE-COMPONENT.md: 0.2.3.1's cuda13 / cuda12 rows (vision built per variant), with strata-dsv41.exe added to the cuda13 contents.
- serve/server.py: the parser is self.parser_cls (OutputParser, or the DeepSeek DSML parser) and keeps 0.2.3.1's declared_only argument.
- strata_cli/launch/cmd.py: the --plan output prints 0.2.3.1's drafting line and then the DeepSeek placement lines.
- strata_cli/launch/runner.py: plan() returns 0.2.3.1's dict with "drafting", plus "placement" for multi-file (DeepSeek) models;
  drafting_plan_line() kept; dsv41_engine() and placement() added.
2026-10-23 23:27:29 +02:00
Niko1221 0be62ccad7 Merge f-dsv41-setup 86ba33845 into f-dsv41-rel (serve: DeepSeek turn without </think> returns its text as content; pack recipe noise token/markov rank; exec bits in packages; console-safe smoke) 2026-10-23 23:00:32 +02:00
Niko1221 86ba33845f serve: a DeepSeek turn that stops without </think> and wrote no answer returns its text as the answer (streaming ends with it as content; the non-streaming OpenAI and Anthropic replies drop the identical reasoning); tests 2026-10-23 23:00:05 +02:00
Niko1221 6f507fb210 packaging: strata-dsv41 and strata-vision get execute bits in the package and in the tar (ELF header decides when packed on Windows); setup restores them on an engine copied without modes 2026-10-23 22:49:29 +02:00
Niko1221 1a7b4ae236 setup: pack recipe states --dspark-noise-token 128799 and --dspark-markov-rank 256; plain wording (nothing to download, one unit: tokens a second, the one-time SHA-256 check and its time, the remembered-folder line) 2026-10-23 22:48:21 +02:00
Niko1221 a9dd554e13 setup: the smoke reply is made printable for the console code page before it is shown 2026-10-23 21:54:52 +02:00
Niko1221 3724818db5 Merge f-dsv41-setup into f-dsv41-rel (setup installs the official IST-DAS 2.00/2.25 sets; strata-dsv41 in the engine packages) 2026-10-23 21:54:42 +02:00
Niko1221 e9546cb564 Merge f-dsv41-cpul e5f536a6c into f-dsv41-rel (AVX-512 one-token rows for Zen 4/5, bit-identical to ggml, per-type start-up self-check, chosen by the detected CPU) 2026-10-23 21:10:53 +02:00
Niko1221 8de8d90287 dsv41 smoke: utf-8 output (the reply has DeepSeek's special tokens; a Windows console code page failed on them) 2026-10-23 21:09:46 +02:00
Niko1221 e5f536a6cb dsv41 cpu lane: AVX-512 rows from 1 token on Zen 4 as well (IQ2_XXS, IQ2_XS; IQ3_XXS and IQ1_M from 2), measured 2026-10-23 21:02:09 +02:00
Niko1221 b6cc7e681b setup: DeepSeek family line names IST-DAS for the official 2-bit GGUFs and Unsloth for UD-Q8_K_XL; says who made the files of the chosen version 2026-10-23 20:41:48 +02:00
Niko1221 f2323d876d setup test + docs: no --prefill-chunk, one plain line about the engine's own chunked prompt 2026-10-23 20:37:24 +02:00
Niko1221 10af5bb1e4 setup: drop --prefill-chunk (the chunked prompt is the engine's default now); doc line 2026-10-23 20:37:14 +02:00
Niko1221 237eb667d3 Merge branch 'f-dsv41-rel' into f-dsv41-setup 2026-10-23 20:37:12 +02:00
Niko1221 d6f18babe9 Merge f-dsv41-r97 fb321d62c into f-dsv41-rel (layer-major chunked prompt as the engine default) 2026-10-23 20:12:55 +02:00
Niko1221 1747c8e538 dsv41 cpu lane: one-token AVX-512 rows - sign masks by popcount (IQ2_XXS, IQ2_XS, IQ3_XXS), IQ1_M AVX-512 rows with pdep indices; chosen per CPU (Zen 5 from 1 token) 2026-10-23 20:09:59 +02:00
Niko1221 f1cb66d09e dsv41: strata-dsv41 --version and a 'ds4: build <commit>' line with --verbose (commit from git at configure, a BUILD_ID file in an archive, or -DSTRATA_BUILD_ID) 2026-10-23 20:03:24 +02:00
Niko1221 4eedb86b4f Merge f-dsv41-h100 (store pre-grow: register the whole arena before the first kernel on Linux, fixes the --ssd-cache zero-copy hang) 2026-10-23 20:01:41 +02:00
Niko1221 e917cf5b37 Merge branch 'f-dsv41-rel' into f-dsv41-setup 2026-10-23 20:01:11 +02:00
Niko1221 c506238ce2 setup: DeepSeek official sets start with --dspark-conf 0.65 (H100 measured default) 2026-10-23 20:00:50 +02:00
Niko1221 c35710e388 dsv41: store arena pre-grown by default on Linux when the GPU maps it 2026-10-23 19:59:29 +02:00
Niko1221 fb321d62cb dsv41: the layer-major (chunked) prompt is the default on every run: chunks of 1024 for the single-card default run, staging ring 16 MB where the cards hold most of the experts 2026-10-23 19:59:13 +02:00
Niko1221 f7d7072841 dsv41: DS4_STORE_PREGROW=1 (map the whole store arena before the first kernel; cuMemHostRegister mid-decode hangs on Linux) 2026-10-23 19:58:22 +02:00
Niko1221 1c1005952a Merge f-dsv41-cuk 86bdfbd0d (HIP keeps the release grids and kernels). One conflict: the identical IQ1_M code-table HIP guard (9c46ab821 vs cuk); took cuk's wording. 2026-10-23 19:52:52 +02:00
Niko1221 41856f6594 dsv41: staging ring 64 MB when the experts are within 30% of fitting the cards (R9700 x4 plain 2.00: ratio 1.16) 2026-10-23 19:45:21 +02:00
Niko1221 0beae0bca6 dsv41: the store fill after a chunked prompt reaches the store's whole capacity, not only its allocated slots 2026-10-23 19:44:54 +02:00
Niko1221 9469412db2 setup: DeepSeek official sets start with --prefill-chunk 1024 (layer-major prompt path, measured 231 tok/s at 8K on a 12 GB PC) 2026-10-23 19:44:40 +02:00
Niko1221 a345d9f8cf Merge branch 'f-dsv41-rel' into f-dsv41-setup 2026-10-23 19:44:39 +02:00
Niko1221 a82477069b Merge branch 'f-dsv41-pkg' into f-dsv41-setup 2026-10-23 19:41:53 +02:00
Niko1221 81dd215de9 setup: the two official IST-DAS DeepSeek-V4.1-Flash sets (2.00 / 2.25 bpw) as sizes: pinned files, shared Engram + DSpark, plain pack recipe, shipped profiles, pack-info and a 16-token text check, --dspark auto; fit words from the measured numbers 2026-10-23 19:41:49 +02:00
Niko1221 4b5f8d89f2 tests + ENGINE-COMPONENT.md: strata-dsv41 in the engine package and the --require-dsv41 refusal 2026-10-23 19:39:24 +02:00
Niko1221 dc352b839b dsv41: say how the chunk staging ring was sized 2026-10-23 19:36:53 +02:00
Niko1221 e838e2beca Merge f-dsv41-cpul 02b07b686 into f-dsv41-rel (exact multi-row CPU-lane rows for IQ types, bit-identical to ggml-cpu, self-check per type, DS4_GG_IQ256=0 switch) 2026-10-23 19:35:58 +02:00
Niko1221 6053a43841 packaging: build_dsv41_windows.bat and build_dsv41_cuda.sh build strata-dsv41 for the NVIDIA release packages (sm_86 + sm_120 fat) 2026-10-23 19:35:51 +02:00
Niko1221 faaf82acd7 build_hip_linux.sh: build strata-dsv41 (own build tree) and ship it in the rx9070 package; DSV41=1|0 overrides, --require-dsv41 on the package 2026-10-23 19:35:51 +02:00
Niko1221 8fee63f4f0 package_engine: ship strata-dsv41 beside the engine (picked up like the picture readers); --require-dsv41 refuses a package or a --verify without it 2026-10-23 19:34:59 +02:00
Niko1221 7519b1c75c pack_gguf: warn loudly when --skip-tensors / --dense-override are used on an official IST-DAS set (engram_k/q/wkv present): the rebuilt pack loads but gives garbage text; the plain recipe is right 2026-10-23 19:27:17 +02:00
Niko1221 dbf3bb2e64 dsv41: the layer-major run's RAM is given back before the cache catch-up and the store fill 2026-10-23 19:24:01 +02:00
Niko1221 86bdfbd0da dsv41 iq: HIP keeps the release grids and kernels (no persistent work-item grid there) 2026-10-23 19:23:13 +02:00
Niko1221 796dbb77b4 dsv41 iq: the IQ1_M code-table copy is CUDA only (kIq1mCodes does not exist on HIP; found by building the HIP-guarded paths under nvcc with -DSTRATA_USE_HIP) 2026-10-23 19:17:14 +02:00
Niko1221 96a47b79d5 dsv41: after a chunked prompt the RAM store is filled with the prompt's hottest CPU-lane experts (a window prompt fills it through the CPU lane; the chunk path streams past it) 2026-10-23 19:14:44 +02:00
Niko1221 9c46ab821a dsv41 iq: kIq1mCodes is CUDA-only, so its use in load_tab_fast is under #if !STRATA_USE_HIP (HIP failed 'undeclared identifier kIq1mCodes': non-dependent names are looked up in the dead constexpr branch) 2026-10-23 19:13:07 +02:00
Niko1221 12595032e7 Merge f-dsv41-cuk a3d955201 into f-dsv41-rel (persistent grids only for codebooks >= 8 KiB: fixes 2.5x slowdown with 1 active expert) 2026-10-23 19:06:12 +02:00
Niko1221 a3d9552015 dsv41 iq test: golden compare also covers dynamic-count window groups (5/1/3 entries, ne_max 5; 9 groups with 4 live) 2026-10-23 19:05:14 +02:00
Niko1221 01220edb67 dsv41 iq test: DS4_BENCH_WIN (window path / DSpark verify bench: n entries a group) 2026-10-23 19:01:30 +02:00
Niko1221 02b07b686a dsv41 cpu lane: AVX-512 rows chosen by the detected CPU (Zen 5 family 0x1A: IQ2_XXS and IQ3_XXS; Zen 4 0x19: IQ2_XXS; others none), prompt chunks of 7+ tokens only 2026-10-23 18:51:19 +02:00
Niko1221 a70b006929 dsv41 iq (CUDA): persistent work-item grids only for codebooks of 8 KiB+ (IQ1_M/IQ1_S/IQ2_S), lazy codebook load, small-codebook types keep the release grids and kernels (no regression with few active experts) 2026-10-23 18:49:14 +02:00
Niko1221 4cca3fac20 dsv41: --spec-eval honours --dspark off 2026-10-23 18:44:21 +02:00
Niko1221 995ff61f3c dsv41 cpu lane: AVX-512 rows by default for IQ2_XXS prompt chunks (7+ tokens), measured on Zen 4 2026-10-23 18:43:28 +02:00
Niko1221 f5fc88c599 dsv41 iq (CUDA): token and light kernels take work items (group, row tile) round a persistent grid (any number of active groups shares all blocks), IQ1_M codes from a generated table (ds4_iq1m_codes.inc), persistence only for codebooks of 8 KiB+; many-row light sets keep the release kernel; test: --bench-tiles, DS4_BENCH_ACTIVE 2026-10-23 18:41:55 +02:00
Niko1221 428950b96c Merge catch-up: f-dsv41-win fd19f9d6b, f-dsv41-h100 c0f944e12, f-dsv41-cuk 2026-10-23 18:35:43 +02:00
Niko1221 0553ba6c3f dsv41 iq (CUDA): light sets (DSpark verify, short prompts) take the persistent grid and the IQ1_M code table for experts of <= 4 rows, 4-block occupancy kept for 8+ rows; test: --bench-light 2026-10-23 18:16:11 +02:00
Niko1221 fd19f9d6bf dsv41 win: store off the lookahead by default 2026-10-23 18:09:27 +02:00
Niko1221 b43b0bb518 dsv41: kEntMax declared before Ent 2026-10-23 18:06:26 +02:00
Niko1221 d2411af9fd dsv41: i-quant CPU-lane entries take a whole window's tokens of an expert (cap 16, was 8) for the exact multi-token rows; the chunk staging ring is 64 MB where the cards hold 90% of the experts (decode after a chunked prompt back to the windowed path's speed) 2026-10-23 18:04:51 +02:00
Niko1221 49e35e8bb7 dsv41 cpu lane: exact AVX-512 rows (iq512x_rows, 1-6 tokens and prompt tiles), opt-in DS4_GG_IQ512=1 until measured on Zen 4 2026-10-23 18:04:12 +02:00
Niko1221 c25724a222 Merge branch 'f-dsv41-cpul' into f-dsv41-r97 2026-10-23 18:01:40 +02:00
Niko1221 d689e6080d Merge f-dsv41-cuk into f-dsv41-rel (per-device light-set scratch and tensor-core check, faster IQ1_M/IQ2_S/IQ3_XXS token kernels) 2026-10-23 17:59:37 +02:00
Niko1221 cf4f8befe7 dsv41 cpu lane: take the AVX2 rows by ggml-cpu's own AVX2/FMA/F16C answer (MSVC builds have no __builtin_cpu_supports) 2026-10-23 17:59:03 +02:00
Niko1221 1cace5c864 dsv41 iq (CUDA): per-device light-set scratch and tensor-core check (stream 0 is one handle on every device: the scratch of the first GPU was reused on the second), block/sub-block loop form in the token kernels; test: --devices (in-process multi-GPU, bit-compared) 2026-10-23 17:58:59 +02:00
Niko1221 7e9b0dcc25 dsv41 cpu lane: exact AVX2 rows for 7..64+ tokens an expert (the prompt path): decode once, u8 activations, no per-token sign; IQ1_M too
T tokens in tiles of 16 and rows in groups of 8 (activation slice and accumulators in L1): 7.7-9 ns per block and token on IQ2/IQ3 rows against
28-60 for ggml's dot (x3.6-6.6), IQ1_M 16 ns (x1.8); bit-identical to ggml (ds4_gg_bench --exact at 7, 8, 16, 33, 64 tokens, all six types).
gg_cpu_gate_up takes up to 64 tokens a call (and any number in pieces); ds4_gg_bench --expert times the whole expert pipeline.
2026-10-23 17:57:53 +02:00
Niko1221 0b48e23fa5 dsv41 win: --dspark auto (not loaded when the experts do not fit the GPU), --ids @file, h100sim 2026-10-23 17:57:34 +02:00
Niko1221 f6e4cd6899 dsv41: chunked prompt for a single card (--prefill-chunk N), the expert cache learns from chunks and catches up after a prompt, chunk buffers fall back by halving, CLI feeds a whole prompt
The layer-major chunked prompt path only ran with --threads on / --tp > 1. --prefill-chunk N (N > 0) now enables it on one card too (kChunkedPromptDefault stays false until the decode gate is signed off). A chunk's routing bumps the layers' heat and CPU-lane hits, and cache_settle() runs the swap rule in rounds after the prompt so the decode that follows finds the hot experts in VRAM (8K prompt: 1699 swaps, 5.6 s). If the chunk buffers do not fit, the chunk is halved down to 256 rows, then windows of 16 with one line saying so. ds4_main: --window 0 (default) feeds a whole prompt when the chunk path is on.
2026-10-23 17:47:43 +02:00
Niko1221 c0f944e127 dsv41: DS4_PRED_DUMP / DS4_XIN_DUMP (router logits + projected router input; router inputs for the expert-similarity scan) 2026-10-23 17:44:34 +02:00
Niko1221 6aba354c16 dsv41 cpu lane: the exact AVX2 rows on by default from 2 tokens (1 on IQ3_S, 3 on IQ1_M), 5-6 tokens in one pass, IQ1_M rows rewritten
The rows are ggml-cpu's vec_dot bit for bit at any token count, so the default cannot change an answer; a one-time per-type check against the
ggml-cpu build in use turns them off if they ever differ (DS4_GG_IQ256=0 never, =1 always).  EX rows: 5 and 6 tokens in one decode pass
(were 4+rest), independent integer chains for 1-2 tokens, IQ1_M: delta dot from per-call pair sums of the activation, group signs from one
shifted load.  The main engine's iq256_rows hash is unchlanged (phaseA-tests/cpul/hash_iq256.cpp).
2026-10-23 17:42:52 +02:00
Niko1221 71abb6c163 dsv41 iq (CUDA): persistent token kernels (codebook loaded once a block) and IQ1_M decode through a 4096-entry code table (delta sign folded in): bit-identical, IQ1_M layer -15% sm_120 / -30% sm_86 2026-10-23 17:39:02 +02:00
Niko1221 cf45533cf6 dsv41 cpu lane: AVX2 multi-token i-quant rows in ggml's float order (bit-identical to ggml-cpu at 1..8 tokens), IQ1_M rows
The main engine's iq256_* rows keep their float order (EX=false, the default; hash of their outputs on all types and 1..8 tokens is
identical before/after, phaseA-tests/cpul/hash_iq256.cpp).  The new iq256x_rows (EX=true) sums as ggml-cpu does - block scale f16(d)*y.d, the
format constant after the sum, ggml's hsum - so a token's dot no longer depends on how many tokens share the pass (the greedy ids of a
DSpark run changed with DS4_GG_IQ256=1 because the old order differed in the last bits).  IQ1_M (29), single-token in ggml, gets a
multi-token AVX2 row.  ds4_gg_bench --exact: 0 differing bits for IQ1_M, IQ2_XXS/XS/S, IQ3_XXS/S at 1..8 tokens.
2026-10-23 17:28:19 +02:00
Niko1221 95994c45c5 dsv41 iq test: --bench-big (>=640 MiB working set, rotated experts: DRAM rates), --dump/--cmp (bit-exact golden compare incl. real shapes and grouped path) 2026-10-23 17:18:21 +02:00
Niko1221 ea081e3312 dsv41 rel: one IQ1_M implementation in ds4_iq.cu (the textual merge kept both; port's kernels stay), win's dequant_rows (chunked-prompt dense dequant) kept and takes IQ1_S too; strix add4 dropped with win's IQ1_M decode (port's decode has no __vadd4) 2026-10-23 17:06:08 +02:00
Niko1221 8cfe988383 Merge f-dsv41-strix into f-dsv41-rel (HIP build fixes: portable add4 for the IQ1_M decode, strata_core link for the DXGI budget, HIP build scripts, umaprobe)
No conflicts. The strix add4 edit applies to the merged IQ1_M decode; no __vadd4 is left in ds4_iq.cu.
2026-10-23 17:02:44 +02:00
Niko1221 e5c70bbaa0 Merge f-dsv41-h100 into f-dsv41-rel (--profile-run, DS4_TRACE on the chunked path, --spec-eval) 2026-10-23 17:02:30 +02:00
Niko1221 c70ae83d4c Merge f-dsv41-port into f-dsv41-rel (release integration)
Conflict decisions (win side = f-dsv41-win, port side = f-dsv41-port):
- ds4_iq.cu: kept port. Its IQ1_S/IQ1_M/IQ4_NL kernels are verified bit-exact on HIP gfx1201, sm_86 and sm_120; win's
  IQ1_M table load and supported() list are a subset (IQ1_S, IQ4_NL added).
- ds4_kernels.cu/.cuh: kept port's engram_dequant fmt int (0 MXFP8, 1 Q6_K, 2 IQ4_NL) and its iq4nl_value, replacing
  win's q6k flag and iq4nl_value; same arithmetic.
- ds4_model.cu: kept port's eg_fmt vector and its Engram type check (unknown type exits 2 instead of segfaulting);
  win's eg_q6k is gone, the duplicate IQ4_NL row branch from the textual merge is removed.
- ds4_packinfo.cpp: kept port (device types derived from iq::supported()).
- iq_expert_test.cpp: kept port (hip_or_cuda.h); win's tests/dsv41/hip_as_cuda.h removed (nothing else used it).
  The IQ1_M test-d placement is the same bytes in both versions.
- CMakeLists.txt: kept port's test-language block (HIP, CUDA or CXX for the CPU-only build); win's
  _strata_dsv41_test_lang variable dropped.
- pack_gguf.py: kept port's type table (IQ1_S/IQ1_M comment) and combined both missing-Engram checks
  (has_part from win's split Engram layout AND port's dense-override names).
2026-10-23 17:02:22 +02:00
Niko1221 f232cbfed6 dsv41 win: store test covers cancel and shrink; shrink finalizes read slots 2026-10-23 16:59:01 +02:00
Niko1221 08224f228f dsv41 win: priority SSD queue with cancellation (demand before guesses), partial GPU visibility, store shrink on RAM pressure, predsim 2026-10-23 16:53:02 +02:00
Niko1221 c8ead78419 dsv41 win: store init last, zc off by default, flow reads the store, release notes 2026-10-23 16:37:01 +02:00
Niko1221 55e1a53cfd dsv41: --spec-eval and DS4_ROUND_LOG (DSpark acceptance per round, every draft verified) 2026-10-23 16:32:59 +02:00
Niko1221 8da42765dd dsv41: DS4_TRACE also records the chunked prompt path's picks 2026-10-23 16:29:22 +02:00
Niko1221 75c9f2d81e dsv41: profile-run accepted without --ids 2026-10-23 16:25:51 +02:00
Niko1221 2e61fef4d1 dsv41 win: store auto size, profile preload, decode staging zc_tab (T=1), release plumbing 2026-10-23 16:21:59 +02:00
Niko1221 fb141be3e0 dsv41: profile-run string fix 2026-10-23 16:21:19 +02:00
Niko1221 9051f31306 dsv41: --profile-run mode (feed id segments without logits, expert profile saved as it goes) 2026-10-23 16:20:57 +02:00
Niko1221 17bc654b6d dsv41 strix: umaprobe (UMA budget, hipMalloc reach, host registration, zero-copy read bandwidth), build script 2026-10-23 16:10:12 +02:00
Niko1221 084e654bcb dsv41 on Windows HIP (gfx1151): build script for ROCm 10.2 + MSVC, Linux HIP build script, portable per-byte add for the IQ1_M decode (no __vadd4 on HIP), strata_core link for the DXGI-budgeted cudaMemGetInfo 2026-10-23 15:38:17 +02:00
Niko1221 7d2307deb0 dsv41 win: the store's slots in blob layout, GPU-visible (zero-copy mapped) with a PCIe staging copy for decode picks, node-free reserved arena 2026-10-23 15:33:54 +02:00
Niko1221 226d73b45b tools/dsv41/engram_fold.py --gate-kv-from: make the ple table the only variable, and the answer it gave
Deriving their engram.gate from their k*q and transposing their wkv scored 0.409 then 2.996 KL - both my arithmetic, not
their data (their k/q/wkv are stored transposed and the gate is not k*q in the engine's layout).  --gate-kv-from <pack>
copies those two tensors from a pack that is known good, so a pack can carry their ple table and nothing else of theirs
differ.  That is how the comparison the plan asks for was finally made (numbers in EACH-PC-PLAN/R9700-BOX.md, step 2):
their 110.6 GB IQ4_NL table scores KL 0.16787 / ppl 3.627 against 0.16502 / 3.588 for our 203 GB MXFP8 one - +0.003 KL,
inside this engine's run-to-run placement noise - while saving 92 GB of disk.

Also fixes the fold's reads: Gguf's tensor offsets are relative to the shard's data section (pack_gguf adds data_start),
so the fold must add it too; without that every tensor came out as NaN.
2026-10-23 15:10:00 +02:00
Niko1221 52543768c7 dsv41: one runtime header for the tests on both backends (HIP or CUDA), so a CUDA box needs no shim on -I
tests/dsv41/{iq,tq}_expert_test.cpp included <hip/hip_runtime.h> unconditionally, which no CUDA toolkit ships - every CUDA
box had to pass a hand-written shim on -I (found on l620 and z440, 2026-10-23, and reported as the one thing worth
upstreaming).  include/strata/dsv41/hip_or_cuda.h now provides the handful of HIP names those tests use on a HIP build and
maps them onto the CUDA runtime otherwise (hipMalloc/Free/Memcpy, device sync, the error type and the events).

Verified on r9700 that the HIP build is untouched: incremental build rc=0, ds4_iq_test --type 29 ALL OK, ds4_tq_test ALL OK.
2026-10-23 15:00:19 +02:00
Niko1221 3826885f4b tools/dsv41/engram_fold.py: transpose their Engram layout, but the fold's reader is still wrong
Their engram_k/q/wkv are transposed against the engine's layout (wkv [6144, 25600] against [25600, 6144], k/q [5120, 4]
against a [4, 5120] gate), so the fold now transposes and writes the engine's shape.  The pack builds (their IQ4_NL table,
all files present) and runs without crashing, but it scores KL 0.40915 - and that is this tool's bug, not their data: a
direct read of their BF16 engram_k through Gguf's offsets already comes out as NaN, while the engine reads the same tensors
through a pack fine, so the fault is in how this code turns an offset into bytes (the Q5_K dequant inherits it).

Do not quote 0.409 as the IQ4_NL-vs-MXFP8 comparison; that measurement is still open.  Next step is to read the tensors the
way pack_gguf writes them into tensors.txt and check one row against the engine's own dump.
2026-10-23 14:53:02 +02:00
Niko1221 e7b3f15240 dsv41 win: RAM expert store (unbuffered reads, policy, lookahead fill), Windows direct reads, dense dequant for the chunked prompt, SMT option, store test 2026-10-23 14:44:51 +02:00
Niko1221 7ddc17a38f tests: the old-launcher test expects the 0.2.3.1 version line 2026-10-23 14:37:21 +02:00
Niko1221 d62ff4a608 tools/dsv41: engram_fold.py (their k*q gate and wkv from their own shards) and pack_gguf accepts an override for the Engram tensors
Their sets keep the Engram as three per-layer tensors (blk.N.engram_k/q/wkv) while the engine wants engram.gate (k*q
folded) and engram.wkv; their engram-IQ4_NL.gguf carries only the ple table, so a pack built around their Engram stopped at
'blk.1.engram_kv.weight: missing'.  engram_fold.py folds the gate and renames the kv into a dense_requant-style folder, and
pack_gguf now treats a tensor a --dense-override supplies as present (it checked the quant's shards and the Engram set only).

Result: packs/ista_2.00_native - 48784 tensors, their IQ4_NL table, Engram layers 1,14, 2896 Engram reads for a 32-token
prompt, no crash.

NOT yet a measure of their table: it scored KL 0.40915 / ppl 4.769 against 0.16502 / 3.588 for the same set with our MXFP8
table, because their engram_k/q/wkv are stored TRANSPOSED relative to the engine's layout (their engram_wkv is [6144, 25600]
against [25600, 6144]; their k/q [5120, 4] against the [4, 5120] gate).  Copying the bytes as they are yields a valid pack
with the wrong data.  engram_fold.py still needs the transpose (gate in float; the Q5_K kv dequantised, transposed, written
BF16) - Phase 1.1's 'load their layout as published'.
2026-10-23 14:36:12 +02:00
Niko1221 488dd8459c dsv41: --pack-info's device-type list is derived from iq::supported() instead of copied
It was a hand-kept copy of the supported() switch and fell behind when IQ1_S, IQ1_M and IQ4_NL got kernels: the 2.00 set's
60 IQ1_M expert tensors (59.5 GB) were reported as 'no GPU kernel ... the CPU lane takes them' while the kernels were in the
same binary and ds4_iq_test passed them.  That is exactly the line an operator trusts before a download, so it now walks
ids 0..63, asks iq::name()/iq::supported(), and adds MXFP4 and the trellis types by hand (they are not in that switch).
Verified: packs/ista_2.00bpw_9e2528f and packs/h225 both report 'no GPU kernel: none'.  Also fixes the intake's eval call
(session16.sh takes the pack directory name, not a path - the 2.00 score had failed with an empty runs/s16_<path>.tsv).
2026-10-23 14:21:38 +02:00
Niko1221 b2d2cf22eb dsv41: the Engram reader takes IST-DAS's IQ4_NL table, and a pack with an unknown Engram type stops cleanly instead of segfaulting
Found and fixed on p620, the only box with their engram-IQ4_NL.gguf (110.6 GB). Before this, a pack built around their
file loaded for 38 s and then died in Model::Impl::eg_issue at the first token (3 of 3 runs, addr2line 0x6d3450).

- Model::Impl::load: one guard line and exit(2) for an Engram tensor whose type the reader does not handle, naming the
  layer and the type (verified with a pack retyped to IQ4_XS, rc 2; a missing embed tensor also exits 2). The guard stays
  for the next unknown type.
- eg_q6k becomes eg_fmt per layer: 0 MXFP8 ([scale, 32 codes] blocks, 264 B a row), 1 ggml Q6_K (210 B a 256), 2 ggml
  IQ4_NL (18 B a 32: an fp16 d and 16 nibble bytes), 3 an old pack's split FP8. The load loop sniffs the type from the
  pack's tensor table, eg_issue builds the row descriptor from it (144 B a row, off + id*144, no scale tensor), and
  EgReader::read() needed no new branch: its contiguous, scale-free path already served Q6_K.
- ds4_kernels.cu: engram_dequant_kernel and engram_dequant_tok_kernel take the format and add the IQ4_NL branch, a literal
  port of ggml's dequantize_row_iq4_nl (fp16 d per 18-byte block, low nibbles 0-15, high 16-31, kvalues_iq4nl).
- The Engram row slot stays cols*(hd+hd/32) for every format, so existing packs are byte-identical: --pack-info on the
  MXFP8 h225 pack and on a Q6_K zero pack is unchanged, and a Q6_K pack's 32-token ids are identical old against new.

Verified with their real table: the pack that segfaulted runs 32 tokens in 1692-1711 ms (18.1-18.3 tok/s) and produces
coherent Python (GGML_TYPE_Q2_0 = 42, GGML_TYPE_NAMES: dict[int, str]); 64 tokens on the code prompt, DSpark off 17.16
and on 23.36 tok/s (44 of 53 drafts accepted). IQ4_NL decode is bit-exact against ggml on 8 of 8 sampled rows, and the row
addresses match the pack's tensor table (data_start 320, 144 B a row).

Not covered: no non-zero MXFP8 table existed on the box to re-check that path in-engine (the Q6_K and synthetic-zero MXFP8
packs were), there is no in-engine float dump against the Python reference (the proof is formula- and address-level), and
the legacy split-FP8 format (3) is kept but untested.
2026-10-23 14:13:26 +02:00
Niko1221 7b3e2eaa0b Control 0.1.48.1 / Terminal 0.2.3.1: version bump for the Control-only hotfix of #3810 (app stays 0.2.3, engine packages stay 0.1.48); release notes 2026-10-23 14:10:33 +02:00
Niko1221 f5f205bee0 setup: the HIP ready-made-engine checks read gfx names with arch_names, not arch_numbers (an installed AMD engine was never recognised); tests for archs as a list and as a string 2026-10-23 14:07:48 +02:00
Niko1221 0edd96ccf6 setup: BUILD.json archs / vision_archs are read as a list or as the ';'-separated string the packaging writes (setup stopped on the released Windows CUDA package: int(';'), #3810) 2026-10-23 14:05:32 +02:00
Niko1221 eb6c0a23f0 dsv41 win: pack_gguf reads the IST-DAS Engram parameters from tensors, IQ1_M type, packinfo 64-bit sizes, DS4_TRACE_AHEAD, cachesim 2026-10-23 13:17:02 +02:00
Niko1221 c523890228 dsv41: a CPU-only build path, and the intake's pack recipe fixed
1. CMakeLists: -DSTRATA_BUILD_DSV41=ON was silently dropped when no GPU backend was on, because the whole DSV41 block
   sits inside if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP).  Found on p1004 (2026-10-23), whose P100 is not a DeepSeek
   target but which is exactly the box the CPU-lane A/B belongs on.  A new branch builds ds4_gg_bench with no GPU
   runtime at all.  Verified on r9700 with both backends off: configure rc=0, build rc=0, the benchmark runs IQ3_XXS at
   60.5 GB/s (ggml-cpu) against 64.1 (AVX2 rows, x1.06) and 8.02e-09 against a double reference.

2. tools/dsv41/ista_intake.py: the pack step now uses the recipe that actually builds a loadable pack for these sets -
   --engram-meta (the UD set's first shard carries the Engram hash parameters, without it pack_gguf sees no Engram layers
   and refuses per_layer_token_embd), --mmproj, the combined --skip-tensors regex, and the DSpark noise token and markov
   rank (pack_gguf fills the rest of the geometry from the drafter but not those two, so a pack came out with
   dspark_noise_token_id 0).  Verified end to end on the 2.25 set: the intake rebuilds packs/h225's content exactly -
   same 48794 tensor lines, --pack-info 'files: 10, all present', 'no GPU kernel: none', dspark geometry 5/128799/256/128/3.
2026-10-23 12:56:24 +02:00
Niko1221 5c523d55f7 dsv41: device kernels for IQ1_S, IQ4_NL and IQ1_M (the three IST-DAS types that had none)
Found and implemented on z440's CUDA bring-up, applied here and re-verified on HIP.

ds4_iq.cu: decode<T>/deq8<T> for kIQ1_S, kIQ4_NL, kIQ1_M, plus iq1_codes() (iq1s_grid_gpu's 4-bit nibbles -> the int8 codes
8*(n-1)+-1 through two __vsub4) and iq1m_d() (IQ1_M carries its F16 d in the top nibbles of its four u16 scales - there is no d
field).  kQK<T> is 32 for IQ4_NL as well as MXFP4; load_tab uses iq1s_grid_gpu / kvalues_iq4nl; DS4_IQ_SWITCH and
supported() cover the three types, so they leave the CPU lane and join the device path.

tests/dsv41/iq_expert_test.cpp: make_mat now writes IQ1_M's scale where the format keeps it (bytes 49/51/53/55, high nibbles).
Without that the derived d is random and roughly 1/32 of the blocks come out NaN, which std::max then hides.

Parity (z440, one A4000, ds4_iq_test --type 19,20,29): token gate_up 6.70e-08 / 2.04e-08 / 5.97e-08, token down 3.62e-08 /
4.71e-08 / 3.71e-08 against ggml's to_float and a double reference; CPU lane 3.4e-8..8.2e-8; F16 prompt tiles inside their
own tolerances.  Bandwidth: IQ4_NL 376 GB/s (84% of the A4000's 448, bandwidth-bound), IQ1_S 214, IQ1_M 202 (both
compute-bound at 0.92-1.10e12 weight-values/s against IQ2_XXS's 1.22e12).

Not covered: no end-to-end run with a pack that uses these expert types (none on z440); a combined-seed run put IQ1_M's
light-path gate_up at 1.10e-2 against its 1e-2 tolerance, which is the int8 activation rounding of that path (it does not
move with tensor cores off), not the dequant.
2026-10-23 12:34:16 +02:00
Niko1221 45dc5e3400 dsv41: the CUDA build of the tree needs two portable edits (found on z440's sm_86 bring-up)
1. ds4_tq.cu:262 used the two-argument __shfl_xor, which is HIP-only; the rest of the tree already uses
   __shfl_xor_sync(0xffffffffu, v, lane), which both backends have.
2. tests/dsv41/{tq,iq}_expert_test.cpp were forced to LANGUAGE HIP, so a CUDA configure failed at generate time
   ('The language HIP was requested for compilation but was not enabled'). The language now follows the enabled
   backend: HIP, CUDA, else CXX.

Verified on r9700 that the HIP build is untouched: incremental rebuild rc=0, ds4_iq_test --type 17 ALL OK, the engine
--pack-info clean. Verified on z440 (CUDA 12.9, sm_86) by the bring-up agent: HEAD builds, ds4_iq_test ALL OK for
IQ2_XXS/XXS/IQ2_XS/IQ2_S/IQ3_XXS/IQ3_S/IQ4_XS at 5e-8 against ggml, 272-369 GB/s on one A4000. IQ1_M, IQ1_S and
IQ4_NL still have no CUDA kernel (CPU lane only) - that is the Phase 5 kernel list.
2026-10-23 12:08:49 +02:00
Niko1221 c42f4f2969 dsv41 on Windows+CUDA: IQ1_M device kernels (bit-exact vs ggml), IQ4_NL Engram rows, CUDA build fixes (strtok_r, shfl_xor, test language), IST-DAS pack names, hardware bench 2026-10-23 11:57:51 +02:00
Niko1221 5c8dd32252 serve: max_tokens -1 and 0 mean no cap again, as in 0.2.2 (clients send -1 for that); documented 2026-10-23 07:57:09 +02:00
Niko1221 5964d113f8 release notes 0.2.3: hotfix heading, status/logs names, launch not setup, drop the unreleased-pack limit 2026-10-23 06:37:32 +02:00
Niko1221 fea2d02aaa docs: Strata 0.2.3 release notes 2026-10-23 06:36:54 +02:00
Niko1221 81a7260194 launch config never keeps --mtp beside "mtp": false; one line says where the pack and draft layer are read from (B10, C08 note) 2026-10-23 03:28:36 +02:00
Niko1221 3b1ab2848d Save and launch saves the preset only after the restart question is answered Restart; the question names the running model and port 2026-10-23 03:27:48 +02:00
Niko1221 80a70b86de Folder step: models Strata already keeps stay where they are (the text said they were moved) 2026-10-23 03:24:46 +02:00
Niko1221 9222b6a1d3 a failed start ends its own runtime host, checks the port first and quotes only this attempt's logs (X01) 2026-10-23 03:23:58 +02:00
Niko1221 67a48427b8 Merge f-023-int into f-023-tui2 2026-10-23 00:27:00 +02:00
Niko1221 d64ae1b0b1 tests: the smoke test reads the engine's own words from .reason, the message is plain (X01) 2026-10-23 00:26:59 +02:00
Niko1221 27c0cabd81 Merge f-023-tui1: TUI stress fixes (second launch reuse-or-ask with --restart, context change on the same port, status/logs find the launch, key kept, retune stops its own model, folder checks, Ctrl+C, --model-path wins) 2026-10-23 00:13:45 +02:00
Niko1221 2f5f7c1135 Failed engine test start gives a plain cause and next step; rollback never says the same version twice; max_tokens below 1 is a 400 (TUI stress X01, #18, #15)
X01: TestStartFailed has one text builder (TestStartFailed.plain): what did not work in plain words, that nothing was installed and the current engine is unchanged, and one next step (`strata update --force`, or a plain retry when the card was busy). The engine's own words stay in .reason, the state file and the JSON. The app's message matcher also accepts the new wording. Hints that a caller passes, such as the one for a busy launch, are used as they are, so the launch-holds-the-card line of f-023-tui1 composes with it.
#18: `components rollback` when an earlier rollback is still in force says the engine is already the previous one and what to run, instead of "0.1.45.3 -> 0.1.45.3".
#15 (K05): max_tokens, max_completion_tokens and max_output_tokens below 1 are a 400 naming the field (they used to mean "no cap"); an unknown model id is still answered by the loaded model, now documented in DETAILS.md.
2026-10-23 00:06:13 +02:00
Niko1221 3bf804c9c8 tests follow the folder check and the compact save summary 2026-10-23 00:05:00 +02:00
Niko1221 0af0f9f125 Installer: an older feed shows its real reason; a folder with accented letters unpacks (TUI stress #11 A01b/A05, #12 A04)
#11: the refusal of a feed older than the one already accepted now says both sequence numbers, the folder that holds the newer one (.updates) and one next step (use the newest release folder, or delete state.json to take the older one). install.ps1 and install.sh print the whole check output, not its first 6 lines, and no longer blame a missing signature when the check gave a reason.
#12: tar.exe got the path through the ANSI/OEM code page and failed on accented letters. The installer now runs Windows' own bsdtar (System32 first, so a GNU tar from Git for Windows cannot be picked up for a zip) with ASCII relative arguments from inside the data folder and renames the result, so no non-ASCII path ever crosses that boundary. Tested on C:\...\<U-umlaut>n<i-umlaut> test with a stub release folder; the old script fails there with "could not chdir".
2026-10-22 23:54:57 +02:00
Niko1221 ea67f6999a A missing shard is named with its file name in the CLI, the TUI and the app's plain issues (TUI stress #9, C05, #3478)
plan_import now returns missing_shards (the file names of the split set that are not in the folder). problem_words and plain_issues say "<name>-00002-of-00002.gguf is missing" instead of "some parts of the model are missing"; more than two are counted. The three call sites (models import, strata launch --model-path, the Model screen's own-files field) pass the list.
2026-10-22 23:47:37 +02:00
Niko1221 eb818c50ed Download size counts the draft layer and the picture reader; --plan no longer calls an installed engine missing; --drafting off writes no --mtp (TUI stress #6, B03, B10/E04)
#6 (A07/B03): a model that is not installed now counts the draft layer (5.2 GB) and the picture reader (0.9 GB) in the one place (modelstate.assess), so the Folder step, the confirm and --plan agree (Gyro-S 58.5 + 5.2 + 0.9 = 64.6 GB). Gyro joins the families that get the helpers. --mtp off and --drafting off drop the draft layer from the count and from what setup is told. setup.py's disk check counts the draft layer it is about to fetch.
B03: --plan keeps "engine_missing" for no engine at all; an installed engine that fails the check says so in plain words and adds engine_usable.
B10/E04: with --drafting off (or an installed config that says off) the written run config carries "mtp": false and no --mtp / --spec-min-p arguments.
2026-10-22 23:44:27 +02:00
Niko1221 092a62daaa --model-path and --data-dir win over an installed copy with the same model and context (C08) 2026-10-22 23:37:20 +02:00
Niko1221 1ed28f8863 the Folder step checks the path before going on, the Save summary shows the storage path, a denied folder is told in plain words (B05, B12) 2026-10-22 23:36:16 +02:00
Niko1221 38c7f7017d ctrl+c quits the launch screens like ctrl+q; only a running download or tune asks first (B13) 2026-10-22 23:36:15 +02:00
Niko1221 c924d3c9d7 An old Terminal that cannot hand over to a new Control is told to run the install command once more (TUI stress #4, H03)
The Terminals before 0.2.1.3 (0.2.0-beta, 0.2.1, 0.2.1.1, 0.2.1.2) install a newer Control and report "updated", but their launcher has no hand-over, so the old Control keeps running. Their updater test-starts the staged Control with --version (STRATA_NO_ACQUIRE=1, no STRATA_HANDOVER, from the .s staging folder) and shows the first 120 characters of the answer when that fails. Control now answers that start with one plain sentence and exit 7, so the old updater keeps the old Control and prints the sentence instead of "updated". Every updater that can hand over sets STRATA_HANDOVER and is not touched. Release note added.
2026-10-22 23:33:24 +02:00
Niko1221 6f8800aefe Version bump for 0.2.3: app 0.2.3, engine and Control 0.1.48
Cargo.toml and Cargo.lock, desktop and host package.json and lock, PRODUCT_VERSION, CMake project and Metal STRATA_VERSION, build_hip_linux PKGVER default, About comments, tests that pin the number, Docker image tags and docs (0.2.3-<flavor>), download names in the READMEs, AMD_HIP.md and UPDATES.md, AGENTS.md, the bug report placeholder. The engine moves too: it shares the CMake number and its source changed since 0.2.2 (DFlash2, verify and prefill paths). A new test checks every place agrees and that a 0.2.2 install (Control 0.1.47) is offered Control 0.1.48.
2026-10-22 23:33:15 +02:00
Niko1221 814716336a a second launch reuses the same choices, asks or needs --restart for other choices, and frees the port first (G04/E01) 2026-10-22 23:31:30 +02:00
Niko1221 0530a1f7c2 retune stops its own server first; engine update gives one next step when the launch holds the card (E07/H04/H06) 2026-10-22 23:22:57 +02:00
Niko1221 f6db09bae2 launch keeps the API key it made until --new-key is given (E05/G01) 2026-10-22 23:21:37 +02:00
Niko1221 217ddda0eb status and logs find the running launch when no address is given (F02) 2026-10-22 23:20:36 +02:00
Niko1221 734ada7e1b requant_gguf: check the output's free space against the plan before writing (or in --dry-run, return 3)
The release runbook promises the pack/requant builder checks free space before it writes (DEEPSEEK-RELEASE.MD 9.6 item 5);
it did not.  --dry-run now prints 'space: N GB planned, M GB free on <fs>' and returns 3 when 98% of the free space does
not cover the plan, so 'requant_dry && requant' cannot start a run that will fill the disk; a real run raises instead.
With --resume only the bytes still missing are counted against the free space.

Verified on the box with two recipes that do not fit /data today: rq3 (224.3 GB planned against 128.6 GB free) and rq_gu3
(251.2 GB against 128.6 GB) both refuse with the numbers printed, rc 3 in dry-run.  pack_gguf's own output is a few MB
(tensors.txt, engram.bin, tokenizer) and needs no guard.
2026-10-22 22:38:16 +02:00
Niko1221 a14e85d8e6 tests: the pinned DFlash2 drafter address is allowed in strata_control (only strata drafting fetch reaches it, behind acquisition.require) 2026-10-22 21:19:41 +02:00
Niko1221 996b8a4044 dsv41: a type with no device kernel is known, laid out, and kept on the CPU lane (stream H1, second half)
The rule from DEEPSEEK-RELEASE.MD 6.5.2 - every ggml type works, a tensor whose type has no GPU kernel stays on the CPU
lane with one warning instead of stopping the load - is now implemented for IQ1_S (19), IQ1_M (29) and IQ4_NL (20):

- iq::known(t) (a layout exists: block_values/block_bytes/name) is separated from iq::supported(t) (a device kernel
  exists).  type_row_bytes/type_block use known(), so a pack lays out such a tensor instead of refusing it;
  gg::type_on_device(t) is what the VRAM tier tests.
- the CPU lane takes them: gg_cpu_type() covers 10-14, 16-23 and 29 (ggml-cpu's row dots).
- a layer whose experts are such a type is flagged cpu_only at load, printed once ('no GPU kernel for it, its experts
  stay on the CPU lane') and excluded from the VRAM budget in place_experts().
- iq::block_values(IQ4_NL) = 32 (QK4_NL): the only block here that is not 256 values.  Without it the CPU lane read
  IQ4_NL at the wrong row stride (2.4e-02 and 1.4e+05 error against the reference).

Verified: ds4_iq_test --type 29,19,20 runs the CPU lane against ggml's own decode and a double reference - IQ1_M
1.6e-08 / 4.6e-08, IQ1_S 5.4e-08 / 4.0e-08, IQ4_NL 3.1e-08 / 3.2e-08, ALL OK; the device types still pass; --pack-info
reports the layer and role for each such tensor.  A full model with real IQ1_M weights has not been run (none on the
box): the arithmetic of the lane is what is verified.
2026-10-22 20:27:19 +02:00
Niko1221 164dd3bbef Merge f-023-fix3: drafting network code loads only when used, rollback tests wait for the restored start, Arc B-series runs with pictures on 2026-10-22 19:58:52 +02:00
Niko1221 6577b69d5d Intel Arc B-series (Battlemage, e.g. Arc Pro B70) runs like any Arc: pictures on by default with the CPU reader, one plain line that the card is new and how to report a problem
Owner decision 2026-10-22. setup_intel no longer switches pictures off for a B-series card; it prints the line instead (known risk: the first-picture xe reset on the B70, 2026-10-19). strata launch gets the same line as a warn-level note (Computer.new_card_note) and its picture default for an Intel engine now follows the CPU-reader rule at any VRAM size (it used the GPU reader for a 32 GB B70). Gyro adoption on Arc was already allowed in this tree (adopt.FAMILIES). Tests that pinned the opt-in are updated; docs/INTEL.md, packaging/README.md and docs/GYRO.md say B-series is supported and untested by us.
2026-10-22 19:39:27 +02:00
Niko1221 dca4217501 test_runtime: the rollback tests wait 30 s for the restored launch (5 s was shorter than a Windows engine start); the two failures and the teardown error were a timeout, the rollback itself works
Same failure on v0.2.2 (011c9e600): the failed new start is refused for its port as intended, the old config is relaunched, but it was still loading when the 5 s startup wait ended, so rolled_back was False and the left-over process held the temp folder at teardown.
2026-10-22 19:35:29 +02:00
Niko1221 cf4bef2f78 dsv41: --pack-info prints the DSpark and Engram geometry a pack needs at run time
Today a pack built by another tool had dspark_block_size 0: DSpark ran one token a round and was worth +0.8% instead of
+28-77%.  The report now shows block_size / noise_token_id / markov_rank / the drafter's expert counts, the mtp tensor
count, and the Engram layer ids, with a <- marker when the block size is 0 or there is no drafter.  Verified on h225,
c0q3hQ6_K, c0q2d5 and mxfp4 (all: block 5, noise 128799, rank 256, 1242 mtp tensors, engram layers 1,14).
2026-10-22 19:33:47 +02:00
Niko1221 84b1e9815e strata drafting: load the network code only when the command runs, so a started engine's command line does not import it (437a60676 regressed the start-path rule) 2026-10-22 19:30:23 +02:00
Niko1221 c266cfb3cb Merge f-023-int into f-023-gui 2026-10-22 18:59:33 +02:00
Niko1221 a8301a2709 Merge f-023-metalfix2: no expert-cache loan in the Metal CLI prompt path either 2026-10-22 18:42:02 +02:00
Niko1221 669023fc08 Merge f-023-strixpp: Strix Halo gets its 8192 prompt chunk back on Windows, and the HIP engine finds its own tuning table 2026-10-22 18:36:58 +02:00
Niko1221 2f3f363c33 dsv41: --pack-info checks the files the pack points at (a cleaned-up model_dir is a dead pack, not a description)
The pack is pointers: ds4.txt + tensors.txt + the weights where model_dir says.  Today /home/niko/dsv41-c0/t3 was empty
and the c0q3 pack (all IQ3_XXS) described itself perfectly while being unable to load.  --pack-info now stats every file,
compares the length with the largest tensor end, prints MISSING/SHORT lines and exits 2 when any is bad; a healthy pack
prints 'files: N, all present and long enough'.  Verified: h225 10/10, c0q3hQ6_K 19/19, c0q2d5 12/12, c0q3 fails with
six MISSING lines.
2026-10-22 18:31:53 +02:00
Niko1221 b440640929 metal: no expert-cache loan in the CLI prompt path either (the 4K+ prompt of a plain strata run aborted in ExpertCache::fill_slot_queued) 2026-10-22 18:31:04 +02:00
Niko1221 155995b65c Merge f-023-drafting: the app hides DFlash2 until the engine serves it 2026-10-22 18:07:21 +02:00
Niko1221 e890e30766 The app offers DFlash2 only once the engine serves it: the Drafting setting is Auto / MTP / Off until then (Control reports it) 2026-10-22 18:07:03 +02:00
Niko1221 7b04360f93 hip prefill: the package tuning folder lookup split the exe path on '/' only (a backslash escape lost in the string): split on both 2026-10-22 18:05:47 +02:00
Niko1221 31785cce47 pack_gguf: build a pack from a quant that was made by another tool (the IST-DAS sets)
The IST-DAS 2.25bpw GGUF differs from ours in five ways that stopped pack_gguf, each found by building its pack:

- geometry(): ALIASES for the keys they spell differently (kv_source_layer_ids, index_source_layer_ids, the indexer's
  candidate_*, dspark_target_layer_ids, engram_layer_ids).  Without the source ids the engine refused the pack
  ('layer 2: compress ratio 2 without a KV source of that ratio before it').
- --engram-meta FILE: their GGUF carries no Engram hash parameters (no ple.*/engram.layers, no token map, no
  multipliers/primes/offsets - those are tensors of integer type in their file); the parameters come from a GGUF of the
  same model that still has them, the tables from --engram.
- integer tensors (I8/I16/I32/I64/F64) are left out with a line instead of stopping the build (engram_token_map.weight,
  engram_primes.weight, ...).
- --skip-tensors REGEX: their attn_output_a is one interleaved 3-D tensor ([8,1024,4096] ne) that this engine's
  o_groups slices cannot take; our Q5_K requant of the same BF16 weights supplies it through --dense-override, which
  now also *adds* a tensor the quant leaves out (it could only replace before).
- the DSpark geometry (dspark_block_size / n_routed_experts / num_experts_per_tok / target_layer_ids / markov_rank) is
  filled from the drafter's dflash.* keys and the markov tensor's shape, and --dspark-noise-token sets the model's noise
  token (128799).  Without it d_block stayed 0 and DSpark ran one token a round: h225 measured 16.77 tok/s off vs
  25.93 on (3.16 tokens a round) after the fix, 1.01 before.
2026-10-22 18:04:57 +02:00
Niko1221 e22a3c9ba9 Merge f-023-drafting: strata launch --drafting auto|mtp|dflash2|off and the app's Drafting setting; suggestion table (Auto = MTP until a card wins everywhere); pinned drafter download and numpy packer; gated until the engine serves DFlash2 2026-10-22 17:43:34 +02:00
Niko1221 b7196c23de Drafting in the app: "Drafting: Auto (recommended) / MTP / DFlash2 / Off" beside context in the model's settings
- The Models screen gets one more column in the context row, the existing Select, with the four choices; DFlash2 is offered for Qwen3.8-Flash-Next
  (the qwen and gyro families) only. The choice is saved with the model (a setting the app owns, like Draft layer) and shown again next time.
- Before a load the host asks Control (strata drafting plan / fetch --if-needed): when the choice would use the DFlash2 drafter and its file is not
  here, it is downloaded pinned and checked and packed, with plain progress in the load. A drafter that cannot be had never stops the load: the MTP
  starts and the job says so. The download plan dialog lists the drafter's size when it is chosen.
- What ran is told in the engine's words under Details and below the row: "Drafting: MTP (DFlash2 didn't fit)" and the other three reasons, all in
  the eight languages. Off is the draft layer's own switch.
- The app never reads the suggestion table: Control decides (strata_control/drafting.py), so the Terminal and the app cannot disagree.
2026-10-22 17:41:10 +02:00
Niko1221 fcb0c2d0ec Merge f-023-int into f-023-gui 2026-10-22 17:34:22 +02:00
Niko1221andmedking82 e17efa53ce Updates: an interruption that a later finished update settled says so in the same red alert (#3578)
Logic and tests ported from #3578 by medking82. The alert stays red and in place; only its words change for the restart interruption of an update that a later update of the same component finished. Sentence-case text in all eight languages; the PR's screenshots and validation files are left out.

Co-authored-by: medking82 <[email protected]>
2026-10-22 17:33:54 +02:00
Niko1221andmedking82 3f81245864 Reuse a retained legacy model library through a guarded native data root, without moving weights (#3362)
Ported from #3362 by medking82.

Co-authored-by: medking82 <[email protected]>
2026-10-22 17:33:01 +02:00
Niko1221andG0-0000 9471faa5f0 Web page: model settings rows no longer overflow their card on narrow windows (#3601)
The overflow was found and reported in #3601 by G0-0000. Its 75% column made the inputs narrower at every width, so this keeps the current layout when it fits: the label column may shrink, long names wrap, the input keeps 360 px.

Co-authored-by: G0-0000 <[email protected]>
2026-10-22 17:32:50 +02:00
Niko1221andjkir4n 3b51719abc Web page: keep long words and wide tables inside the viewport on narrow screens (#3594)
Ported from #3594 by jkir4n.

Co-authored-by: jkir4n <[email protected]>
2026-10-22 17:32:35 +02:00
Niko1221andengharat 2ba95cf61f Web page: use an edited API key at once, even when browser storage is blocked (#3435)
Ported from #3435 by engharat.

Co-authored-by: engharat <[email protected]>
2026-10-22 17:32:35 +02:00
Niko1221 437a60676f Drafting: strata launch --drafting auto|mtp|dflash2|off, one suggestion table, and the DFlash2 drafter fetched pinned and packed on the person's PC
- strata_control/drafting.py is the one place: the choice (saved in the model's run config as "drafting"), the table card class -> suggested
  drafter with the measured evidence (Auto picks DFlash2 only where it was faster on every tested prompt; today no class qualifies, so Auto is
  the MTP everywhere), the pinned drafter (incoai/Qwen3.8-Flash-Next-DFlash2 rev a22a1744, sizes and SHA-256 of the repository), what each choice
  puts on the engine arguments, and the plain "Drafting: MTP (DFlash2 didn't fit)" words read from the engine's own log.
- serve/server.py applies the choice at every start and reports "drafting" / "drafting_note" in /metrics.
- strata launch --drafting writes it to the launch config and presets, shows it in --plan, adds the drafter to the download plan when it would
  be used, fetches it before the start (a failure starts the MTP and says so), and prints the engine's fallback line.
- strata drafting plan|fetch (JSON/JSONL for the app). tools/dflash2_pack.py now reads safetensors with numpy alone (no torch on the person's
  PC), byte for byte the same file as before (checked against the earlier version), and ships in the Terminal bundle.
- DFlash2 is for Qwen3.8-Flash-Next (qwen, gyro) only. The engine in this release runs it only in its CLI run, not with --serve, so while
  drafting.ENGINE_SERVES_DFLASH2 is False the choice starts the MTP, says "DFlash2 isn't available in this mode yet" and downloads nothing.
2026-10-22 17:27:39 +02:00
Niko1221 f1b6a2144c Merge f-023-df2: DFlash2 drafter (opt-in --draft dflash2) merged onto the combined branch, builds on CUDA, HIP and Metal 2026-10-22 17:05:26 +02:00
Niko1221 c62d980112 hip prefill: with no STRATA_HIPBLASLT_TUNING in the environment the engine takes the table beside it (tuning/<arch>-hipblaslt-<version>.txt) itself, so a start that did not come through the launcher (hand-made config, CLI) does not read prompts at half speed on a Strix Halo; STRATA_HIPBLASLT_TABLE=off keeps plain hipBLAS 2026-10-22 17:04:58 +02:00
Niko1221 9ee8b0cf79 dsv41: --pack-info (the pack as a description: type map, bytes an expert/a token, tiers, tensors with no GPU kernel)
strata-dsv41 --pack-info --pack <dir> reads only ds4.txt and tensors.txt: no device, no weights, no lock.  It prints the
layer x role type map with an expert's bytes and a token's MB (top-k picks), the type totals, all-experts GB at the mean
bpw, the dense side split into embed / head / mtp / vision / engram, and every tensor whose type has no device kernel
(IQ1_S, IQ1_M, IQ4_NL today) with the layer and role - the H1 rule's report, and the first line of every benchmark log.
Verified on c0q3 (208.1 GB at 3.06 bpw, 3.251 GB a token - the document's figures) and c0q3d5.
2026-10-22 16:57:11 +02:00
Niko1221 051db74cdf gfx1151 on Windows: a bare --prefill auto may reach 8192 again (the 6144 cap of #1630 stays for other cards); STRATA_GFX1151_CHUNK=0 keeps 6144 2026-10-22 16:55:16 +02:00
Niko1221 49c157fae3 dsv41: IQ2_XS device kernels (bit-exact vs ggml), Q5_K/Q4_K/Q6_K embedding rows on the host, i-quant expert test, CPU-lane bench reference
- ds4_iq.cu: IQ2_XS (type 17) through the same kernels as IQ2_XXS: Ty<17> (74 B, 1024-word grid),
  decode<17> and deq8<17>, block_bytes/name/dispatch.  ds4_iq_test compares the token path, the window
  path, the prompt sets and the CPU lane against ggml's own to_float + a double reference: worst
  4.6e-08 (token gate_up), 4.6e-08 (down), 8.9e-03 (F16 tiles), CPU lane 3.5e-08.  IQ2_XXS / IQ2_S /
  IQ3_XXS / IQ3_S / IQ4_XS pass the same test unchanged.
- ds4_model.cu: gg_type_of takes 17; embed.weight may be any ggml block type (Q5_K / Q4_K / Q6_K ...)
  through gg_row_to_bf16 (ggml's to_float, the same BF16 rounding as the Q8_0 path) - the IST-DAS sets'
  Q5_K token_embd needs no dense override now.
- gg_dot_bench: the A/B's pass criterion was |x-y|/|x| on single outputs, which fails on cancellation
  while the two paths agree to a few ulps; it now also compares 64 sampled rows against a double
  reference scaled by the L1 norm (worst 1.1e-08) and requires |x-y| <= 1e-6 of the output scale.
  A/B at 32 threads: single token x1.03 (IQ2_XXS) / x1.13 (IQ2_S); 4 rows x2.4 / x2.3.
- CMake: ds4_iq_test target; ds4_tq_test and ds4_tq_test_portable link ds4_gg_cpu.cpp (the ggml CPU lane
  ds4_iq_cpu.cpp refers to since 49e82263a, missing from those targets).
2026-10-22 16:53:39 +02:00
Niko1221 0c467e9d45 Merge f-023-agent: agent context pressure from measured tokens, summaries in pieces that fit, recovery from the context-window 400, preflight of oversized messages, quote-aware read-only shell detection, visible compaction, plain context errors, stale child marker after a force-kill
# Conflicts:
#	apps/host/src/screens/Chat.tsx
2026-10-22 16:52:15 +02:00
Niko1221 76ac5842ff Merge f-023-tune: Work profile CPU workers ceil(0.8 x physical cores), VRAM reserve capped at 1536 MiB on 16 GB cards (picture floor only for a GPU reader); #3493 conversation cache sized automatically after load; #3438 bench reports an engine that did not stop 2026-10-22 16:26:51 +02:00
Niko1221 5de8d26949 tests: setup no longer tips about the conversation cache (the engine sizes it, #3493) 2026-10-22 16:22:37 +02:00
Niko1221 20fed551ba dsv41: Strata AVX2 i-quant rows opt-in (DS4_GG_IQ256=1) until the engine A/B 2026-10-22 16:21:25 +02:00
Niko1221 e4b31d6f69 strata-context: drop the development log hook 2026-10-22 16:14:56 +02:00
Niko1221 b96f174775 Merge f-dflash2 (the DFlash2 drafter, opt-in) into f-023-int 2026-10-22 16:10:08 +02:00
Niko1221 f0010a607f serve: the conversation cache is sized from the free RAM when --conversation-cache-mib is not given (#3493): 40% of what is left above the parking floor, at most 8192, off under 1024, one log line; an explicit value (0 too) is kept; harness_reuse_probe.py; docs 2026-10-22 16:06:17 +02:00
Niko1221 ed0ed38e2d Merge f-023-tls: #3722 HTTPS uses the OS certificate store (truststore, certifi fallback) in setup, Control, serve and the fetch tools; the app host adds the system roots; a certificate failure says plainly what is intercepting and what to try 2026-10-22 15:43:33 +02:00
Niko1221 c4aec611b2 tls: downloads check the server against the operating system's certificates; a failed check gives a plain message (#3722)
Python's OpenSSL checks against certifi's roots and does not fetch a missing intermediate; Chrome and curl.exe use the Windows store
(schannel), which also holds the roots antivirus HTTPS scanning, a company proxy or a preview build adds. strata_control/tls.py
install() puts truststore (pinned in requirements.txt and the three runtime locks with hashes, CPython 3.12 in every archive) under
every HTTPS connection of setup, Control's transfer and component download, serve and the fetch tools, with certifi as the fallback.
A certificate failure is no longer the raw urlopen text and is not retried: it names the likely causes (antivirus web scanning, a
company or school proxy), says what to try, and never offers to skip the check.

The host: server/system-ca.mjs adds the system roots to Node's bundled list at start (Node 24.5+), and the agent process gets
NODE_USE_SYSTEM_CA=1.
2026-10-22 15:43:12 +02:00
Niko1221 3ae6b3041b A big message on a chat near the compaction threshold summarises first; the real-engine script can also run against a server that is already up 2026-10-22 15:40:55 +02:00
Niko1221 ebaeda85f1 Summarize and continue asks for the answer instead of sending the refused message a second time; agent README describes the context pieces 2026-10-22 15:37:35 +02:00
Niko1221 815256d89b Tests for the two ways to send a message that is too large (file in the chat folder, shortened preview) and the host route 2026-10-22 15:26:53 +02:00
Niko1221 b14862351b The chat checks a message against the model's window before sending it, handles /compact itself, and explains a full context in plain words
- /compact was typed into the chat and sent to the model as text (it answered "nothing to compact, this conversation is still short" at 90%
  full). The host now runs the Harness's /compact and says how full the chat was and is, measured.
- Before a message goes out the model server counts it (its own tokenizer). Fits: sent. Fits once the older messages are a summary: summarised
  first, with a line in the chat. Can never fit (a 103 KB paste is 70K tokens in a 64K window): not sent, and the card offers "Attach as a file"
  (saved in the chat folder, the assistant reads it in parts) or "Send a shortened preview".
- A refusal that remains after the Harness's own compact-and-retry is a plain sentence with the engine's text under Details, and the card
  offers "Summarize and continue" or "Start a new chat" instead of "Try sending the message again".
- Compaction shows: a status line while it runs and one plain line afterwards. The context meter follows the running turn (the host reports
  what the context holds after every model request) so it no longer reads 1% between two tool steps.
- A Harness error "lost host child is still present" reads as "the last Strata session is still closing down".
- All new sentences are in the catalog in the eight languages. The real-engine run is scripts/context-e2e.mjs.
2026-10-22 15:26:30 +02:00
Niko1221 f8f4e8a7df bench: a failed engine stop is loud and fails the run (#3438): stop_engine returns why the process may still be alive (taskkill result and exit, the 60 s and 30 s timeouts), the run prints an ERROR, writes ENGINE-NOT-STOPPED.txt, exits 6 and reports ok false 2026-10-22 15:20:59 +02:00
Niko1221 acf63bef73 app: the Work profile takes 80% of the physical cores rounded up (the engine's own count at most), keeps no more than 1536 MiB on a card of 16 GB or less, and a CPU picture reader no longer raises the reserve to 2048 (RTX 5070, Gyro-S 64K: 9 workers + 2048 33 tok/s, 5 + 1536 46) 2026-10-22 15:17:24 +02:00
Niko1221 098fd545b3 A marker left by a force-killed app no longer blocks the next load when its process number belongs to a later program
The child marker without a creation time could not tell the recorded engine from another Python or Strata program that had reused the
number, so every load said "lost host child is still present" until the marker was deleted by hand. The child is always started before
its marker is written, so a live process created after the marker's file time (with a two-second slack) is not the child. Unreadable
times still keep refusing; a recorded creation time still decides by itself. New procs.started_epoch reads a creation time on Windows,
Linux and macOS.
2026-10-22 15:02:49 +02:00
Niko1221 a7cd2de97a A look-only shell command with an & or a bracket inside quotes no longer asks for approval
The read-only check refused any command containing & ; ( ) { } before it understood quoting, so listing "C:\...\GUI Q&A" showed an
approval card. Quoted text is now set aside first (maskQuotes): an expansion inside double quotes ($ or a backtick), an unterminated
quote, a quote glued to other characters and a quoted text that starts with a dash are still not understood, so those and everything
outside quotes ask as before.
2026-10-22 14:46:52 +02:00
Niko1221 be58604387 Compaction reads the prompt size the model server measured, and a summary request always fits the window
The Harness prices a conversation at 4 characters a token; code, logs and JSON are about twice as dense. A new Harness plugin
(plugins/strata-context.mjs, arithmetic in context-budget.mjs) wraps the token meter so pressure is the last measured prompt plus what
came after, priced at the density that measurement showed. It also wraps the compaction summary request: when it would not fit the
window it is asked for in pieces (a message bigger than a piece is cut for the request, each piece builds on the summary before it, a piece
the server still refuses is halved). A chat the engine refused once (a 70K-token message in a 64K window) is no longer stuck: the next
message compacts and goes through. Tests run the real Harness against a scripted server that counts and refuses like the engine.
2026-10-22 14:40:20 +02:00
Niko1221 5701fc65ce Merge f-023-tools: tool-returned pictures with elastic vision, picture-reader start errors and per-variant packaging, Markdown images (safe sources), PR #3556 tool aliases, MCP wiring, command-tool cwd/shell/timeout, image-token limit, gate fails closed 2026-10-22 14:31:55 +02:00
Niko1221 0351ecb11d Two GTX 1080s are a working PC: an 8 GB card starts Off only beside a bigger card, and a PC whose only NVIDIA cards are Pascal uses them on the CUDA 12 engine (#3655)
recommend() (Terminal, app and shared vectors) now turns a card with 8 GB or less Off only when a bigger usable card is there or it has under 4 GB. The hardware report (tools/strata_mcp.py) applies setup's own opt-in rule for a PC with only Pascal/Volta NVIDIA cards and no usable AMD card, so those cards are no longer flagged 'older than the RTX 20 series' (engine_variant cuda12); the feed already picks the cuda12 package for sm_61 and refuses cuda13. Tests: shared gpu-roles vectors, strata_mcp hardware, feed selection for sm_61, and Tune keeping a two-card role map.
2026-10-22 13:47:41 +02:00
Niko1221 d9042a16bd Missing model files are named, with one next step (#3478)
The load used to stop with 'Some model files are missing' and nothing else, so Retry looked the same every time. Control's model list now carries the required parts it cannot find (part, file, status); the host joins them with the installer's missing GGUF files and shards, puts the names in the error (four shown, all in Details and in the load log), and the app shows 'These model files are missing: ...' with 'Press Download & load again to fetch them. Files that are already there are kept.' in all eight languages.
2026-10-22 13:29:59 +02:00
Niko1221 642f5120a7 Route change: the view transition's update callback no longer waits for requestAnimationFrame (#3673)
While the callback is pending the browser holds rendering, so the frame callback never ran and every click waited for the browser's own guard: about 4 s in Chromium (and so the WebView2 app window, reproduced in Edge 155: callback 4023 ms) and about 5 s in Firefox. The callback now resolves on the first DOM change after the hash flipped, or after 150 ms. Measured in Edge 155: callback 9-19 ms, full transition about 350 ms, with and without a 3.6 MB chat history.
2026-10-22 13:24:33 +02:00
Niko1221 98a139c3c7 Updates never cross GPU makes: Windows display adapters decide the backend, an installed engine of another make is not replaced (#3688)
On an AMD R9700 PC a leftover nvidia-smi made detection say cuda, so the update staged cuda13 and its test start failed. detect_backend (Control) and gpuBackendHere (app) now let the Windows display adapters decide first and ignore a tool for a make with no adapter. The update decision keeps the installed engine's make when the PC has a card of it, and otherwise stages nothing and says so in plain words (status wrong-make, shown by the app). update_engine refuses a package of another make before any download. --backend and STRATA_ENGINE_BACKEND are the explicit way to change make.
2026-10-22 13:21:36 +02:00
Niko1221 828a2559a9 vision: the image-token limit has a default and is enforced (I7, #3690)
The reader was clamped only when the config carried vision.max_tokens; an old, hand-edited or migrated config left it unclamped and
one picture became 93,700 image tokens. Vision.token_limit gives every reader a limit (GPU 1024, CPU 300, the numbers setup writes)
unless the config names one (a value the person set is kept, 0 still means none). strata-vision refuses a picture that comes back
over the limit plus an eighth with a plain ERR instead of passing 90 times the budget on, and prints a WARNING when its own warm-up
picture is over it. The server logs a picture that is more than 4 times the limit with the numbers to send with a report.
2026-10-22 13:10:12 +02:00
Niko1221 bf8285b8c9 Merge f-dflash2-check: the per-context cost table for the cost-aware MTP window (opt-in, STRATA_SPEC_COST), phase 0 tools and launch-script fixes 2026-10-22 13:09:05 +02:00
Niko1221 a71fd34758 agent: custom command tools run in the right shell and folder, with a time the tool can raise (T7)
shell: true meant cmd.exe on Windows (Get-Content failed), the host's own folder (python tools/foo.py found nothing) and a fixed two
minutes with silently cut output. custom-command-run.mjs: a Windows command that reads as PowerShell runs in pwsh or powershell.exe,
any other in cmd.exe as before, sh elsewhere (or the tool's named shell); the folder is the call's optional cwd, then the tool's cwd,
then the home folder, and a missing folder is a plain refusal; the time is the tool's timeoutSec (1 to 3600, default 120) and ends the
programs the command started; a cut output says so. Settings stores cwd, shell and timeoutSec for a command tool and an edit that
does not send them keeps them. No new field in the Settings screen: that needs a design decision.
2026-10-22 13:09:00 +02:00
Niko1221 7d55581527 agent: stop claiming a window opened; point the model at the present tool (T9, O6, #3576)
A launch command that printed nothing was reported as 'The program, folder or file was opened.', and the system prompt told the model
to say it is open. A browser given a file:// address also returns without an error, and the model then reported that it had previewed
the page. The card now says what is known (the command finished without an error, so the program was started; Strata cannot confirm
that a window or page opened). The prompt (and the plan view step) send local images, SVG, HTML and PDF to the present tool and
forbid claiming a window opened or a page rendered unless a tool confirmed it.
2026-10-22 13:07:18 +02:00
Niko1221 034f2fdebe agent: tool servers follow the default model, one naming rule, a dropped server is reported (T5, T6)
T5: the agent's tool servers came from local[0], the first model loaded, not the model the chat runs on. buildProfile takes
defaultLocalId (the native default instance, agent-bridge option defaultLocalId); the agent's default route follows it too.
T6: the editor and the save check accepted 64 characters while the profile dropped everything over 32 or with a dot, silently.
server/mcp-names.mjs holds the one rule (32, letters, digits, hyphen, underscore; src/lib/mcp-names.ts is its copy, a test
keeps them equal). A name saved before the rule stays saveable. mcpRows reports what it leaves out (name, no program or address,
a name that collapses to another's id); the agent status carries skippedServers and the chat shows one notice per reason before
the turn. The sentences are in all eight languages (src/locales/tools-<lang>.json).
2026-10-22 13:06:11 +02:00
Niko1221 c5b13accfd metal: serve_probe.py, the owner round 9 short/long prompt probe on the default config 2026-10-22 13:05:58 +02:00
Niko1221 4791d6b676 agent: the approval gate fails closed when it cannot read the permission mode (T4)
A session whose preset lookup threw ran the call unasked (return next()). It is treated as Manual now: a call that is not provably
read-only asks, a read goes through, and the reason is logged.
2026-10-22 13:03:11 +02:00
Niko1221andAmit Tsur 8bf178febf serve: tool-call aliases and wrapper boundaries (port of PR #3556), and an unparsed call is logged (T1)
Ported from #3556 (Amit Tsur): a declared namespaced tool written with its flat alias (mcp__server__tool for mcp__server.tool) keeps
its schema while streaming and is reported under the declared name; an ambiguous alias is no alias; a quoted incomplete wrapper
(`Format: <tool_call>... </function> and then I explain it.`) stays text; a following call opener separates concatenated calls;
streamed JSON is closed only once the wrapper or the next call confirms the boundary; a call cut by the output limit stays
incomplete. The Responses-only alias map is gone (the parser resolves aliases now).

Reconciled with the 0.2.2 envelope rule: with tools declared, a call for an undeclared name stays text on every route (the PR's
tests that expected a native call for an undeclared name now expect text, or a native call only where no tools were declared and
declared_only is off: the Responses/Codex compaction route keeps that). The aliases attribute replaces the envelope work's _alias
set, so the lenient opener (#3441) and the declared-only gate use the PR's unambiguous alias table.

New rail: a call that named a tool the request did not declare is counted (parser.unread) with the names the model wrote
(parser.unread_names), and the server's existing log line for unread calls now prints them.

Co-authored-by: Amit Tsur <[email protected]>
2026-10-22 13:01:04 +02:00
Niko1221 1fa22cd29a app: the chat renders Markdown images from data: URLs and the chat's own folder; remote pictures stay links (O3)
![alt](src) used to come out as a link with a stray '!', and a data: URI as literal text. Allowlist (src/lib/markdown-images.ts):
data: PNG/JPEG/WebP/GIF (never SVG), the host's /api/files/raw address, and a scheme-less path read through that address for the
active chat. http(s) pictures are not fetched: they show as a link with the alt text. file:, javascript:, blob:, share paths and
every other scheme show as text. One .md-image rule keeps the picture inside the bubble.
2026-10-22 12:58:22 +02:00
Niko1221 e2858b9855 packaging: the picture reader is built per CUDA variant and checked against the package's archs (I1, I2)
build_cuda_linux.sh builds strata-vision for the same architectures as the engine (cuda12 includes sm_60/61/70) and records
vision_archs in BUILD.json; the Dockerfile records it too. packaging/build_vision_windows.bat does the same for the Windows
cuda12 and cuda13 zips (they shared one reader built before Pascal support, #3669). package_engine.py refuses a CUDA package whose
reader's vision_archs do not cover its archs and a macOS package without strata-vision (#3697); --allow-stale-vision and
--no-picture-reader are the explicit exits; --stamp-vision records a hand-built reader. ENGINE-COMPONENT.md describes the release steps.
2026-10-22 12:56:30 +02:00
Niko1221 e6dbbc312a metal: no expert-cache loan for the prompt path (Prefill::relayout and the refill into given-back allocations), auto expert cache capped at 1500 slots; CI: Metal build job on a macOS runner 2026-10-22 12:55:09 +02:00
Niko1221 425bca7199 Agent watchdog: the engine's prompt-read progress counts as activity; local routes get a 60 min stream-idle timeout (#3623)
A local engine reading a long prompt sends the Harness nothing for minutes, so the 4 minute stall limit ended the turn mid-read. The host now hands the bridge the engine's /metrics progress (live.prompt_read, generated, slots); a moving mark keeps the turn alive for at most 30 minutes after the Harness last spoke. An engine that stops reporting still ends at the usual limit. API routes are unchanged.
2026-10-22 12:54:48 +02:00
Niko1221 1298a3971c Chat store: save the chat list at most once a second, serialise only changed chats, cap the saved copy (#3604, #3603)
Every streamed token stringified every chat and wrote the lot to localStorage, which grew the WebView2 renderer to gigabytes. Writes from the streaming path are now coalesced (1 s), flushed when a turn ends, is cancelled or fails, and on pagehide/hidden. Unchanged chats reuse their JSON by object identity; a field over 200k characters, a chat over 1.5M and a list over 3.5M are shortened in the saved copy only. The stored format is unchanged.
2026-10-22 12:52:53 +02:00
Niko1221 c40a03e6dc serve: a tool's picture with an elastic image encoder starts the encoder (I6); a failed encoder start carries its stderr tail, exit status and card hint (I3)
A picture a tool returned wrote to an elastic encoder that was not running (AttributeError, then a 400 that every later
turn repeated). _note_unreadable_tool_images now calls _vision_up and turns a start failure into the usual
[image omitted: why] note; Vision.encode raises a clear ValueError when no reader runs.

vision_start_error no longer returns a blank sentence: last stderr lines, the exit status in words, the seconds of a
timeout, and a kernel-image error (sm_60 on the Windows cuda12 reader, #3669) names the card/architecture and the CPU way out.
2026-10-22 12:52:37 +02:00
Niko1221 eebb870922 dflash2: reserve the drafter's head before the expert cache is sized, loud fallback lines naming the VRAM needed, Blackwell window cap 6 (draft_cap_test) 2026-10-22 12:44:45 +02:00
Niko1221 dd8201d20d metal: link stubs for hc_mean_streams and route_marginal_stats (STRATA_TAP_FILE / STRATA_ROUTE_STATS hooks of the shared Verifier; owner round 9 G6 link failure) 2026-10-22 12:44:40 +02:00
Niko1221 a4c503d7ce nv_defaults: the sm_86 speculative start-up adaptation is opt-in (STRATA_SPEC_ADAPT_SM86=1); owner round 9 measured 0.995 on the A4000s and a length-confounded 3060, so the earlier gain did not reproduce 2026-10-22 12:43:35 +02:00
Niko1221 23b3dfc2ca Merge f-0221-sycl: a Linux SYCL (Intel Arc) engine package recipe and feed entry, --version without GPU access, RUNPATH $ORIGIN; Arc PCs get an engine from the feed 2026-10-22 12:13:33 +02:00
Niko1221 8be3f916f2 Intel Arc engine package: packaging/build_sycl_linux.sh, tests, docs
No release feed ever carried a linux-x86_64-sycl engine package, because no recipe built one (the 0.1.45 package of stream split-s1 was made by
hand). build_sycl_linux.sh builds the JIT engine with oneAPI, bundles the oneAPI runtime and the Unified Runtime adapters with collect_libs.py,
sets RUNPATH $ORIGIN on every ELF file (Intel's libraries have none or a wrong one), packages it with archs intel-arc and checks the package
with --verify, binary_deps.py and an env -i strata --version. _drm_vendor_backend takes the sysfs root so the Arc detection can be tested with a
fake /sys/class/drm; tests/terminal/test_sycl_package.py covers the choice (an Arc PC with no oneAPI tools gets the package, nobody else does,
Windows gets a reason) and the recipe; the release-matrix test has the sycl package.
2026-10-22 12:12:30 +02:00
Niko1221 424b364a88 SYCL engine: strata --version answers before any GPU is touched; no SYCL device is built before main
The migrated generate.cpp had no --version (the CUDA and HIP engines answer it first thing), and three things in dpct's helper headers
built a SYCL device at program start: blas::descriptor's inline _saved_queue_ptr, the dpct::device_memory constructor (pointer table,
mem_mgr, dev_mgr) and its default-built sycl::queue member. On a PC whose user cannot open the GPU the engine therefore aborted with
'No device of requested type available' before it could print anything, which also failed the engine smoke test of strata update.
The headers are edited and sycl/tools/fixups.py reproduces each edit after a re-migration. A frozen first-token-logits run on an Arc A750
(4095-token prompt, IQ3_XXS) is byte-identical before and after.
2026-10-22 12:12:30 +02:00
Niko1221 0788a91c64 Merge f-0221-fix: #3683 test start behind a Windows venv launcher, #3670 certifi 2026.10.19, #3613/#3467 calibrate in the bundle, and the owner's round 8 Terminal fixes (real errors, cards and roles kept, --mtp off, every download planned, engine-version preflight) 2026-10-22 11:33:43 +02:00
Niko1221 ad98bf1d17 N6: Gyro on macOS needs engine 0.1.47 (the Metal TQK6/TQK7/TQ2_T kernels first shipped there) 2026-10-22 11:33:30 +02:00
Niko1221 c67180edf1 Owner round 8: show the real engine error, keep cards and roles, honour --mtp off, plan every download, check the engine version
F5: a start that did not reach ready names the stage, the cause line from the engine log, a next step (render group on Linux, smaller
context, strata update) and the log path; --no-tui and runs without a keyboard never ask 'Keep or move?' and print what was kept.
F3: a context or picture change forwards the installed cards and Main/Helper roles to setup.
N3: --mtp on|off reaches setup, and setup no longer fetches the draft layer for --mtp off.
N4/N2: --plan counts a picture reader unless one sits beside the files, names the --vision off and --mtp off switches, and counts
the ready-made pack (NVFP4) setup would fetch; setup's disk hint is --data-dir under launch.
N5/N6: STRATA_DATA_DIR/install is looked at first, --plan says when an earlier install is the one in use, and an installed engine
older than the model needs is updated or named.
N8: --plan warns when the file headers say another family or model than the one named.
N9: update --check names the package it selected.
N11: the Arc adopt path takes Gyro and the sentence has no double full stop.
2026-10-22 11:32:32 +02:00
Niko1221 49e82263ad dsv41: CPU lane takes Strata's AVX2 i-quant rows (switchable), ds4_gg_bench, pack_gguf repeatable --dense-override 2026-10-22 10:31:03 +02:00
Niko1221 736d7a840c dsv41: pack_gguf --override-experts takes several folders with roles 2026-10-22 10:16:05 +02:00
Niko1221 a539e12c97 Merge f-strix-hcq8: Strix Halo 8-bit hyper-connection weights by default (gfx1151), sm_86 start-up adaptation default, gfx1151 expert layout mode 8, env-gated DFlash2/spec instrumentation and the opt-in cost-aware MTP window 2026-10-22 10:10:32 +02:00
Niko1221 2a22daa4f0 Fix #3613 #3467: the Terminal/Control bundle ships tools/calibrate.py; a setup crash now says what it was
setup.py imports tools/calibrate.py after a tune, but INSTALLER_TOOLS did not list it, so every launch after a tune died with
ModuleNotFoundError and only 'The model could not be installed'. The list now names it, and add_installer also reads the
tools/ modules setup.py imports so the list cannot go stale again (stage-control.py uses the same list).

A new test stages the bundle the way package_terminal.py does and imports setup.py and its tools with tools/ limited to the bundle.
strata launch keeps the last 400 lines of setup's output in launch/<name>.setup.log, shows the Python error line in plain words
when setup crashed without an [X] line, and names the file.
2026-10-22 10:08:50 +02:00
Niko1221 27a5707de5 Fix #3670: certifi 2026.10.19 (drops the emSign ECC Root CA - G3 root) in requirements.txt and the three runtime locks 2026-10-22 09:38:34 +02:00
Niko1221 72d0bf667e Fix #3683: the test start no longer waits out its 10 minutes when a Windows venv launcher sits above the server
A source checkout's .venv\Scripts\python.exe starts the real interpreter as its child, so the pid in the server's ready file
was one below the pid the runtime host started, the ready file was never matched, no probe was sent and the update ended
with startup_wait_expired. The host now accepts a ready pid within three parents of its child (Windows only).

The test start also shows progress (loading, loaded and waiting for one token) and a wait that runs out says what the engine was
doing, with the kept log, instead of blaming a busy graphics card because the log mentions VRAM.
2026-10-22 09:38:34 +02:00
Niko1221 e7ded666be docs: the Strix Halo 8-bit hyper-connection line goes into a new release-notes-0.2.3.md (0.2.2 is released) 2026-10-22 09:13:17 +02:00
Niko1221 3ae2123bce dflash2: --draft-conf (window cut by the selector's cumulative probability), drafter streams at high priority on CUDA 2026-10-22 08:51:54 +02:00
Niko1221 6d4874a491 hip: 8-bit hyper-connection weights (STRATA_HC_Q8) on by default on gfx1151, STRATA_HC_Q8=0 keeps the bf16 read; one start-up line; docs and 0.2.2 release note with the KL table and the CPU-vs-GPU rounding baseline 2026-10-22 08:16:47 +02:00
Niko1221 b1ebc3d7ca spec: the cost-aware window keeps one row-cost table per context length (< 6K, < 24K, longer), seedable per bucket 2026-10-22 07:24:46 +02:00
Niko1221 5e52cf569f dsv41: pack_gguf --override-experts (mixed-bit packs from two requantized sets); stageB tooling 2026-10-22 07:03:10 +02:00
Niko1221 ff9ca03f3c dflash2: a verify window whose graph runs out of memory narrows the window instead of failing; --draft-cap, 5 by default on Strix Halo, a plain line on 12 GB NVIDIA 2026-10-22 06:24:00 +02:00
Niko1221 5278ba0043 dsv41: dense K-quant GEMV (Q5_K/Q6_K) + dense_requant, ggml-type CPU lane, imatrix builder, requant fixes (C0 experiments) 2026-10-22 04:03:32 +02:00
Niko1221 1ef68f83e7 docs: note the STRATA_HC_Q8 measurement on the Strix Halo
+8.4% on json_extract at fixed windows (53.55 -> 58.03 tok/s), 94 of 250
output tokens differ from the BF16 read, cold HC read 93.8 -> 20.3 us
(142 -> 352 GB/s).  Experimental, opt-in only, never a default.
2026-10-21 23:43:02 +02:00
Niko1221 aec327ee51 gr: fold the hyper-connection inject and gate/mean into their neighbours
gr_read launched six kernels per hyper-connection read: norm, down,
gate, mean, inject, write.  The inject projection is now the down grid's
last block (one warp per stream, the same lane stride and warp_sumf as
gr_inject_kernel), and gate + mean are one kernel (one block owns WARPS
columns for every stream, the per-stream gated values meet in shared and
are added in the old c order).  Both keep the old per-row arithmetic and
reduction order, so lo/lq, mixed and inject are bitwise unchanged; gated
is no longer written to global because nothing outside gr_read reads it.
Six launches per read become four.

This is a no-op for the default IQ3_S path on the Strix Halo: that engine
already takes the fused (staged) hyper-connection read, not gr_read.  The
change is kept for the unfused gr_read path (CUDA, other HIP
architectures and --no-fused-gr).

Verified on gfx1151: json_extract at fixed windows (--spec 4
--spec-min-p 0 --suffix-draft 0) gives an identical output line before
and after.
2026-10-21 23:42:55 +02:00
Niko1221 9fe2ad3f55 wip: gfx1151 takes mode 8 gate/up (LDS, fused SwiGLU + q8_1) and the CUDA-layout down for IQ4_NL 2026-10-21 19:55:22 +02:00
Niko1221 ba6966d83f verify: the DFlash2 tap kernel only where the DFlash2 sources are built (Metal and SYCL keep the per-row dump path) 2026-10-21 19:52:11 +02:00
Niko1221 4c953b424d wip (experiment): expert layouts compiled for every HIP arch 2026-10-21 19:46:43 +02:00
Niko1221 6f83fe2c42 dflash2: drop the cache-touch kernel (no gain), wait-commit measurement switch 2026-10-21 19:46:26 +02:00
Niko1221 2ccf57ec46 spec: faster start-up adaptation of the expert tier in speculative runs (swap every 2 verify rounds for the first 200, then the normal interval); default on sm_86 only, STRATA_SPEC_ADAPT_EVERY / STRATA_SPEC_ADAPT_WARM set it elsewhere, an explicit --adapt-every is kept 2026-10-21 19:45:52 +02:00
Niko1221 89799596a6 dflash2: fused conv+residual+norm+quant, q/k norm+rope, K/V ring write and attention-output quantization; stage skip switches and small benches 2026-10-21 16:34:27 +02:00
Niko1221 48ec4a59ac spec: cost-aware window: the first round of a width is replaced by the guess from the other widths, a running mean for the first samples 2026-10-21 16:10:56 +02:00
Niko1221 e0d79b02f9 spec: with STRATA_SPEC_COST a round far above the known cost of its width (the first use captures a graph) counts as a tenth of a sample 2026-10-21 16:04:10 +02:00
Niko1221 d45cd9d3cf spec: STRATA_SPEC_COST_TABLE seeds the row costs (a per-card table), the CLI prints the learned table at the end 2026-10-21 15:35:07 +02:00
Niko1221 5bc7f8cab3 dflash2: tiled Q8 GEMM, fused quantizers, head-major K/V ring with a prefetching attention, forked context update, PLE read-ahead, benches 2026-10-21 15:31:29 +02:00
Niko1221 b7d5ea2f82 native_expert_bench: BENCH_CAP sizes the launch for more possible groups than real ones (what the empty block rows of a verify window cost); bench only 2026-10-21 15:08:13 +02:00
Niko1221 d232721db6 spec: cost-aware chain rule keeps the best width seen and looks one or two drafts ahead (the row cost is not convex); unmeasured widths are probed a few times 2026-10-21 15:03:51 +02:00
Niko1221 5f49ec4599 spec: STRATA_SPEC_COST keeps an MTP draft only while the next row pays at the measured row cost (step-by-step chain rule in MtpDrafter, DraftPolicy::keep_draft) 2026-10-21 14:46:56 +02:00
Niko1221 54f81315bf dsv41: DS4_TRACE routing trace (env), routestats / budget model scripts live in phaseA-tests 2026-10-21 14:12:19 +02:00
Niko1221 6878105560 spec: STRATA_SPEC_COST, a cost-aware MTP window (DraftPolicy::best_rows: the window with the best expected tokens per ms from the chain's probabilities, a measured probability-to-acceptance table and the measured row costs); the trace now logs the probabilities the window was chosen from (A/B arm, off by default) 2026-10-21 14:07:25 +02:00
Niko1221 7f97c35aca DFlash2 drafter for Qwen3.8-Flash-Next: Q8_0 pack tool, GPU forward (conv, K/V ring, windowed attention, top-16 selector), permanent verify/prefill taps, --draft dflash2 --draft-model (opt-in, MTP stays the fallback) 2026-10-21 14:04:15 +02:00
Niko1221 651a56806a generate: STRATA_SPEC_TRACE, a per-round trace of the window rows, accepted drafts, round ms and MTP draft probabilities for offline window-length studies (bench only, off by default) 2026-10-21 13:46:06 +02:00
Niko1221 d276adf277 verify: STRATA_ROUTE_STATS (distinct and new-per-row routed experts per layer per window width) and a per-width GPU stage profile line for STRATA_SPEC_T_CYCLE runs (bench only, off by default) 2026-10-21 13:28:02 +02:00
Niko1221 d8f58af1b5 generate: STRATA_SPEC_T_CYCLE, a bench-only cycle of the verify window size with a per-width cost line (off by default) 2026-10-21 13:25:20 +02:00
Niko1221 dcba249d01 verify: STRATA_TAP_FILE, an env-gated dump of the hidden states at chosen layers per verified row (DFlash2 acceptance experiment; off by default, no new graph nodes without it) 2026-10-21 13:25:19 +02:00
Niko1221 5f426c38cb tools/dflash2: q8test, the drafter with Q8_0 matrices keeps its acceptance 2026-10-21 13:24:21 +02:00
Niko1221 128223891e tools/dflash2: the torch port of the DFlash2 drafter and the scripts of the acceptance experiment (research tooling) 2026-10-21 13:24:21 +02:00
Niko1221 17c9b92bc9 generate: STRATA_SPEC_T_CYCLE, a bench-only cycle of the verify window size with a per-width cost line (off by default) 2026-10-21 13:24:21 +02:00
Niko1221 d1dec64362 verify: STRATA_TAP_FILE, an env-gated dump of the hidden states at chosen layers per verified row (DFlash2 acceptance experiment; off by default, no new graph nodes without it) 2026-10-21 13:24:21 +02:00
Niko1221 2355265fdc dsv41: pack_gguf --tq-layers (a subset of the converted layers) 2026-10-21 08:24:45 +02:00
Niko1221 1c2388876c dsv41: trellis gate/up fused in one pass sharing the activation fragments (token path 454 -> ~355 us a layer, outputs unchanged: ds4_tq_test ALL OK); optional 32-bit stream loads (no gain) 2026-10-21 01:30:01 +02:00
Niko1221 e8cb1cba7f dsv41: tq_convert resumes (skips finished layers), pack_gguf --tq-experts takes several folders 2026-10-21 00:26:31 +02:00
Niko1221 da70196350 dsv41: tq_convert --act (input-column weighted encode from the gate-3 calibration sums) 2026-10-20 23:24:48 +02:00
Niko1221 88fcf2b443 dsv41: engine-kernel bench in ds4_tq_test (--bench) 2026-10-20 23:21:41 +02:00
Niko1221 12a3c0739e dsv41: portable (FMA) tile test target, docs for the trellis experts 2026-10-20 22:31:25 +02:00
Niko1221 7df82763e3 dsv41: tq_convert.py (re-encode a pack's MXFP4 experts with the trellis encoder into the engine's file layout) and pack_gguf --tq-experts 2026-10-20 21:52:59 +02:00
Niko1221 e5b4021c6f dsv41: trellis routed experts end to end on the device and the CPU lane (TQK6/7, TQK5X/4X computed, TQK5T/4T table): ds4_tq.cu (gfx12 WMMA + portable FMA tiles over the file layout), ds4_tq_cpu.cpp (Strata tq_cpu kernels), iq entry points route the types; test ds4_tq_test (all six types pass: device and CPU lane within 1e-6 of a double reference) 2026-10-20 21:49:29 +02:00
Niko1221 d6a6c1c797 Merge f-dsv41-enc (trellis encoder, K4/K5 computed-code types 224/225, table versions 226/227) into f-dsv41-port; conflicting trellis files taken from f-dsv41-enc 2026-10-20 21:23:42 +02:00
Niko1221 a5f54c9796 dsv41: test includes point at src/dsv41 2026-10-20 21:02:33 +02:00
Niko1221 e38025769d dsv41: picture test (mkimg.py) and serve_prompts GENI / text output 2026-10-20 20:40:41 +02:00
Niko1221 3d8d1079bc dsv41: gfx12 WMMA trellis GEMV (ds4_tqw.cuh, from the CUDA lab's recipe) with a parity + bench test: all K4-K7, widths 1/3/5, table and computed codes within 8e-8 of the double reference on the R9700; ~1.07 Tw/s 2026-10-20 20:09:32 +02:00
Niko1221 6c8247af60 Merge f-dsv41-serve into f-dsv41-port 2026-10-20 20:03:45 +02:00
Niko1221 2713121725 trellis: computed codebook on the GPU encoder, README for the K5/K4 versions 2026-10-20 19:59:48 +02:00
Niko1221 c11e66aa8b tqe_table takes a type 2026-10-20 19:37:54 +02:00
Niko1221 9fcc101844 docs: DEEPSEEK41.md (where the model goes, install, the API) 2026-10-20 19:26:21 +02:00
Niko1221 a7c50df002 K5/K4 version 2: computed (LUT-free) codebook for TQK5X/TQK4X, point-table versions kept as TQK5T/TQK4T; CPU decoder tables per type; tests 2026-10-20 19:25:16 +02:00
Niko1221 a4c9336416 serve: engine_ready_s in the run config (DeepSeek-V4.1-Flash reads 300 GB at start; setup writes 3600) 2026-10-20 19:23:44 +02:00
Niko1221 2189884a59 dsv41: DS4_PROF events per device (invalid resource handle on several GPUs), exl3_legacy.cu in CUDA builds (reported by the CUDA lab) 2026-10-20 19:22:51 +02:00
Niko1221 f7c645c065 DeepSeek-V4.1-Flash in the catalog, setup and strata launch
- setup.py: the nine pinned shards of unsloth/DeepSeek-V4.1-Flash-1017-GGUF UD-Q8_K_XL (revision f924b94f, sizes and SHA-256 from the reference run; the DSpark drafter and picture reader pinned but not fetched), MULTI_FILE_FAMILIES, install_dsv41: plain warnings for the 508 GB download, RAM and disk (a refusal only where the disk cannot hold the files), the strata-dsv41 check before any download, --gguf-dir for files you already have, pack_gguf.py, config with --pack --max-context 8192 --eos 1 --tp 2 and the cards; menu entry and a --check line.
- strata_control/dsv41_fit.py: where the model goes on the cards and in RAM, from the measured run (6,240 experts in graphics memory predicted on 4 x 32 GB, 6,197 measured), the n-gram table's SSD note.
- tools/strata_mcp.py and strata_cli/launch: the model is in the list; strata launch --plan prints the placement (layer blocks per card, experts on the cards and on the CPU, RAM, disk, the SSD note); --model-path takes the nine shards where they are; parallel, pictures and tuning are said, not passed to an engine that does not take them.
- tests: tools/test_setup_dsv41.py (22), tests/terminal/test_launch_dsv41.py (13).
2026-10-20 19:22:11 +02:00
Niko1221 4455c8bd1d tqenc.py: GPU lock wait with CPU fallback 2026-10-20 19:18:57 +02:00
Niko1221 d9220bd1a5 Merge f-dsv41-cudalab (trellis GEMV lab, tqenc) into f-dsv41-port 2026-10-20 18:54:44 +02:00
Niko1221 d251b2c0db dsv41 cudalab: gfx12 WMMA port recipe of the trellis GEMV: host emulation of the data flow (matches the reference), HIP kernel source, layout probe 2026-10-20 18:45:22 +02:00
Niko1221 5a52007005 serve: DeepSeek-V4.1 through the server (deepseek-v3 pre-tokenizer, DSML tool calls, effort words, end token, card sampling)
- tools/strata_tokenizer.py: the deepseek-v3 pre-tokenizer (llama.cpp's DEEPSEEK3_LLM list as a sequence of splits) chosen by the pack's tokenizer.json 'pre'; special tokens decode to their literal text (the DSML token is not in the byte alphabet). 20 of 20 reference prompts tokenize to the ids llama.cpp made.
- serve/dsml.py: DSML calls (the GGUF template's spelling) translated as they stream into the form OutputParser reads, so tool streaming, tool_choice and calls in the thinking work as for the other families; string=true values stay strings.
- serve/server.py: Service.set_family('deepseek41'): end token from the pack, DsmlOutputParser, forced-call opening in DSML, effort words low/medium/high/max/1..100, model name and card sampling.
- serve/test_dsv41.py (39 tests, fake engine) and fixtures from the GGUF's own template and the llama.cpp reference prompts.
2026-10-20 18:45:21 +02:00
Niko1221 6f703a6dbe dsv41 cudalab: computed (LUT-free) codebooks for the 15-bit trellis (1mad, 3inst, 3sum, 3single), encoder hook TQE_CODE, LUT-free GEMV kernel, code SQNR tool, pilot scripts from f-dsv41-enc 2026-10-20 18:41:32 +02:00
Niko1221 befdd9d3e1 tqenc.py: optional per-call GPU locks (TQENC_GPU_LOCK) 2026-10-20 18:30:12 +02:00
Niko1221andmw00 2d718defc0 dsv41: pack_gguf takes unsloth's DSpark drafter GGUF (dflash) and the mmproj vision GGUF into the pack; the vision loader reads F32 rows and gate/up halves
Co-authored-by: mw00 <[email protected]>
2026-10-20 18:28:30 +02:00
Niko1221 9f78a77e01 Merge f-022-int into f-dsv41-port 2026-10-20 18:22:49 +02:00
Niko1221 01389205e9 dsv41: split-KV attention opt-in (DS4_ATTN_SPLIT=1): A/B on 4x R9700 shows no decode gain, KL unchanged 2026-10-20 18:22:02 +02:00
Niko1221 a63b2d4dae dsv41: --score segment file really carries end_line 2026-10-20 18:16:03 +02:00
Niko1221 8e13982710 build_swap: ldl damping 0.3 2026-10-20 17:58:45 +02:00
Niko1221andmw00 ff5577ad70 dsv41: split-KV sparse attention from the CUDA lab for decode / short verify windows (packed rows, T<=5), DS4_ATTN_SPLIT=0 off
Co-authored-by: mw00 <[email protected]>
2026-10-20 17:54:04 +02:00
Niko1221 d17c32d6a3 dsv41: --score writes end_line in the segment file (quant_quality.py compare reads it) 2026-10-20 17:51:12 +02:00
Niko1221 1c20a59675 dsv41 cudalab: split-KV sparse attention (2.9x at T=1), CPU-kernel cross-check of the trellis GEMV, build for the repo CPU kernels 2026-10-20 17:47:29 +02:00
Niko1221 cb725bb209 dsv41 cudalab: DeepSeek kernel parity + speed harness (mHC, router, compressor, indexer, top-k, sparse attention, Engram) over src/dsv41/ds4_kernels.cu 2026-10-20 17:39:16 +02:00
Niko1221 770292e41c dsv41 cudalab: tensor-core trellis GEMV (fp16 point table, TQL coalesced layout, prepared B fragments), K4-K7, widths 1-5 2026-10-20 17:34:48 +02:00
Niko1221 090121c31b dsv41 cudalab: trellis K4-K7 dequant + GEMV v1 on sm_86 with parity and speed harness (tqenc and the K5/K4 CPU decoder taken from f-dsv41-enc) 2026-10-20 17:20:22 +02:00
Niko1221 6b5ad4f27d trellis: proxy_ldl 2026-10-20 17:11:51 +02:00
Niko1221 c3e9a998ad dsv41: tools to turn the llama.cpp reference's 10 greedy prompts into ids and compare the engine's greedy output (serve protocol) 2026-10-20 16:50:46 +02:00
Niko1221andmw00 a0dbad8078 dsv41: pack_gguf uses the GGUF's own chat template (fixes Maya #299), drop Maya's template and the EXL3 packer
Maya's tools/ds4/chat_template.jinja lost tool associations and later system instructions; the GGUF's embedded
template matched DeepSeek's encoding.py byte for byte on 9 cases (notes-dsv41-ref.md).

Co-authored-by: mw00 <[email protected]>
2026-10-20 16:36:35 +02:00
453cc0d56f dsv41: build the imported DeepSeek-V4.1 engine path in Strata (HIP gfx1201), add --score
CMake option STRATA_BUILD_DSV41 (default OFF, adds the target strata-dsv41 and a private static kernel lib; no
existing target changes). The vendored exl3 / sampler / window-grouping kernels live in namespace strata::dsv41k so
no symbol is shared with strata_kernels. Missing CUDA names for HIP come from a force-included private header
(include/strata/dsv41/hip_extra.h), the IQ CPU lane stays the stub branch, MAYA_* env aliases are gone.
New --score IDS.json --score-out RUN.tsv: teacher-forced top-64 log-probabilities in tools/quant_quality.py's
row format (the gate against the llama.cpp reference). MIT notice added to every ported file.

Co-authored-by: mw00 <[email protected]>
Co-authored-by: needmorevram <[email protected]>
2026-10-20 16:34:12 +02:00
Niko1221 b73bd26a5c trellis: run_parts (engine runs that survive a failed segment) 2026-10-20 16:26:00 +02:00
mw00andneedmorevram bbd0a2c673 dsv41: import Project Maya's DeepSeek-V4.1 engine unmodified (v1.1.17, 8d9a9b5d)
Verbatim copy of mw00/project-maya src/ds4, tests/ds4_*, tools/ds4 and the exl3 / sampler
kernel sources it needs, MIT, with their LICENSE and a NOTICE. Not built yet; the next
commits adapt it to Strata's tree.

Co-authored-by: needmorevram <[email protected]>
2026-10-20 16:19:45 +02:00
Niko1221 a7eb99bba9 trellis: block-feedback (BlockLDLQ) encoder and the ldl mode of build_swap 2026-10-20 15:59:36 +02:00
Niko1221 431803abb9 STRATA_ACT_DUMP: rarer flush with the covariance on; merge_dumps keeps it 2026-10-20 15:57:45 +02:00
Niko1221 adf7ca9729 trellis: swap comparison and greedy sanity scripts 2026-10-20 15:34:41 +02:00
Niko1221 0b4e603cfd STRATA_ACT_DUMP: keep the accumulator alive for the exit flush 2026-10-20 14:36:38 +02:00
Niko1221 2ec3d2e381 trellis: corpus builder, original-weight fetch, swap builder for Gyro-S layer variants 2026-10-20 14:36:38 +02:00
Niko1221 a775b04695 native experts: STRATA_ACT_DUMP, per-layer second moments of the CPU expert inputs (research, env-gated, off by default) 2026-10-20 14:36:38 +02:00
Niko1221 6f99190f82 trellis: README, column-profile check of the Gyro error 2026-10-20 14:36:38 +02:00
Niko1221 4292678b8f trellis: GPU path in tqenc.py and the pilot, diagonal activation second moments (importance-weighted encode), error profile script 2026-10-20 14:36:38 +02:00
Niko1221 68a8cad3ed trellis HIP: register pressure tweaks, CU mode 2026-10-20 14:36:38 +02:00
Niko1221 c23936be78 trellis: HIP encoder (gfx1201), bit-identical to the CPU encoder; host helpers; pilot driver reads remote GGUF shards 2026-10-20 14:36:38 +02:00
Niko1221 7e9531b3da trellis: python wrapper, Hadamard helpers, .tqx container, Gyro re-encode and original-weight comparison scripts, pilot driver 2026-10-20 14:36:38 +02:00
Niko1221 512e298e6e trellis encoder: tail-biting Viterbi for TQ2_T/TQK6/TQK7 plus experimental TQK5X/TQK4X (CPU decoder reads them)
tools/trellis/tqenc.{hpp,cpp}: shift-trellis Viterbi (group-min ACS), tail-biting by a rotated free pass or the exact
search over all start groups, fp16 scale refinement, importance weights.  tqenc_test.cpp checks the streams, the
Viterbi against a naive DP, planted solutions, bit-exact decode against tq_states.hpp and the CPU kernels.
The CPU decoder gets K = 5 and 4 (ids 224, 225, experimental v1); existing types and layouts are unchanged.
2026-10-20 14:36:38 +02:00
473 changed files with 68282 additions and 823 deletions

No files matched your search

+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.2 · engine 0.1.47"
placeholder: "Strata 0.2.3 · engine 0.1.48"
validations:
required: true
- type: input
+1 -1
View File
@@ -13,7 +13,7 @@ on:
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag whose engine packages go into the images (for example v0.2.2)'
description: 'Release tag whose engine packages go into the images (for example v0.2.3)'
required: true
type: string
flavors:
+51
View File
@@ -0,0 +1,51 @@
name: Metal build
# Compile and link the Metal engine on a macOS runner (no GPU needed: the kernels are compiled at run time). The shared host files
# (src/core/verify.cpp, src/prefill, src/program/generate.cpp) call kernel entry points that exist only for CUDA / HIP until Metal gets a
# stub or an implementation; a missing one is a link error here, not on a user's Mac (owner round 9, G6).
on:
pull_request:
paths:
- 'src/**'
- 'include/**'
- 'metal/**'
- 'third_party/**'
- 'CMakeLists.txt'
- '.github/workflows/metal-build.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
metal-link:
runs-on: macos-14
timeout-minutes: 45
env:
# setup.py's LLAMA_CPP_COMMIT and metal/setup_mac.py's metal-cpp pin
LLAMA_CPP_COMMIT: 3cf03257f219afbe7334045ff7c6a06ac68c627d
METAL_CPP_URL: https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip
METAL_CPP_SHA256: 4df3c078b9aadcb516212e9cb03004cbc5ce9a3e9c068fa3144d021db585a3a4
steps:
- uses: actions/checkout@v4
- name: Tools
run: brew install ninja
- name: metal-cpp
run: |
curl -fsSL "$METAL_CPP_URL" -o metal-cpp.zip
echo "$METAL_CPP_SHA256 metal-cpp.zip" | shasum -a 256 -c -
unzip -q metal-cpp.zip -d _mcpp
- name: llama.cpp (ggml) at the pinned commit
run: |
mkdir llama.cpp && cd llama.cpp
git init -q
git remote add origin https://github.com/ggml-org/llama.cpp.git
git fetch -q --depth 1 origin "$LLAMA_CPP_COMMIT"
git checkout -q FETCH_HEAD
- name: Configure
run: |
cmake -S metal -B build-metal -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DMETAL_CPP_DIR="$PWD/_mcpp/metal-cpp" -DSTRATA_GGML_DIR="$PWD/llama.cpp"
- name: Build and link the engine
run: cmake --build build-metal --target strata
+1 -1
View File
@@ -2,7 +2,7 @@
Strata runs the Qwen3.8-Flash-Next mixture-of-experts model (and its Coder, Swift 1.5, Unsloth, Gyro and NVFP4
variants, and Qwen3.6-35B-A3B) on a normal PC: one NVIDIA or AMD graphics card plus system RAM on Windows or Linux,
or an Apple Silicon Mac. Version 0.2.2, engine 0.1.47. It has three parts:
or an Apple Silicon Mac. Version 0.2.3, engine 0.1.48. It has three parts:
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
not want a terminal. The installer has the engine inside.
+150 -5
View File
@@ -1,4 +1,4 @@
# Strata Engine - top-level build (P1.S1).
# Strata Engine - top-level build (P1.S1).
#
# Targets (phase-1 §P1.S1): `strata_artifact` (no CUDA dependency), `tests`. `strata_plan` and the C++
# `strata-pack` are added when their sources exist - a target with no sources would make the build graph
@@ -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.47 LANGUAGES CXX)
project(strata VERSION 0.1.49 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)
@@ -336,6 +336,8 @@ 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
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
target_compile_definitions(strata_core PUBLIC STRATA_HAVE_DFLASH2=1)
if(STRATA_ENABLE_CUDA AND STRATA_EXPERIMENTAL_SM60)
# the runtime device check (src/core/device.cu) admits the cards this build was made for (#236)
target_compile_definitions(strata_core PRIVATE STRATA_EXPERIMENTAL_SM60=1)
@@ -418,6 +420,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
src/kernels/cuda/dequant_bf16.cu
src/kernels/cuda/qsa_select.cu
src/kernels/cuda/verify_kernels.cu
src/kernels/cuda/dflash2.cu
src/kernels/cuda/iq_kernels.cu
src/kernels/ngram.cpp
src/ngram/ple_reader.cpp
@@ -579,7 +582,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
# about arithmetic, and this one knows the ORDER the arithmetic happens in.
add_library(strata_engine STATIC src/core/layer.cpp src/core/session.cpp src/core/expert_source.cpp src/core/remote_experts.cpp
src/core/remote_expert_opt.cu
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp src/core/dflash2.cpp
src/core/conversation_snapshot.cpp src/core/conversation_state.cpp src/core/conversation_memory.cpp src/core/peer_experts.cpp
src/core/arch_defaults.cpp src/core/nv_defaults.cpp src/core/vmm.cpp src/core/conversation_file.cpp)
target_include_directories(strata_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
@@ -732,6 +735,27 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(tune_rocblas PRIVATE hip::host roc::rocblas)
endif()
# DFlash2 drafter tools (not built by default: cmake --build <dir> --target dflash2_parity dflash2_gemm_bench dflash2_attn_bench): the drafter against
# the torch port on a synthetic case (needs the pack and a case file, tools/dflash2/mk_parity_case.py), and the timing of its Q8 GEMM and attention
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_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})
add_executable(dflash2_launch_bench EXCLUDE_FROM_ALL src/core/dflash2_launch_bench.cu)
target_include_directories(dflash2_launch_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_launch_bench PRIVATE ${_strata_gpu_runtime_target})
add_executable(dflash2_bw_bench EXCLUDE_FROM_ALL src/core/dflash2_bw_bench.cu)
target_include_directories(dflash2_bw_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_bw_bench PRIVATE ${_strata_gpu_runtime_target})
add_executable(dflash2_small_bench EXCLUDE_FROM_ALL src/core/dflash2_small_bench.cpp)
target_include_directories(dflash2_small_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_small_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_attn_bench EXCLUDE_FROM_ALL src/core/dflash2_attn_bench.cpp)
target_include_directories(dflash2_attn_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_attn_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(strata src/program/generate.cpp)
strata_utf8_manifest(strata)
target_include_directories(strata PRIVATE ${_strata_gpu_include_directories})
@@ -770,6 +794,108 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_compile_definitions(strata PRIVATE STRATA_HAVE_QWEN35=1)
endif()
# DeepSeek-V4.1-Flash: the third engine path (src/dsv41, from Project Maya's maya-ds4, MIT, third_party/project-maya).
# Its own executable for now (strata-dsv41); a family switch in `strata` comes after first light. OFF by default: it
# changes no existing target (the vendored kernels live in strata::dsv41k, so no symbol is shared with strata_kernels).
option(STRATA_BUILD_DSV41 "Build the DeepSeek-V4.1 engine path (strata-dsv41)" OFF)
if(STRATA_BUILD_DSV41 AND NOT STRATA_HIP_GFX906)
add_library(strata_dsv41_kernels STATIC src/dsv41/vendor/exl3.cu src/dsv41/vendor/sampler.cu src/dsv41/vendor/moe_win.cu
src/dsv41/vendor/exl3_cpu.cpp)
if(NOT STRATA_ENABLE_HIP) # (the pre-sm_80 NVIDIA decode kernels exl3.cu dispatches to)
target_sources(strata_dsv41_kernels PRIVATE src/dsv41/vendor/exl3_legacy.cu)
endif()
target_include_directories(strata_dsv41_kernels PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories})
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)
# 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)
endif()
if(NOT STRATA_BUILD_ID)
execute_process(COMMAND git -C ${CMAKE_CURRENT_SOURCE_DIR} rev-parse --short=9 HEAD OUTPUT_VARIABLE STRATA_BUILD_ID
OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
endif()
if(NOT STRATA_BUILD_ID)
set(STRATA_BUILD_ID unknown)
endif()
set_source_files_properties(src/dsv41/ds4_main.cpp PROPERTIES COMPILE_DEFINITIONS "DS4_BUILD_ID=\"${STRATA_BUILD_ID}\"")
target_include_directories(strata-dsv41 PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
target_link_libraries(strata-dsv41 PRIVATE strata_dsv41_kernels ${_strata_gpu_runtime_target} ${_strata_gpu_blas_target})
find_package(Threads REQUIRED)
target_link_libraries(strata-dsv41 PRIVATE Threads::Threads)
if(WIN32 AND STRATA_ENABLE_HIP) # cudaMemGetInfo is the DXGI-budgeted hip_compat::mem_get_info (src/core/device.cu): on a Strix Halo the
# carve-out is not all the GPU may use, and the engine sizes its expert tiers by what the budget says is free
target_link_libraries(strata-dsv41 PRIVATE strata_core)
endif()
if(STRATA_NATIVE_EXPERTS) # Strata's CPU trellis kernels (tq_cpu.hpp) for the trellis experts on the CPU lane
target_link_libraries(strata-dsv41 PRIVATE strata_kernels_cpu)
target_compile_definitions(strata-dsv41 PRIVATE STRATA_DSV41_TQ_CPU=1)
endif()
if(STRATA_NATIVE_EXPERTS)
# the CPU lane's ggml-type rows: ggml-cpu's dots against Strata's AVX2 i-quant rows (bits and GB/s); no GPU, no model
add_executable(ds4_gg_bench tests/dsv41/gg_dot_bench.cpp src/dsv41/ds4_gg_cpu.cpp)
target_include_directories(ds4_gg_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41
${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)
endif()
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
if(STRATA_ENABLE_HIP)
set(_ds4_test_lang HIP)
elseif(STRATA_ENABLE_CUDA)
set(_ds4_test_lang CUDA)
else()
set(_ds4_test_lang CXX)
endif()
set_source_files_properties(tests/dsv41/tq_expert_test.cpp PROPERTIES LANGUAGE ${_ds4_test_lang})
add_executable(ds4_tq_test tests/dsv41/tq_expert_test.cpp src/dsv41/ds4_tq.cu src/dsv41/ds4_iq.cu src/dsv41/ds4_tq_cpu.cpp
src/dsv41/ds4_iq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp tools/trellis/tqenc.cpp)
target_include_directories(ds4_tq_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/cpu)
target_link_libraries(ds4_tq_test PRIVATE strata_kernels_cpu ${_strata_gpu_runtime_target} Threads::Threads)
target_compile_definitions(ds4_tq_test PRIVATE STRATA_DSV41_TQ_CPU=1)
# the same with the plain-FMA tile instead of the gfx12 WMMA one (what CUDA and gfx11 builds run)
add_executable(ds4_tq_test_portable tests/dsv41/tq_expert_test.cpp src/dsv41/ds4_tq.cu src/dsv41/ds4_iq.cu src/dsv41/ds4_tq_cpu.cpp
src/dsv41/ds4_iq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp tools/trellis/tqenc.cpp)
target_include_directories(ds4_tq_test_portable PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/cpu)
target_link_libraries(ds4_tq_test_portable PRIVATE strata_kernels_cpu ${_strata_gpu_runtime_target} Threads::Threads)
target_compile_definitions(ds4_tq_test_portable PRIVATE STRATA_DSV41_TQ_CPU=1 DS4_TQ_PORTABLE=1)
if(STRATA_ENABLE_HIP)
target_compile_options(ds4_tq_test_portable PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
endif()
if(STRATA_ENABLE_HIP)
target_compile_options(ds4_tq_test PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
endif()
# the i-quant experts (ds4_iq.cu) against ggml's own decode of the same blocks + a double reference, device and CPU
# lane, for every type the device takes: the gate a new type (IQ2_XS ..) has to pass before the engine uses it
set_source_files_properties(tests/dsv41/iq_expert_test.cpp PROPERTIES LANGUAGE ${_ds4_test_lang})
add_executable(ds4_iq_test tests/dsv41/iq_expert_test.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_iq_cpu.cpp
src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp)
target_include_directories(ds4_iq_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/cpu)
target_link_libraries(ds4_iq_test PRIVATE strata_kernels_cpu ${_strata_gpu_runtime_target} Threads::Threads)
target_compile_definitions(ds4_iq_test PRIVATE STRATA_DSV41_TQ_CPU=1)
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 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)
endif()
if(STRATA_ENABLE_HIP)
foreach(_t strata_dsv41_kernels strata-dsv41)
target_compile_options(${_t} PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
endforeach()
endif()
endif()
if(STRATA_ENABLE_HIP)
enable_testing()
set(_strata_hip_tests)
@@ -1081,7 +1207,6 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(verify_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME verify_parity COMMAND verify_parity --selftest)
endif()
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})
@@ -1453,6 +1578,10 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(spec_prob_test src/core/spec_prob_test.cpp)
target_include_directories(spec_prob_test PRIVATE include)
add_test(NAME spec_prob_test COMMAND spec_prob_test)
# the per-card window cap of --draft dflash2 (header-only, CPU)
add_executable(draft_cap_test src/core/draft_cap_test.cpp)
target_include_directories(draft_cap_test PRIVATE include)
add_test(NAME draft_cap_test COMMAND draft_cap_test)
# the f32 -> bf16 rounding against ggml's, NaN included, over every f32 bit pattern (header-only, CPU)
add_executable(bf16_bits_test src/kernels/bf16_bits_test.cpp)
target_include_directories(bf16_bits_test PRIVATE include)
@@ -1596,7 +1725,7 @@ if(STRATA_NATIVE_EXPERTS)
set_source_files_properties(src/kernels/cpu/iq_avx1.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX")
else()
set_source_files_properties(src/kernels/cpu/iq_avx512.cpp src/kernels/cpu/nvfp4_avx512.cpp PROPERTIES COMPILE_OPTIONS
"-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mf16c;-mfma")
"-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mavx512vpopcntdq;-mbmi2;-mf16c;-mfma")
set_source_files_properties(src/kernels/cpu/iq_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
set_source_files_properties(src/kernels/cpu/kq_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
set_source_files_properties(src/kernels/cpu/nvfp4_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
@@ -2238,3 +2367,19 @@ if(STRATA_HIP_GFX906)
add_executable(gfx906_qsa_query_swizzle_probe bench/gfx906_qsa_query_swizzle_probe.cpp)
target_link_libraries(gfx906_qsa_query_swizzle_probe PRIVATE strata_kernels ${_strata_gpu_runtime_target})
endif()
# ---- DeepSeek-V4.1 on a box with no GPU toolkit ------------------------------------------------
# The whole DSV41 block above lives inside if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP), so with neither backend on
# -DSTRATA_BUILD_DSV41=ON was silently unused and the host-side benchmark could not be built at all (found on p1004,
# 2026-10-23: the CPU lane is exactly what a box without a usable GPU needs to measure). This branch builds the one target
# that has no device code, so the CPU-lane A/B runs anywhere.
if(STRATA_BUILD_DSV41 AND NOT STRATA_ENABLE_CUDA AND NOT STRATA_ENABLE_HIP AND NOT STRATA_HIP_GFX906)
message(STATUS "STRATA_BUILD_DSV41: no GPU backend, building the host-side benchmark only (ds4_gg_bench)")
if(STRATA_NATIVE_EXPERTS)
add_executable(ds4_gg_bench tests/dsv41/gg_dot_bench.cpp src/dsv41/ds4_gg_cpu.cpp)
target_include_directories(ds4_gg_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41
${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)
endif()
endif()
+2 -1
View File
@@ -56,7 +56,8 @@ RUN set -e; \
ver="${ENGINE_VERSION:-$(sed -n 's/^[[:space:]]*project([^)]*VERSION[[:space:]]*\([0-9][0-9.]*\).*/\1/p' CMakeLists.txt | head -1)}"; \
cudafull=$(nvcc --version | sed -n 's/.*release \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1); cudamajor=${cudafull%%.*}; \
if [ "$cudamajor" = 13 ]; then mindriver=580.65; else mindriver=575.51; fi; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true}" > /stage/BUILD.json; \
vision_archs=""; [ -f /stage/strata-vision ] && vision_archs=", \"vision_archs\": \"$CUDA_ARCHITECTURES\""; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true${vision_archs}}" > /stage/BUILD.json; \
python3 packaging/collect_libs.py --binary build/strata --search /usr/local/cuda/lib64 --out /stage --also 'libcublasLt.so*'; \
archs=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do archs="$archs --arch sm_$a"; done; \
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
+26 -11
View File
@@ -33,7 +33,7 @@ Bilder und arbeitet mit deinen Apps und Coding-Agents zusammen. Deine Chats und
- **Kostenlos.** MIT-Lizenz, kein Konto, keine Cloud.
- **Privat.** Keine Telemetrie. Was Strata herunterlädt, wann es das Internet nutzt und wie es Downloads prüft, steht in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.2 · Engine 0.1.47
Version 0.2.3 · Engine 0.1.48
## Was du brauchst
@@ -73,16 +73,16 @@ unsicher, welche du brauchst? Lies den Absatz unter der Tabelle.
| System | Deine Grafikkarte | Datei zum Herunterladen |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Wenn du unsicher bist:** Bei NVIDIA nimmst du `cuda13`, wenn dein Treiber Version 580 oder neuer ist, sonst `cuda12`.
`cuda12` ist für ältere Karten und ältere Treiber. Die Dateien `hip` und `amd` sind für AMD-Karten. Für andere AMD-Karten
@@ -190,6 +190,21 @@ Weitere Auswahl:
Alle Größen, Downloads und was wo läuft: [docs/MODELS.md](docs/MODELS.md).
### DeepSeek V4.1-Flash (im Terminal, ab 0.2.4)
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) ist ein anderes, deutlich größeres Modell. ISTA-DASLab hat die drei Sets unten gebaut. Strata startet sie auf einem PC mit einer 12-GB-Grafikkarte und 46 bis 64 GB RAM und liest den Rest von der SSD. Es läuft auf NVIDIA-RTX-Karten der Serien 30 bis 50 und auf der Radeon AI PRO R9700. Es nutzt ein eigenes Engine-Paket, `strata-dsv41`, das `strata launch` lädt, wenn du DeepSeek wählst. Die Desktop-App bietet es noch nicht an.
| Set | Download | Qualität im Vergleich zum Originalmodell | Antwortet mit |
| --- | ---: | --- | --- |
| **IQ1_M** (1,75 Bit) | 239 GB | KL 0,217, Perplexität 3,80 (+21 %); Code +8,9 %, Agent-Aufgaben +10,2 %; schwächer bei einfacher englischer Prosa. Laut Modellkarte von ISTA-DASLab behält es 98,7 % des Aufgabendurchschnitts des Basismodells (AIME25, GPQA-Diamond, LiveCodeBench v6). | noch nicht gemessen |
| **IQ2_XXS** (2.00 Bit) | 257 GB | KL 0,170, Perplexität 3,63 (+15,5 %); Code +8,6 %, Agent-Aufgaben +5,3 % | 6 bis 9 Tokens/s auf einer RTX 3060, etwa 5 auf einer RTX 5070 |
| **IQ2_XS** (2.25 Bit) | 274 GB | KL 0,107, Perplexität 3,37 (+7,3 %); Code +3,2 %, Agent-Aufgaben +2,2 % | etwa 11 Tokens/s auf einer Radeon AI PRO R9700 |
| **IQ4_NL** (Engram-Tabelle, kein drittes Modell) | 111 GB, schon in allen drei Downloads oben enthalten | Die 4-Bit-Engram-Nachschlagetabelle, die das Modell aus RAM oder SSD liest. Alle drei Sets nutzen diese eine Datei (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
4x Radeon AI PRO R9700: etwa 31 Tokens/s, 55 bis 69 mit DSpark.
Starte es mit `strata launch --model ist-das-2.00bpw` (oder `ist-das-1.75bpw`, `ist-das-2.25bpw`). Details, Geschwindigkeiten je Karte und die manuelle Installation: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
## Wie schnell ist es?
Gemessen mit einer Grafikkarte mit 12 GB (RTX 5070, Ryzen 5 7600, 64 GB RAM). Ergebnisse anderer Karten findest du hier:
+26 -11
View File
@@ -33,7 +33,7 @@ con tus aplicaciones y tus agentes de programación. Tus chats y tus archivos se
- **Gratis.** Licencia MIT, sin cuenta, sin nube.
- **Privado.** Sin telemetría. Lo que Strata descarga, cuándo usa internet y cómo comprueba las descargas está en [docs/SAFETY.md](docs/SAFETY.md).
Versión 0.2.2 · motor 0.1.47
Versión 0.2.3 · motor 0.1.48
## Lo que necesitas
@@ -73,16 +73,16 @@ sabes cuál elegir? Mira el párrafo que hay debajo de la tabla.
| Sistema | Tu tarjeta gráfica | Archivo que descargar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Si no estás seguro:** con NVIDIA, elige `cuda13` si la versión de tu controlador es la 580 o posterior, y `cuda12` en caso
contrario. `cuda12` es para tarjetas y controladores más antiguos. Los archivos `hip` y `amd` son para tarjetas AMD. Las
@@ -190,6 +190,21 @@ Más opciones:
Todos los tamaños, descargas y lo que cabe en cada equipo: [docs/MODELS.md](docs/MODELS.md).
### DeepSeek V4.1-Flash (en el Terminal, desde 0.2.4)
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) es otro modelo, mucho más grande. ISTA-DASLab hizo los tres conjuntos de abajo. Strata los ejecuta en un PC con una tarjeta gráfica de 12 GB y de 46 a 64 GB de RAM, y lee el resto de tu SSD. Funciona en tarjetas NVIDIA RTX de las series 30 a 50 y en la Radeon AI PRO R9700. Usa su propio paquete de motor, `strata-dsv41`, que `strata launch` descarga cuando eliges DeepSeek. La app de escritorio todavía no lo ofrece.
| Conjunto | Descarga | Calidad frente al modelo original | Responde a |
| --- | ---: | --- | --- |
| **IQ1_M** (1,75 bits) | 239 GB | KL 0,217, perplejidad 3,80 (+21 %); código +8,9 %, tareas de agente +10,2 %; más débil con prosa inglesa corriente. Según la ficha del modelo de ISTA-DASLab, conserva el 98,7 % del promedio de tareas del modelo base (AIME25, GPQA-Diamond, LiveCodeBench v6). | aún sin medir |
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0,170, perplejidad 3,63 (+15,5 %); código +8,6 %, tareas de agente +5,3 % | de 6 a 9 tokens/s en una RTX 3060, unos 5 en una RTX 5070 |
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0,107, perplejidad 3,37 (+7,3 %); código +3,2 %, tareas de agente +2,2 % | unos 11 tokens/s en una Radeon AI PRO R9700 |
| **IQ4_NL** (tabla Engram, no es un tercer modelo) | 111 GB, ya incluidos en las tres descargas de arriba | La tabla de consulta Engram de 4 bits que el modelo lee de la RAM o del SSD. Los tres conjuntos usan este único archivo (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
4x Radeon AI PRO R9700: unos 31 tokens/s, de 55 a 69 con DSpark.
Inícialo con `strata launch --model ist-das-2.00bpw` (o `ist-das-1.75bpw`, `ist-das-2.25bpw`). Detalles, velocidades por tarjeta e instalación manual: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
## ¿Qué velocidad tiene?
Medido con una tarjeta gráfica de 12 GB (RTX 5070, Ryzen 5 7600, 64 GB de RAM). Resultados con otras tarjetas:
+26 -11
View File
@@ -33,7 +33,7 @@ fonctionne avec vos applications et vos agents de code. Vos conversations et vos
- **Gratuit.** Licence MIT, pas de compte, pas de cloud.
- **Privé.** Aucune télémétrie. Ce que Strata télécharge, quand il utilise internet et comment il vérifie les téléchargements est expliqué dans [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.2 · moteur 0.1.47
Version 0.2.3 · moteur 0.1.48
## Ce qu'il vous faut
@@ -73,16 +73,16 @@ Vous ne savez pas lequel prendre ? Lisez le paragraphe sous le tableau.
| Système | Votre carte graphique | Fichier à télécharger |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Si vous hésitez :** pour NVIDIA, prenez `cuda13` si la version de votre pilote est 580 ou plus récente, sinon `cuda12`.
`cuda12` convient aux cartes plus anciennes et aux pilotes plus anciens. Les fichiers `hip` et `amd` sont pour les cartes
@@ -191,6 +191,21 @@ Autres choix :
Toutes les tailles, les téléchargements et ce qui tient où : [docs/MODELS.md](docs/MODELS.md).
### DeepSeek V4.1-Flash (dans le Terminal, depuis 0.2.4)
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) est un autre modèle, beaucoup plus gros. ISTA-DASLab a produit les trois versions ci-dessous. Strata les fait tourner sur un PC avec une carte graphique de 12 Go et de 46 à 64 Go de RAM, et lit le reste sur votre SSD. Il fonctionne sur les cartes NVIDIA RTX des séries 30 à 50 et sur la Radeon AI PRO R9700. Il utilise son propre paquet de moteur, `strata-dsv41`, que `strata launch` télécharge quand vous choisissez DeepSeek. L’application de bureau ne le propose pas encore.
| Version | Téléchargement | Qualité par rapport au modèle d’origine | Écrit à |
| --- | ---: | --- | --- |
| **IQ1_M** (1,75 bit) | 239 Go | KL 0,217, perplexité 3,80 (+21 %) ; code +8,9 %, tâches d’agent +10,2 % ; plus faible sur la prose anglaise courante. D’après la fiche du modèle d’ISTA-DASLab, elle garde 98,7 % de la moyenne des tâches du modèle de base (AIME25, GPQA-Diamond, LiveCodeBench v6). | pas encore mesurée |
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0,170, perplexité 3,63 (+15,5 %) ; code +8,6 %, tâches d’agent +5,3 % | 6 à 9 tokens/s sur une RTX 3060, environ 5 sur une RTX 5070 |
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0,107, perplexité 3,37 (+7,3 %) ; code +3,2 %, tâches d’agent +2,2 % | environ 11 tokens/s sur une Radeon AI PRO R9700 |
| **IQ4_NL** (table Engram, pas un troisième modèle) | 111 Go, déjà compris dans les trois téléchargements ci-dessus | La table de consultation Engram en 4 bits que le modèle lit dans la RAM ou sur le SSD. Les trois versions utilisent ce seul fichier (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
4x Radeon AI PRO R9700 : environ 31 tokens/s, 55 à 69 avec DSpark.
Lancez-le avec `strata launch --model ist-das-2.00bpw` (ou `ist-das-1.75bpw`, `ist-das-2.25bpw`). Détails, vitesses par carte et installation manuelle : [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
## Quelle est sa vitesse ?
Mesuré avec une carte graphique de 12 Go (RTX 5070, Ryzen 5 7600, 64 Go de RAM). Voir les résultats d'autres cartes :
+26 -11
View File
@@ -33,7 +33,7 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
- **無料です。** MIT ライセンス。アカウントもクラウドも不要です。
- **プライバシーを守ります。** テレメトリはありません。Strata が何をダウンロードするか、いつインターネットを使うか、ダウンロードをどう検証するかは [docs/SAFETY.md](docs/SAFETY.md) に書いてあります。
バージョン 0.2.2 · エンジン 0.1.47
バージョン 0.2.3 · エンジン 0.1.48
## 必要な環境
@@ -72,16 +72,16 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
| OS | グラフィックスカード | ダウンロードするファイル |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**迷ったときは:** NVIDIA の場合、ドライバーがバージョン 580 以降なら `cuda13`、それより古ければ `cuda12` を選びます。`cuda12` は古いカードと古いドライバー向けです。
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000)には、まだ用意されたアプリがありません。
@@ -183,6 +183,21 @@ Gyro は NVIDIA と AMD のカード(AMD は実験的)、および Linux 上
すべてのサイズ、ダウンロード、どの構成で何が動くかは [docs/MODELS.md](docs/MODELS.md) にあります。
### DeepSeek V4.1-Flash(ターミナル版、0.2.4 から)
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) は、これとは別のずっと大きなモデルです。下の 3 つのセットは ISTA-DASLab が作りました。Strata は、12 GB のグラフィックスカード 1 枚と 46 から 64 GB の RAM を持つ PC でこれを動かし、残りは SSD から読みます。NVIDIA RTX 30 から 50 シリーズのカードと Radeon AI PRO R9700 で動きます。専用のエンジンパッケージ `strata-dsv41` を使い、DeepSeek を選ぶと `strata launch` がダウンロードします。デスクトップアプリはまだ対応していません。
| セット | ダウンロード | 元のモデルとの品質差 | 出力速度 |
| --- | ---: | --- | --- |
| **IQ1_M**(1.75 ビット) | 239 GB | KL 0.217、パープレキシティ 3.80(+21%)、コード +8.9%、エージェント作業 +10.2%、通常の英語の散文では弱めです。ISTA-DASLab のモデルカードによると、基本モデルのタスク平均の 98.7% を保ちます(AIME25、GPQA-Diamond、LiveCodeBench v6)。 | 未測定 |
| **IQ2_XXS** (2.00 ビット) | 257 GB | KL 0.170、パープレキシティ 3.63(+15.5%)、コード +8.6%、エージェント作業 +5.3% | RTX 3060 で毎秒 6 から 9 トークン、RTX 5070 で約 5 |
| **IQ2_XS** (2.25 ビット) | 274 GB | KL 0.107、パープレキシティ 3.37(+7.3%)、コード +3.2%、エージェント作業 +2.2% | Radeon AI PRO R9700 1 枚で毎秒約 11 トークン |
| **IQ4_NL**(Engram テーブル、3 つ目のモデルではありません) | 111 GB、上の 3 つのダウンロードに含まれています | モデルが RAM または SSD から読む 4 ビットの Engram 参照テーブルです。3 つのセットはこの 1 つのファイル(`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`)を共有します。 | 該当なし |
Radeon AI PRO R9700 4 枚:毎秒約 31 トークン、DSpark ありで 55 から 69。
`strata launch --model ist-das-2.00bpw`(または `ist-das-1.75bpw`、`ist-das-2.25bpw`)で起動します。詳細、カードごとの速度、手動インストールは [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md) にあります。
## どのくらい速い?
12 GBのグラフィックスカード(RTX 5070、Ryzen 5 7600、RAM 64 GB)での数値です。ほかのカードの結果は
+26 -11
View File
@@ -33,7 +33,7 @@ agents. Your chats and files stay on your PC.
- **Free.** MIT license, no account, no cloud.
- **Private.** No telemetry. What Strata downloads, when it uses the internet and how it checks downloads is in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.2 · engine 0.1.47
Version 0.2.3 · engine 0.1.48
## What you need
@@ -73,16 +73,16 @@ line under the table.
| System | Your graphics card | File to download |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**If you are unsure:** for NVIDIA, take `cuda13` if your driver is version 580 or newer, otherwise `cuda12`. `cuda12` is
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. RX 6000 cards have no installer of
@@ -188,6 +188,21 @@ More choices:
Every size, download and what fits where: [docs/MODELS.md](docs/MODELS.md).
### DeepSeek V4.1-Flash (in the Terminal, from 0.2.4)
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) is a different, much bigger model. ISTA-DASLab made the three sets below. Strata runs them on a PC with one 12 GB graphics card and 46 to 64 GB of RAM, and reads the rest from your SSD. It runs on NVIDIA RTX 30 to 50 cards and on the Radeon AI PRO R9700. It uses its own engine package, `strata-dsv41`, which `strata launch` downloads when you pick DeepSeek. The desktop app does not offer it yet.
| Set | Download | Quality against the original model | Writes at |
| --- | ---: | --- | --- |
| **IQ1_M** (1.75 bits) | 239 GB | KL 0.217, perplexity 3.80 (+21%); code +8.9%, agent work +10.2%; weaker on plain English prose. ISTA-DASLab's model card: keeps 98.7% of the base model's task average (AIME25, GPQA-Diamond, LiveCodeBench v6). | not measured yet |
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0.170, perplexity 3.63 (+15.5%); code +8.6%, agent work +5.3% | 6 to 9 tokens/s on an RTX 3060, about 5 on an RTX 5070 |
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0.107, perplexity 3.37 (+7.3%); code +3.2%, agent work +2.2% | about 11 tokens/s on one Radeon AI PRO R9700 |
| **IQ4_NL** (Engram table, not a third model) | 111 GB, already in all three downloads above | The 4-bit Engram lookup table the model reads from RAM or SSD. All three sets use this one file (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
4x Radeon AI PRO R9700: about 31 tokens/s, 55 to 69 with DSpark.
Start it with `strata launch --model ist-das-2.00bpw` (or `ist-das-1.75bpw`, `ist-das-2.25bpw`). Details, speeds per card and the manual install: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
## How fast is it?
Based on a 12 GB graphics card (RTX 5070, Ryzen 5 7600, 64 GB RAM). See results from other cards:
+26 -11
View File
@@ -33,7 +33,7 @@ agentes de programação. Suas conversas e arquivos ficam no seu PC.
- **Gratuito.** Licença MIT, sem conta, sem nuvem.
- **Privado.** Sem telemetria. O que o Strata baixa, quando usa a internet e como confere os downloads está em [docs/SAFETY.md](docs/SAFETY.md).
Versão 0.2.2 · mecanismo 0.1.47
Versão 0.2.3 · mecanismo 0.1.48
## O que você precisa
@@ -73,16 +73,16 @@ logo abaixo da tabela.
| Sistema | Sua placa de vídeo | Arquivo para baixar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Se você estiver em dúvida:** para NVIDIA, use `cuda13` se o seu driver for a versão 580 ou mais nova, e `cuda12` nos outros casos. O `cuda12`
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
@@ -183,6 +183,21 @@ Mais opções:
Todos os tamanhos, downloads e o que cabe em cada configuração: [docs/MODELS.md](docs/MODELS.md).
### DeepSeek V4.1-Flash (no Terminal, a partir da 0.2.4)
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) é outro modelo, bem maior. A ISTA-DASLab fez os três conjuntos abaixo. O Strata os executa em um PC com uma placa de vídeo de 12 GB e de 46 a 64 GB de RAM, e lê o resto do seu SSD. Ele roda em placas NVIDIA RTX das séries 30 a 50 e na Radeon AI PRO R9700. Usa um pacote de motor próprio, `strata-dsv41`, que o `strata launch` baixa quando você escolhe o DeepSeek. O app de desktop ainda não o oferece.
| Conjunto | Download | Qualidade em relação ao modelo original | Responde a |
| --- | ---: | --- | --- |
| **IQ1_M** (1,75 bits) | 239 GB | KL 0,217, perplexidade 3,80 (+21%); código +8,9%, tarefas de agente +10,2%; mais fraco em prosa comum em inglês. Segundo o cartão do modelo da ISTA-DASLab, mantém 98,7% da média de tarefas do modelo base (AIME25, GPQA-Diamond, LiveCodeBench v6). | ainda não medida |
| **IQ2_XXS** (2.00 bits) | 257 GB | KL 0,170, perplexidade 3,63 (+15,5%); código +8,6%, tarefas de agente +5,3% | 6 a 9 tokens/s em uma RTX 3060, cerca de 5 em uma RTX 5070 |
| **IQ2_XS** (2.25 bits) | 274 GB | KL 0,107, perplexidade 3,37 (+7,3%); código +3,2%, tarefas de agente +2,2% | cerca de 11 tokens/s em uma Radeon AI PRO R9700 |
| **IQ4_NL** (tabela Engram, não é um terceiro modelo) | 111 GB, já incluídos nos três downloads acima | A tabela de consulta Engram de 4 bits que o modelo lê da RAM ou do SSD. Os três conjuntos usam este único arquivo (`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`). | n/a |
4x Radeon AI PRO R9700: cerca de 31 tokens/s, 55 a 69 com DSpark.
Inicie com `strata launch --model ist-das-2.00bpw` (ou `ist-das-1.75bpw`, `ist-das-2.25bpw`). Detalhes, velocidades por placa e a instalação manual: [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md).
## Qual é a velocidade?
Com base numa placa de vídeo de 12 GB (RTX 5070, Ryzen 5 7600, 64 GB de RAM). Veja resultados de outras placas:
+26 -11
View File
@@ -33,7 +33,7 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
- **免费。** MIT 许可证,不用注册账号,不走云端。
- **隐私。** 没有遥测。Strata 下载什么、什么时候联网、如何校验下载内容,都写在 [docs/SAFETY.md](docs/SAFETY.md)。
版本 0.2.2 · 引擎 0.1.47
版本 0.2.3 · 引擎 0.1.48
## 硬件要求
@@ -72,16 +72,16 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
| 系统 | 你的显卡 | 要下载的文件 |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**不确定时:** NVIDIA 显卡的驱动版本在 580 及以上就选 `cuda13`,否则选 `cuda12`。`cuda12` 适用于较旧的显卡和驱动。
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000)目前还没有现成的应用,
@@ -182,6 +182,21 @@ Gyro 可在 NVIDIA 和 AMD 显卡(AMD 为实验性支持)以及 Linux 上的
所有尺寸、下载大小,以及各种配置能跑什么:[docs/MODELS.md](docs/MODELS.md)。
### DeepSeek V4.1-Flash(终端版,0.2.4 起)
[DeepSeek V4.1-Flash](https://huggingface.co/ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF) 是另一个大得多的模型。下面三个版本由 ISTA-DASLab 制作。Strata 在一台有一块 12 GB 显卡和 46 到 64 GB 内存的电脑上运行它们,其余部分从固态硬盘读取。它可在 NVIDIA RTX 30 到 50 系列显卡和 Radeon AI PRO R9700 上运行。它使用自己的引擎包 `strata-dsv41`,你选择 DeepSeek 时由 `strata launch` 下载。桌面应用暂时不提供它。
| 版本 | 下载大小 | 与原模型相比的质量 | 生成速度 |
| --- | ---: | --- | --- |
| **IQ1_M**(1.75 位) | 239 GB | KL 0.217,困惑度 3.80(+21%);代码 +8.9%,智能体任务 +10.2%;普通英文散文表现较弱。据 ISTA-DASLab 的模型卡,它保留了基础模型任务平均分的 98.7%(AIME25、GPQA-Diamond、LiveCodeBench v6)。 | 尚未测量 |
| **IQ2_XXS** (2.00 位) | 257 GB | KL 0.170,困惑度 3.63(+15.5%);代码 +8.6%,智能体任务 +5.3% | RTX 3060 上每秒 6 到 9 个 token,RTX 5070 上约 5 个 |
| **IQ2_XS** (2.25 位) | 274 GB | KL 0.107,困惑度 3.37(+7.3%);代码 +3.2%,智能体任务 +2.2% | 一块 Radeon AI PRO R9700 上每秒约 11 个 token |
| **IQ4_NL**(Engram 表,不是第三个模型) | 111 GB,已包含在上面三个下载中 | 模型从内存或固态硬盘读取的 4 位 Engram 查找表。三个版本共用这一个文件(`DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf`)。 | 不适用 |
4 块 Radeon AI PRO R9700:每秒约 31 个 token,使用 DSpark 时为 55 到 69。
用 `strata launch --model ist-das-2.00bpw`(或 `ist-das-1.75bpw`、`ist-das-2.25bpw`)启动。详情、各显卡的速度和手动安装见 [docs/DEEPSEEK41.md](docs/DEEPSEEK41.md)。
## 速度如何?
以下数据基于 12 GB 显卡(RTX 5070、Ryzen 5 7600、64 GB 内存)。其他显卡的结果见:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.2",
"version": "0.2.3",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+1 -1
View File
@@ -3524,7 +3524,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.2"
version = "0.2.3"
dependencies = [
"gtk",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.2"
version = "0.2.3"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.2",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.2",
"version": "0.2.3",
"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.2",
"version": "0.2.3",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
/**
* The context fixes against a REAL engine (not the scripted worker): Gyro-S at 64K on this PC, the agent bridge and the real Harness.
*
* gpu + heavy locks held first (python phaseA-tests\lock.py acquire gpu "023-agent: context e2e", then heavy); one engine; nothing of the
* installed app is used or touched: its model files are read, its Harness is copied (STRATA_HARNESS_DIR), data goes to a scratch folder.
*
* node scripts/context-e2e.mjs (needs STRATA_HARNESS_DIR; see the E2E_* names below)
*
* It starts serve/server.py as ITS OWN child (foreground: this script waits for it and stops it by PID at the end or on a memory alarm),
* runs four scenarios and prints one table. Exit code 0 when every expectation held.
*
* A a message that can never fit (about 60K tokens in a 64K window) is refused in seconds, before the engine reads it
* B a chat of dense log messages grows past the window several times over: automatic compaction runs, nothing is refused
* C /compact at a high fill: summarises, says the figures; and a second /compact is answered in plain words
* D the needle: a fact from the first message is asked about at the end (reported, not asserted: it is the summary's quality)
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { countTokens } from '../server/agent/context-fit.mjs';
const REPO = join(import.meta.dirname, '..', '..', '..');
const env = (name, fallback) => process.env[name] ?? fallback;
const DOCS = env('E2E_DOCS', 'C:\\Users\\AI-Server\\Documents\\Strata');
const ENGINE = env('E2E_ENGINE', 'C:\\Users\\AI-Server\\Desktop\\Strata\\GitHub\\wt-023-int\\build-cuda\\strata.exe');
const PROFILE = env('E2E_EXPERT_PROFILE', 'C:\\Users\\AI-Server\\AppData\\Local\\Programs\\Strata\\control\\data\\expert-profile.bin');
const PYTHON = env('E2E_PYTHON', 'python');
const PORT = Number(env('E2E_PORT', '18590'));
const MIN_FREE_GB = Number(env('E2E_MIN_FREE_GB', '7'));
const GGUF = join(DOCS, 'models', 'gyro-Gyro-S', 'Qwen3.8-Flash-Next-Gyro-S-TQ1_0.gguf');
const CUDA_BIN = env('E2E_CUDA_BIN', 'C:\\Program Files\\NVIDIA GPU Computing Toolkit\\CUDA\\v13.0\\bin');
const WINDOW = 65536;
/** A model server that is already running (the scripted worker, to try this script without a GPU): nothing is started or stopped. */
const EXISTING = process.env.E2E_BASE_URL ?? '';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const freeGb = () => {
const out = spawnSync('powershell', ['-NoProfile', '-Command', '[math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1MB,1)'], { encoding: 'utf8' }).stdout.trim();
return Number(out);
};
const rows = [];
const note = (id, ok, detail) => { rows.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${id} ${detail}`); };
if (!process.env.STRATA_HARNESS_DIR) { console.error('STRATA_HARNESS_DIR is not set'); process.exit(2); }
if (!EXISTING) for (const f of [ENGINE, GGUF]) if (!existsSync(f)) { console.error(`missing: ${f}`); process.exit(2); }
if (!EXISTING && freeGb() < 20) { console.error(`only ${freeGb()} GB of RAM are free; wait for the other streams (lock.py status) and run again`); process.exit(2); }
const scratch = mkdtempSync(join(tmpdir(), 'strata-ctx-e2e-'));
const configFile = join(scratch, 'server.json');
const logFile = join(scratch, 'server.log');
const readyFile = join(scratch, 'ready.json');
writeFileSync(configFile, JSON.stringify({
exe: ENGINE,
args: ['--pack', join(DOCS, 'packs', 'gyro-gyro-s'), '--native', GGUF, '--ple-gguf', GGUF, ...(existsSync(PROFILE) ? ['--expert-profile', PROFILE] : []),
'--prefill', 'auto', '--spec-min-p', '0.5', '--mtp', join(DOCS, 'mtp', 'rt'), '--max-context', String(WINDOW), '--kv', 'int8', '--pool-workers', '5',
'--expert-cache', 'auto', '--kv-resident', '32768', '--spec', '4', '--vram-reserve-mib', '1536'],
cwd: REPO, tokenizer: join(DOCS, 'packs', 'gyro-gyro-s', 'tokenizer'), model_name: 'qwen3.8-flash-next-gyro-gyro-s', log: join(scratch, 'engine.log'), lib_dirs: [], port: PORT, gpu: 0,
}, null, 2));
let server = null, bridge = null, alarm = null;
const stopServer = () => {
if (server?.pid) spawnSync('taskkill', ['/T', '/F', '/PID', String(server.pid)], { stdio: 'ignore' });
server = null;
};
const finish = async (code) => {
clearInterval(alarm);
try { await bridge?.close(); } catch { /* going away */ }
stopServer();
await sleep(1500);
console.log(`\nscratch folder: ${scratch}`);
process.exit(code);
};
process.on('SIGINT', () => finish(130));
if (!EXISTING) server = spawn(PYTHON, ['serve/server.py', '--engine', 'strata', '--config', configFile, '--port', String(PORT), '--no-api-key', '--ready-file', readyFile], {
cwd: REPO, env: { ...process.env, PATH: `${CUDA_BIN};${process.env.PATH}`, STRATA_DATA_DIR: join(scratch, 'data') }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
const sink = [];
if (server) { 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; }); }
// Memory alarm: this run must never be what freezes the PC. Under the floor the engine is stopped (by PID) and the run ends.
if (!EXISTING) alarm = setInterval(() => { const free = freeGb(); if (free < MIN_FREE_GB) { console.error(`ALARM: ${free} GB of RAM free, stopping the engine`); finish(3); } }, 3000);
alarm?.unref?.();
const base = EXISTING || `http://127.0.0.1:${PORT}`;
let ready = false;
for (let i = 0; i < 120 && !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);
}
writeFileSync(logFile, sink.join(''));
if (!ready) { console.error('the engine did not become ready:\n' + sink.join('').slice(-1500)); await finish(4); }
const model = (await (await fetch(`${base}/v1/models`)).json()).data[0].id;
console.log(`engine ready: ${model}, free RAM ${freeGb()} GB`);
// ---------------------------------------------------------------------------------------------------------------- the pieces
const line = i => `2026-10-22 13:${String(Math.floor(i / 60) % 60).padStart(2, '0')}:${String(i % 60).padStart(2, '0')} INFO svc-${i % 17} req=${(i * 2654435761 >>> 0).toString(16)} status=${200 + (i % 7) * 3} ms=${(i * 37) % 900} path=/api/v1/items/${(i * 7919) % 100000}`;
const logText = (chars, from = 0, needle = '') => {
const out = [];
let n = 0, i = from;
while (n < chars) { const l = line(i++); out.push(l); n += l.length + 1; }
if (needle) out.splice(Math.floor(out.length / 2), 0, needle);
return out.join('\n');
};
const count = text => countTokens({ baseUrl: `${base}/v1`, model, text });
const turnsOf = async (message) => {
const frames = [];
const t0 = Date.now();
await bridge.runTurn({ chatId: 'e2e', message, workspacePath: scratch, mode: 'bypass', thinking: 'Off', onFrame: f => frames.push(f) });
return { frames, seconds: (Date.now() - t0) / 1000, last: frames.at(-1) };
};
const textOf = frames => frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('').trim();
const noteOf = frames => frames.find(f => f.type === 'delta' && f.noteReason === 'compacted')?.text?.trim() ?? null;
bridge = createAgentBridge({
dataDir: join(scratch, 'agent'), env: process.env, startTimeoutMs: 120_000, log: l => console.log(' bridge:', l),
localInstances: () => [{ id: 'e2e', label: 'Gyro-S', baseUrl: `${base}/v1`, modelId: model, context: WINDOW, slots: 1, key: '' }], connections: () => [],
});
mkdirSync(join(scratch, 'agent'), { recursive: true });
// ---------------------------------------------------------------------------------------------------------------- A
{
// Grow a log until the engine itself counts about 60K tokens.
let chars = 150_000, tokens = await count(logText(chars));
while (tokens && tokens < 58_000 && chars < 400_000) { chars = Math.round(chars * (60_000 / tokens)); tokens = await count(logText(chars)); }
const t = await turnsOf(logText(chars) + '\n\nWhat is the most common status?');
const refused = t.last.type === 'error' && t.last.code === 'CONTEXT_MESSAGE_TOO_LARGE';
note('A too large is refused before it is read', refused && t.seconds < 15, `${chars} characters = ${tokens} tokens by the engine; answered in ${t.seconds.toFixed(1)} s; frame ${t.last.type}/${t.last.code ?? '-'}: ${String(t.last.message ?? '').slice(0, 110)}`);
}
// ---------------------------------------------------------------------------------------------------------------- B
const needle = 'NOTE FROM OPS: the vault code is COBALT-27. Do not lose it.';
const timeline = [];
let notes = 0, refusedAny = false, peak = 0;
{
for (let k = 0; k < 5; k++) {
const body = logText(34_000, k * 1000, k === 0 ? needle : '');
const t = await turnsOf(`${body}\n\nReply with the single word OK.`);
const note = noteOf(t.frames);
if (note) notes++;
if (t.last.type === 'error') refusedAny = true;
const used = t.last.contextTokens ?? 0;
peak = Math.max(peak, ...t.frames.filter(f => f.type === 'context').map(f => f.contextTokens), used);
timeline.push(`msg${k + 1}: ${t.last.type}${t.last.code ? '/' + t.last.code : ''} ${t.seconds.toFixed(0)} s context ${used} (${Math.round(100 * used / WINDOW)}%)${note ? ' [' + note.slice(0, 80) + ']' : ''}`);
console.log(' ' + timeline.at(-1));
if (t.last.type === 'error') break;
}
note('B five dense messages (about 14K tokens each) in a 64K window: no error', !refusedAny, `peak context ${peak} of ${WINDOW}; ${notes} compaction note(s) in the chat`);
note('B compaction ran and was announced', notes >= 1, timeline.join(' | '));
}
// ---------------------------------------------------------------------------------------------------------------- C
{
const run = async (line) => { const frames = []; await bridge.compactCommand({ chatId: 'e2e', message: line, workspacePath: scratch, mode: 'bypass', choice: null, onFrame: f => frames.push(f) }); return frames; };
await turnsOf(`${logText(30_000, 90_000)}\n\nReply with the single word OK.`);
const t0 = Date.now();
const first = await run('/compact');
const said = first.find(f => f.type === 'delta');
const ok = said?.key === 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.' && said.values[0] > said.values[1];
note('C /compact summarises and states the figures', ok, `${((Date.now() - t0) / 1000).toFixed(0)} s: ${said?.text}`);
const second = await run('/compact');
const again = second.find(f => f.type === 'delta')?.text ?? '';
note('C a second /compact is answered in plain words', /summarized|nothing|could not/i.test(again) && !/\{|400|invalid_request/.test(again), again);
}
// ---------------------------------------------------------------------------------------------------------------- D
{
const t = await turnsOf('What was the vault code in the very first message? Answer with the code only.');
const answer = textOf(t.frames);
console.log(`D needle after compaction: "${answer.slice(0, 80)}" (${t.last.type})`);
rows.push({ id: 'D the needle', ok: /COBALT-27/.test(answer), detail: `reported only: answer "${answer.slice(0, 80)}"` });
}
console.log('\n' + rows.map(r => `${r.ok ? 'PASS' : r.id.startsWith('D') ? 'INFO' : 'FAIL'} ${r.id}${r.id.startsWith('D') ? ` ${r.detail}` : ''}`).join('\n'));
const failed = rows.filter(r => !r.ok && !r.id.startsWith('D')).length;
writeFileSync(join(scratch, 'result.json'), JSON.stringify(rows, null, 2));
await finish(failed ? 1 : 0);
+12
View File
@@ -14,6 +14,7 @@ and talks to its Gateway. Nothing here downloads or builds anything.
| `children.mjs` | pure projection of one helper agent's events into its row and its approval card |
| `preset.mjs` | the agent preset (which tools the chat has) |
| `plugins/strata-gate.mjs` | the Manual-mode approval gate (a Harness plugin, loaded by path) |
| `plugins/strata-context.mjs`, `context-budget.mjs`, `context-fit.mjs` | measured context pressure, summaries that fit the window, the check before a message goes out |
| `agent-bridge.mjs` | the chat-to-session map, turns, approvals, Stop, status |
## Frames sent on `/api/chat/stream` for an agent turn
@@ -132,3 +133,14 @@ A turn that keeps failing the same way is stopped by the app, never left to the
- **Compaction:** after a guard stop (and after a turn the model server refused because the context was full) the bridge asks the Harness for `/compact` on that session, so the next message has room. Best effort and logged.
- **Never silent:** a turn that ends (`completed` or `max-tokens`) with no answer text gets "I ran out of room in this chat..." when the last request used 85% of the window or more, otherwise "I stopped without writing an answer..." (not when tool cards were shown, not after Stop, not after an error, which says why by itself). Goal runs have their own ending lines and are not guarded.
- Tests: `tests/agent-turn-guard.test.mjs` (counting, always; end to end with the real Harness, the scripted model's `searchloop` and `bloat` scripts and `searchMock: 'limited'` on the bridge, which runs the real search code on a pretend web that refuses, no network).
## Context: measured pressure, summaries that fit, the check before a message goes out (2026-10-22)
Found by the owner's hands-on test of the installed 0.2.1 (Gyro-S, 64K): a 118 KB log paste was 66,500 tokens to the engine and about 29,500 to the Harness (4 characters a token; logs and code are about 2); the engine refused it with a 400, the refusal stayed in the history, and then neither the next message nor `/compact` could go through, because the summary request carried the same history and was refused for its size too. `/compact` itself was never a command of the app: it went to the model as a question and the model answered "nothing to compact".
- **`plugins/strata-context.mjs`** (inserted by the profile, like the gate) wraps `tokenMeter.measure` and `llm.stream` in place, once each. Pressure = the last prompt size the model server reported for an answer, plus what changed since priced at the density that measurement showed (`context-budget.mjs`: `pressureNow`, `densityOf`); never lower than the Harness's own price. Node prices are scaled the same way, so the retained tail is in real tokens. A summary request (`purpose: 'compaction'`) that would not fit the window is asked for in pieces: a message bigger than a piece is cut for the request only, each piece builds on the summary before it, a piece the server refuses is halved and tried again. `/strata-context` (a Harness command, like `/strata-gate`) answers `{tokens, window}` for a session so the host can tell the person true figures.
- **`context-fit.mjs`**: before a message is sent the model server counts it (`POST /v1/messages/count_tokens`, its own tokenizer). `decideFit`: send; summarise first and then send (the message fits an otherwise empty chat); or never fits. The last one is not sent: the error frame has `code: 'CONTEXT_MESSAGE_TOO_LARGE'` and the page offers "Attach as a file" (`oversize.mjs`, `/api/chat/oversize`: the text is saved in the chat's folder, the assistant reads it in parts) or "Send a shortened preview". An engine that cannot count lets the message through; the Harness's own compact-and-retry is the net.
- **`/compact`** is handled by the host (`compactCommand`): the Harness's `/compact`, then a plain line with the measured figures ("The chat was 90% full and is now 38% full"), or "nothing older to summarize" when the newest message is what fills the chat.
- **Visible:** `compaction/start` shows "Summarizing earlier messages..." in the status line; `compaction/end` adds one plain line to the reply. The projector also sends a `context` frame after every model request, so the meter follows the running turn.
- **A refusal that is left** (`CONTEXT_WINDOW_EXCEEDED`) is a plain sentence with the engine's text under Details and the buttons "Summarize and continue" and "Start a new chat". "Try sending the message again" is for other errors only.
- Tests: `tests/agent-context.test.mjs` (arithmetic; the real Harness against the scripted worker with `window`, `charsPerToken` and `noCount`), `tests/oversize.test.mjs`, `tests/context-ui.test.mjs`. The run against a real engine is `scripts/context-e2e.mjs`.
+136 -7
View File
@@ -22,7 +22,7 @@ import { randomUUID } from 'node:crypto';
import { GatewayClient } from './gateway-client.mjs';
import { HarnessProcess, resolveHarness, redact, stopStaleAgent } from './harness-runtime.mjs';
import { StatusReporter } from './status.mjs';
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf } from './profile.mjs';
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudget, compactionHeadroom } from './profile.mjs';
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
import { ChildProjector } from './children.mjs';
import { nearestBudget } from './llm-shape.mjs';
@@ -30,6 +30,7 @@ import { DeliverableTracker, ActivityGroups } 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 { 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';
/**
@@ -111,11 +112,12 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
* @param {string|null} [options.componentsDir] where installed components live
* @param {() => Array} options.localInstances ready local instances (see profile.mjs)
* @param {() => Array} options.connections saved API connections WITH their keys (host-internal)
* @param {() => (string|null)} [options.defaultLocalId] the local instance the chat runs on: its tool servers are the agent's (T5)
* @param {NodeJS.ProcessEnv} [options.env]
* @param {string} [options.nodePath] the Node runtime that runs the Harness
* @param {(line:string)=>void} [options.log]
*/
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, defaultLocalId = () => null, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, engineProgress = () => null, engineGraceMs = 30 * 60_000, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
const root = join(dataDir, 'agent');
const home = join(root, 'home');
const sessionsFile = join(root, 'sessions.json');
@@ -171,7 +173,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** The agent's profile now, plus where its shaped routes really lead (read by the forwarding endpoint). */
let shapeTargets = {};
function buildProfileNow() {
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock });
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock, defaultLocalId: defaultLocalId() });
shapeTargets = profile.shapeTargets ?? {};
return profile;
}
@@ -193,6 +195,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
harness: { version: h.version },
runtime: rt?.hp.running ? { pid: rt.hp.pid, generation: rt.generation } : null,
routes: profile.routes.map(({ route, kind, modelId, name, contextWindow }) => ({ route, kind, modelId, name, contextWindow })),
// tool servers that could not be wired (a name the rule does not allow, no program or address): the chat says so
skippedServers: profile.mcpSkipped ?? [],
activeTurns: activeTurns(),
pendingApprovals: pending.size,
};
@@ -406,7 +410,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const appliedMode = planTask ? 'plan' : modeOf(mode);
await applyMode(runtime, sessionId, appliedMode);
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval });
// Does the message fit the model's window? Asked before anything is sent, with the model server's own count (context-fit.mjs). A message
// that fits once the older messages are a summary is sent after a summary; one that can never fit is not sent, and the person is told
// plainly and offered the two ways out. Whatever cannot be counted goes through: the Harness recovers from a refusal by itself.
if (!goal && route.kind === 'local') {
const fit = await fitCheck({ runtime, route, sessionId, fresh, history, message }).catch(() => null);
if (fit?.action === 'too-large') {
reporter.clear();
const values = [grouped(fit.tokens), grouped(fit.window)];
const text = fill(FIT_TEXT.tooLarge, values);
emit({ type: 'error', message: text, key: FIT_TEXT.tooLarge, values, code: 'CONTEXT_MESSAGE_TOO_LARGE', tokens: fit.tokens, window: fit.window });
return { error: text };
}
if (fit?.action === 'summarize') {
reporter.set({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
const done = await compactChat(runtime, sessionId);
reporter.clear();
if (done.ok) emit(summarizedNote(done.before, done.after, fit.window));
}
}
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval, window: route.contextWindow });
if (!goal) guard = new TurnGuard({ plan: !!planTask, contextMax: route.kind === 'local' ? route.contextWindow : 0 }); // a saved connection's window is often only assumed: never judged by a guess
projector.onStatus = update => { if (!turn?.parentOpen && turn?.children.size && [...turn.children.values()].some(h => !h.done) && update.phase === 'waiting') return; reporter.set(update); };
const turn = {
@@ -415,7 +439,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
abortFromRuntime: reason => finishTurn(reason), finish: failure => finishTurn(failure),
// Helper agents (subagents) the Harness started for this chat, and the state of the run around them.
children: new Map(), subagentCalls: new Map(), parentOpen: false, stopping: false, settleTimer: null, mode: appliedMode,
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), watchdog: null,
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), heard: Date.now(), engineMark: null, watchdog: null,
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
};
c.turn = turn;
@@ -428,6 +452,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
if (turn.finished || turn.stopping) return;
const toolRunning = [...projector.tools.values()].some(x => x.card?.state === 'running' || x.card?.state === 'proposed') || pending.size > 0 && pendingFor(chatId).length > 0;
const limit = toolRunning ? stallToolMs : stallMs;
// A local engine reading a long prompt sends the Harness nothing for minutes (#3623). Its /metrics progress counts as activity,
// for at most engineGraceMs after the Harness last spoke: progress for another chat cannot keep a lost turn alive forever.
if (turn.route?.kind === 'local' && Date.now() - turn.heard < engineGraceMs) {
let mark = null;
try { mark = engineProgress(turn.route.instanceId); } catch { /* no progress known: the Harness alone decides */ }
if (mark != null && mark !== turn.engineMark) { turn.engineMark = mark; turn.lastActivity = Date.now(); return; }
}
if (Date.now() - turn.lastActivity < limit) return;
turn.stalledFor = Math.round((Date.now() - turn.lastActivity) / 1000);
turn.stopping = true;
@@ -501,6 +532,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
function handle(item) {
if (turn.finished) return;
turn.lastActivity = Date.now();
turn.heard = turn.lastActivity;
if (item.type === 'event') {
const e = item.event;
if (e.seq <= turn.lastSeq) return;
@@ -538,7 +570,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
emitRow(child, true);
const fold = item => {
if (turn.finished) return;
turn.lastActivity = Date.now();
turn.lastActivity = turn.heard = Date.now();
// A helper that finishes before its throttled "working" row went out would show queued -> completed and never "working": send the held row first.
if (item.type === 'event' && item.event.type === 'turn/end' && child.rowPending) emitRow(child, true);
const { frames, row } = cp.consume(item);
@@ -951,6 +983,16 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
// -- /plan -----------------------------------------------------------------------------------------------
/** A short answer in the chat that the page translates (the sentence is in the interface catalog, "values" fill its slots). */
function sayKey(options, template, values = []) {
const { onFrame } = options;
onFrame({ type: 'queued', requestId: 'compact-' + randomUUID() });
onFrame({ type: 'start', model: '', contextMax: 0, demo: false, remoteModel: false, agent: true, mode: modeOf(options.mode), skills: [] });
onFrame({ type: 'delta', text: fill(template, values), key: template, values });
onFrame({ type: 'done', finishReason: 'stop', tokensIn: null, tokensOut: null, usageSource: 'unavailable', durationMs: 0 });
return { compact: true };
}
/** A short answer in the chat (no model): the same frames a turn sends. */
function sayOnce(options, reply) {
const { onFrame } = options;
@@ -1150,6 +1192,93 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
* Stop one chat's turn. Resolves only once the turn has ended (or the wait times out), because the
* Harness acknowledges a cancel as admitted, not as done.
*/
/** What the chat holds now and the model's window, as the context plugin measures them (null when it cannot be read). */
async function contextOf(runtime, sessionId) {
try {
const r = await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/strata-context', submittedAttachments: [] });
if (r?.result?.kind !== 'success') return null;
const v = JSON.parse(String(r.result.text));
return Number.isFinite(v.tokens) ? { tokens: Math.round(v.tokens), window: Number(v.window) > 0 ? Math.round(v.window) : 0 } : null;
} catch { return null; }
}
/** The decision of context-fit.mjs for the message about to be sent, or null when the model server cannot count it. */
async function fitCheck({ runtime, route, sessionId, fresh, history, message }) {
const inst = localInstances().find(i => i.id === route.instanceId);
if (!inst || !(route.contextWindow > 0)) return null;
const carried = fresh && history.length ? withEarlierConversation(history, message) : message;
if (carried.length < COUNT_ABOVE_CHARS) return null;
const count = text => countTokens({ baseUrl: inst.baseUrl, key: inst.key, model: inst.modelId, text });
let tokens = await count(carried);
if (tokens == null) return null;
const now = fresh ? null : await contextOf(runtime, sessionId);
const window = now?.window || route.contextWindow;
const pressure = now?.tokens ?? 0;
// Where the Harness starts summarising by itself on this window (profile.mjs: the reply keeps its room, then the headroom).
const reply = localOutputBudget(window);
const threshold = Math.floor(Math.min(window * 0.8, window - reply - compactionHeadroom(window, reply)));
let verdict = decideFit({ window, pressure, messageTokens: tokens, threshold });
// The earlier conversation carried into a fresh session is what pushed it over: send the message alone instead.
if (verdict.action === 'too-large' && carried !== message) {
const alone = await count(message);
if (alone != null) { tokens = alone; verdict = decideFit({ window, pressure, messageTokens: alone, threshold }); }
}
return { ...verdict, tokens, window, pressure };
}
/**
* Summarise the older part of a chat with the Harness's /compact and say what happened. Never throws.
* ok:true carries the figures before and after; ok:false says why (nothing to summarise, busy, failed).
*/
async function compactChat(runtime, sessionId) {
const before = await contextOf(runtime, sessionId);
let result;
try { result = (await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/compact', submittedAttachments: [] }))?.result; }
catch (e) { return { ok: false, reason: /not.found|no such/i.test(String(e.message)) ? 'nothing' : 'failed', before, text: String(e.message ?? e) }; }
const text = String(result?.text ?? '');
log('agent · compact: ' + (result?.kind ?? 'none') + ' ' + text.slice(0, 140));
if (result?.kind === 'success' && /^Compacted/i.test(text)) return { ok: true, before, after: await contextOf(runtime, sessionId) };
if (result?.kind === 'success') return { ok: false, reason: 'nothing', before, text };
if (/active compaction|not idle/i.test(text)) return { ok: false, reason: 'busy', before, text };
// The Harness cannot make a summary smaller than what is left when the chat holds little beyond its first prompt: that is nothing to summarise, not a failure.
const little = before && before.window > 0 && before.tokens - BASE_TOKENS < before.window * 0.25;
return { ok: false, reason: little && /useful summary|not smaller/i.test(text) ? 'nothing' : 'failed', before, text };
}
/** The line in the chat that explains why the context meter fell: how full it was and how full it is. */
function summarizedNote(before, after, window) {
const w = after?.window || before?.window || window || 0;
const pct = v => Math.max(0, Math.min(100, Math.round(100 * v.tokens / w)));
const known = w > 0 && before && after;
const template = known ? FIT_TEXT.summarized : FIT_TEXT.summarizedShort;
const values = known ? [pct(before), pct(after)] : [];
return { type: 'delta', own: true, text: '\n\n' + fill(template, values), key: template, values, noteReason: 'compacted' };
}
/** "/compact": summarise the older messages of this chat now, and say in plain words what happened. */
async function compactCommand(options) {
const { chatId, choice, signal } = options;
const c = chat(chatId);
const say = (template, values = []) => sayKey(options, template, values);
if (c.turn || c.starting) return say(FIT_TEXT.compactBusy);
const known = sessionMap[chatId]?.sessionId;
if (!known) return say(FIT_TEXT.noChatYet);
let runtime;
try { ({ rt: runtime } = await ensureRuntime(choice, signal)); } catch { return say(FIT_TEXT.compactFailed); }
const done = await compactChat(runtime, known);
if (done.ok) {
const note = summarizedNote(done.before, done.after, 0);
return say(note.key, note.values);
}
if (done.reason === 'busy') return say(FIT_TEXT.compactBusy);
if (done.reason === 'nothing') {
const w = done.before?.window || 0;
const pct = w > 0 && done.before ? Math.round(100 * done.before.tokens / w) : 0;
return say(pct >= 60 ? FIT_TEXT.nothingFull : FIT_TEXT.nothingToSummarize, pct >= 60 ? [pct] : []);
}
return say(FIT_TEXT.compactFailed);
}
/** Summarise the older part of a session with the Harness's own /compact. Best effort: the next message compacts again by itself when it must. */
async function compactSession(runtime, sessionId) {
try {
@@ -1204,7 +1333,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 { 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, 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 }) };
}
/** A goal that stopped by itself, in words. */
+162
View File
@@ -0,0 +1,162 @@
/**
* Token arithmetic for the agent's context, with no I/O (the Harness plugin `plugins/strata-context.mjs` and the host both use it).
*
* Why it exists. The Harness prices a conversation with a fixed 4 characters per token. Code, logs and JSON are about twice as dense
* (2.09 characters per token in the 2026-10-22 hands-on test), so its pressure read 31,794 tokens while the engine had been sent 59,048,
* and compaction waited until the window was already past full. The engine reports the real prompt size with every answer; this module
* turns that report into the number compaction decides on, and splits a summarising request that would not fit the window.
*/
/** The Harness's own price of a text: characters over this, plus a fixed cost per block and per message. */
export const CHARS_PER_TOKEN = 4;
const BLOCK_OVERHEAD = 4;
const MESSAGE_OVERHEAD = 4;
/** The most a real token can be worth in the Harness's units (one character per token is the worst a tokenizer does). */
export const MAX_DENSITY = 6;
/** Room kept free in a window for the engine's own framing and the chat template (the engine keeps 64 tokens of slack itself). */
export const WINDOW_SLACK = 1024;
/** The Harness's price (4 characters a token) of one message, the same arithmetic as its token meter. */
export function estimateMessage(message) {
const content = Array.isArray(message?.content) ? message.content : [];
if (message?.role === 'system') {
if (!content.length) return 0;
let chars = 0;
for (const block of content) chars += block?.type === 'text' ? String(block.text ?? '').length : JSON.stringify(block ?? {}).length;
return Math.ceil(chars / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD;
}
let tokens = MESSAGE_OVERHEAD;
for (const block of content) {
if (block?.type === 'text' || block?.type === 'reasoning') tokens += Math.ceil(String(block.text ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
else if (block?.type === 'tool-call') tokens += Math.ceil(String(block.name ?? '').length / CHARS_PER_TOKEN) + Math.ceil(String(block.arguments ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
else tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block ?? {}).length / CHARS_PER_TOKEN);
}
return tokens;
}
/** The Harness's price of the tool list of a request header. */
export function estimateTools(header) {
const tools = header?.tools;
if (!Array.isArray(tools) || !tools.length) return 0;
return Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
}
/**
* How many real tokens one of the Harness's tokens is worth, from one measured request: the engine said the prompt was `real` tokens,
* the Harness priced the same prompt at `estimated`. Never below 1 (a conversation of plain prose is priced about right, and a lower
* number would only delay compaction) and never above MAX_DENSITY (a wild sample must not make every step compact).
*/
export function densityOf(real, estimated) {
if (!(real > 0) || !(estimated > 0)) return 1;
return Math.min(MAX_DENSITY, Math.max(1, real / estimated));
}
/**
* The prompt size compaction decides on. `sample` is the last measured request: `real` tokens for a prompt the Harness priced at
* `estimated`. `now` is the Harness's price of the conversation now. What was added or removed since the sample is scaled by the sample's
* density; the measured part is exact. Without a sample, or when the measurement is lower than the Harness's own price, the Harness's
* price stands (it is never an underestimate that makes this smaller).
*/
export function pressureNow({ sample = null, now }) {
if (!sample || !(sample.real > 0) || !(sample.estimated > 0)) return Math.max(0, now);
const density = densityOf(sample.real, sample.estimated);
return Math.max(Math.round(now), Math.round(sample.real + density * (now - sample.estimated)));
}
/** The usage a finished model request reported, as the prompt side only: input plus the cache's reads and writes (output is not in the prompt). */
export function promptTokensOf(usage) {
if (!usage || typeof usage !== 'object') return 0;
const n = value => (Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0);
return n(usage.inputTokens) + n(usage.cacheReadTokens) + n(usage.cacheWriteTokens);
}
/** The usage of a settled assistant event (a `usage` on the message, or the last usage chunk of its stream). */
export function usageOfEvent(event) {
const data = event?.data;
if (!data) return null;
if (data.usage && typeof data.usage === 'object') return data.usage;
const stream = Array.isArray(data.stream) ? data.stream : [];
for (let i = stream.length - 1; i >= 0; i--) if (stream[i]?.type === 'chunk' && stream[i].chunk?.type === 'usage') return stream[i].chunk.usage ?? null;
return null;
}
/**
* A summarising request is: the conversation's system prompt and tools, the messages to condense, then the instruction. This is how many
* real tokens the messages may take in a window so that all of it, the summary and a safety margin fit.
*/
export function summaryBudget({ window, summaryTokens, toolsTokens = 0, systemTokens = 0, instructionTokens = 900, slack = WINDOW_SLACK }) {
return Math.floor(window - summaryTokens - toolsTokens - systemTokens - instructionTokens - slack);
}
/** Text blocks of a message, longest first: the ones worth shortening when a single message is bigger than the budget. */
function textBlocks(message) {
return (Array.isArray(message?.content) ? message.content : []).filter(b => (b?.type === 'text' || b?.type === 'reasoning') && typeof b.text === 'string');
}
/**
* One message cut down to `maxTokens` (in the Harness's price times `density`): the start and the end of its longest text are kept and
* the middle is replaced by a line saying how much was left out. Only the copy sent to the summariser is cut; the conversation keeps
* the whole message until the summary replaces it.
*/
export function clipMessage(message, maxTokens, density = 1) {
if (estimateMessage(message) * density <= maxTokens) return message;
const blocks = textBlocks(message);
if (!blocks.length) return message;
const others = estimateMessage({ ...message, content: (message.content ?? []).filter(b => !blocks.includes(b)) }) * density;
const room = Math.max(256, maxTokens - others);
const share = room / blocks.reduce((n, b) => n + b.text.length, 0) / density * CHARS_PER_TOKEN; // characters kept per character
const content = (message.content ?? []).map((block) => {
if (!blocks.includes(block)) return block;
const keep = Math.max(200, Math.floor(block.text.length * Math.min(1, share) - 120));
if (block.text.length <= keep) return block;
const head = Math.ceil(keep * 0.6), tail = keep - head;
const left = block.text.length - head - tail;
return { ...block, text: `${block.text.slice(0, head)}\n\n[… ${left.toLocaleString('en-US')} characters from the middle are left out of this summary request …]\n\n${block.text.slice(block.text.length - tail)}` };
});
return { ...message, content };
}
/** Whether cutting the list in front of index `i` would separate a tool call from its result. */
function balancedBefore(messages, i) {
let open = 0;
for (let k = 0; k < i; k++) {
const m = messages[k];
if (m?.role === 'assistant') open += (Array.isArray(m.content) ? m.content : []).filter(b => b?.type === 'tool-call').length;
else if (m?.role === 'tool') open -= 1;
}
return open <= 0;
}
/**
* Split messages into runs that each fit `budget` real tokens (price `density`), cutting between a tool call's result and the next
* message when it can. A message bigger than the budget by itself is clipped first. Order is kept.
*/
export function splitMessages(messages, budget, density = 1) {
const room = Math.max(512, budget);
const sized = messages.map(m => clipMessage(m, Math.floor(room * 0.8), density));
const cost = sized.map(m => estimateMessage(m) * density);
const runs = [];
let start = 0, used = 0;
for (let i = 0; i < sized.length; i++) {
if (i > start && used + cost[i] > room) {
// Cut as late as possible, but never between a tool call and its result when a clean place exists inside the run.
let cut = i;
while (cut > start + 1 && !balancedBefore(sized, cut)) cut--;
if (!balancedBefore(sized, cut)) cut = i;
runs.push(sized.slice(start, cut));
start = cut;
used = cost.slice(start, i).reduce((n, x) => n + x, 0);
}
used += cost[i];
}
if (start < sized.length) runs.push(sized.slice(start));
return runs;
}
/** Whether a failure is the model server saying the prompt does not fit its window. */
export function isContextOverflow(error) {
if (!error) return false;
if (error.code === 'CONTEXT_WINDOW_EXCEEDED') return true;
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);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Does a message fit the model's window? Asked before it is sent, with the model server's own count, so a message the server would refuse
* is never read for half a minute first (2026-10-22: a 103 KB paste was 70,141 tokens in a 65,536-token window, and the 400 came back after
* the whole prompt had been read). No I/O except the one count request; everything else is arithmetic the tests call directly.
*
* Three answers: send it; summarise the chat first and then send it (the message fits once the older messages are a summary); or it can
* never fit, and the person is offered the two ways out that do (the text as a file the assistant reads in parts, or a shortened preview).
*/
/** What a new chat already holds before the person's first word: the system prompt and the tool list. Measured 10,510 by the Harness's price. */
export const BASE_TOKENS = 12000;
/** The least room a reply needs after the prompt: an eighth of the window, at most this. */
export const REPLY_FLOOR = 4096;
/** A message shorter than this many characters cannot matter to a window of 8K or more, so it is not even counted. */
export const COUNT_ABOVE_CHARS = 3000;
export const replyRoom = window => Math.max(512, Math.min(REPLY_FLOOR, Math.floor(window / 8)));
/**
* @param {{window:number, pressure?:number, base?:number, messageTokens:number, threshold?:number}} input
* `pressure` is what the chat holds now (measured), `base` what an empty chat holds, `messageTokens` the new message, `threshold` where the
* Harness starts summarising by itself. The Harness looks at that only before its NEXT step, so a big message that arrives now would otherwise
* be sent on top of a chat already near the threshold (2026-10-22: 54K of 64K held by the time it was looked at): summarise first.
* @returns {{action:'send'|'summarize'|'too-large', room:number, needs:number}}
*/
export function decideFit({ window, pressure = 0, base: given = BASE_TOKENS, messageTokens, threshold = 0 }) {
const reply = replyRoom(window);
const base = Math.min(given, Math.floor(window * 0.6)); // on a small window the first prompt cannot be what the window is made of
// A chat holds at least what an empty one does (the system prompt and the tools), so a new chat is not counted as empty.
const needs = Math.max(pressure, base) + messageTokens + reply;
if (!(window > 0) || !(messageTokens >= 0)) return { action: 'send', room: Infinity, needs: 0 };
if (needs <= window) {
const worthIt = pressure > base + window * 0.1; // older messages worth summarising: more than a tenth of the window beyond the first prompt
if (threshold > 0 && worthIt && pressure + messageTokens > threshold) return { action: 'summarize', room: window - needs, needs, why: 'threshold' };
return { action: 'send', room: window - needs, needs };
}
// Not as it stands. Does the message fit in an otherwise empty chat (only the system prompt and the tools before it)?
if (base + messageTokens + reply <= window) return { action: 'summarize', room: window - (base + messageTokens + reply), needs };
return { action: 'too-large', room: window - (base + messageTokens + reply), needs };
}
/**
* The model server's count of a text (its tokenizer and chat template), through its Anthropic-style count endpoint.
* Returns the number, or null when it cannot be had (an old engine, a server that is busy, a timeout): then nothing is blocked.
*/
export async function countTokens({ baseUrl, key = '', model = '', text, fetchImpl = fetch, timeoutMs = 8000 }) {
try {
const root = String(baseUrl ?? '').replace(/\/v1\/?$/, '').replace(/\/$/, '');
const res = await fetchImpl(`${root}/v1/messages/count_tokens`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}`, 'x-api-key': key } : {}) },
body: JSON.stringify({ model, messages: [{ role: 'user', content: String(text) }] }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
const n = Number((await res.json())?.input_tokens);
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
} catch { return null; }
}
/** The plain sentences (also in the interface catalog in all eight languages). `{0}` slots are filled by the page. */
export const FIT_TEXT = Object.freeze({
tooLarge: 'This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.',
tooLargeNext: 'Attach it as a file for the assistant to read in parts, or send a shortened preview.',
full: 'This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.',
fullNext: 'Summarize it now and continue, or start a new chat.',
summarizing: 'Summarizing earlier messages…',
summarized: 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.',
summarizedWas: 'Older messages were summarized to make room. The chat was {0}% full.',
summarizedShort: 'Older messages were summarized to make room.',
nothingFull: 'This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.',
nothingToSummarize: 'There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.',
noChatYet: 'There is nothing to summarize yet. This chat has not started a conversation with the assistant.',
compactFailed: 'The older messages could not be summarized right now. Try again in a moment, or start a new chat.',
compactBusy: 'This chat is still working. Stop it or wait for it to finish, then try again.',
});
export const fill = (template, values = []) => String(template).replace(/\{(\d+)\}/g, (_, i) => String(values[Number(i)] ?? ''));
/** A number with thousands separators, the way the page writes it. */
export const grouped = n => Math.round(n).toLocaleString('en-US');
/**
* The two ways to send a message that is too large.
* file the whole text goes in a file in the chat's folder; the message names it and shows how it starts
* preview the start and the end of the text, cut to what the window can hold, with the number of characters left out
* @param {{text:string, mode:'file'|'preview', tokens:number, window:number, pressure?:number, base?:number, path?:string}} input
* @returns {{message:string, kept:number}}
*/
export function oversizedReplacement({ text, mode, tokens, window, pressure = 0, base = BASE_TOKENS, path = '' }) {
const t = String(text);
const charsPerToken = tokens > 0 ? Math.max(0.8, t.length / tokens) : 3;
if (mode === 'file') {
const start = t.slice(0, 1200).trimEnd();
return { kept: start.length, message: `My message was too long to send in the chat, so I saved it as a file: ${path}\n\nIt starts like this:\n\n${start}\n\n[the rest is in the file]\n\nPlease read the file in parts (it is long) and then do what my message asks.` };
}
const room = Math.max(1000, Math.floor((window - Math.max(pressure, base) - replyRoom(window)) * 0.7));
const keep = Math.max(500, Math.floor(room * charsPerToken * 0.9));
const head = Math.ceil(keep * 0.7), tail = keep - head;
const left = Math.max(0, t.length - head - tail);
if (!left) return { kept: t.length, message: t };
return { kept: head + tail, message: `${t.slice(0, head)}\n\n[… ${grouped(left)} characters from the middle are left out because the message was too long to send …]\n\n${t.slice(t.length - tail)}` };
}
@@ -0,0 +1,103 @@
/**
* Runs the command of a command tool the person wrote (Settings -> Tools). Split from custom-tools-mcp.mjs so it can be tested.
*
* T7 (tools and images review, 2026-10-22). It used to run every command with `shell: true` (cmd.exe on Windows, so `Get-Content x` failed with
* "not recognized"), in whatever folder the host happened to run in (so `python tools/foo.py` could not find its file), and ended it after two
* minutes with 20,000 characters of output, silently cut.
*
* shell 'auto' (default): PowerShell for a Windows command that reads as PowerShell (Get-Content, Start-Process, $env:X, $(...)), cmd.exe for
* any other Windows command (so every command that worked keeps working), /bin/sh elsewhere. 'cmd', 'powershell', 'bash' and 'sh' name one.
* cwd the folder to run in: the tool's own, or the one the assistant names in the call (it knows the chat's folder); else the person's home.
* 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 { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
export const DEFAULT_TIMEOUT_SEC = 120;
export const MAX_TIMEOUT_SEC = 3600;
export const OUT_MAX = 20000;
export const SHELLS = ['auto', 'cmd', 'powershell', 'bash', 'sh'];
/** A Windows command that is PowerShell and would fail in cmd.exe. */
const POWERSHELL_LOOKS = /(^|[\s;|(&])[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b|\$\w|\$\(|\$env:|@\(|\|\s*(?:%|\?)\s*\{/;
export const looksLikePowerShell = command => POWERSHELL_LOOKS.test(String(command ?? ''));
let powershellFound;
/** pwsh when it is installed (PowerShell 7), else the Windows PowerShell every Windows has. */
function powershellExe(platform) {
if (powershellFound) return powershellFound;
if (platform !== 'win32') return (powershellFound = 'pwsh');
try { if (spawnSync('where', ['pwsh'], { windowsHide: true, timeout: 5000 }).status === 0) return (powershellFound = 'pwsh'); } catch { /* none */ }
return (powershellFound = 'powershell.exe');
}
/**
* -> { file, args, shell } for spawn: which program runs `command`.
* @param {string} command
* @param {{shell?: string, platform?: string}} [options]
*/
export function planShell(command, { shell = 'auto', platform = process.platform } = {}) {
const windows = platform === 'win32';
let choice = SHELLS.includes(shell) ? shell : 'auto';
if (choice === 'auto') choice = windows ? (looksLikePowerShell(command) ? 'powershell' : 'cmd') : 'sh';
if (choice === 'powershell') return { file: powershellExe(platform), args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], shell: false, name: 'PowerShell' };
if (choice === 'bash') return { file: 'bash', args: ['-c', command], shell: false, name: 'bash' };
if (choice === 'sh') return windows ? { file: 'sh', args: ['-c', command], shell: false, name: 'sh' } : { file: command, args: [], shell: true, name: 'sh' };
return { file: command, args: [], shell: true, name: windows ? 'cmd.exe' : 'sh' }; // cmd: spawn's shell:true is cmd.exe on Windows
}
/** The folder a command runs in: -> { cwd } or { error } with a plain sentence. */
export function resolveCwd(...candidates) {
for (const candidate of candidates) {
const text = typeof candidate === 'string' ? candidate.trim() : '';
if (!text) continue;
const expanded = text.replace(/^~(?=$|[\\/])/, homedir());
try {
if (!existsSync(expanded) || !statSync(expanded).isDirectory()) return { error: `The folder “${text}” does not exist, so the command was not run.` };
} catch { return { error: `The folder “${text}” could not be opened, so the command was not run.` }; }
return { cwd: expanded };
}
return { cwd: homedir() };
}
export const clampTimeoutSec = value => {
const n = Number(value);
return Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), MAX_TIMEOUT_SEC) : DEFAULT_TIMEOUT_SEC;
};
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 */ }
}
/**
* @param {{command: string, input?: string, cwd?: string, callCwd?: string, shell?: string, timeoutSec?: number, platform?: string}} job
* @returns {Promise<{ok: boolean, text: string}>}
*/
export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto', timeoutSec, platform = process.platform }) {
return new Promise(resolve => {
const where = resolveCwd(callCwd, cwd);
if (where.error) return resolve({ ok: false, text: where.error });
const plan = planShell(command, { shell, platform });
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'] });
} 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; };
child.stdout.on('data', take); child.stderr.on('data', take);
const timer = setTimeout(() => { timedOut = true; endTree(child); }, limit * 1000);
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); });
child.on('close', code => {
clearTimeout(timer);
if (text.length > OUT_MAX) { cut = true; text = text.slice(0, OUT_MAX); }
const out = text.trim() + (cut ? `\n\n(The output was cut after ${OUT_MAX.toLocaleString('en-US')} characters.)` : '');
if (timedOut) return resolve({ ok: false, text: `The command was stopped after ${limit} seconds.${out ? '\n' + out : ''}` });
resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
});
});
}
+9 -23
View File
@@ -8,49 +8,35 @@
* It speaks the Model Context Protocol over stdio (newline-delimited JSON-RPC), the transport the Harness's MCP client uses for a program
* it starts. The file is re-read on every request, so a change in Settings is seen without restarting anything. The assistant's text for
* a command tool reaches the command in the STRATA_INPUT environment variable (never spliced into the command line, so it cannot add
* commands of its own). A command gets 2 minutes and 20,000 characters of output.
* commands of its own). A command runs in the shell that suits it (PowerShell for a Windows command that reads as PowerShell, cmd.exe for
* the rest, sh elsewhere), in its own folder or the one the assistant names, gets 2 minutes unless its tool allows more (up to an hour) and
* 20,000 characters of output (custom-command-run.mjs).
*/
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { runCommand } from './custom-command-run.mjs';
const [mode, file] = process.argv.slice(2);
if (!['instructions', 'commands'].includes(mode) || !file) { console.error('usage: custom-tools-mcp.mjs instructions|commands <tools.json>'); process.exit(2); }
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8'))[mode] ?? {}; } catch { return {}; } };
const OUT_MAX = 20000;
const TIME_MS = 120000;
const schema = mode === 'commands'
? { type: 'object', properties: { input: { type: 'string', description: 'Text to give the command (optional).' } }, additionalProperties: false }
? { type: 'object', properties: {
input: { type: 'string', description: 'Text to give the command (optional).' },
cwd: { type: 'string', description: 'The folder to run the command in (optional). Give the working folder of this chat when the command uses relative paths.' },
}, additionalProperties: false }
: { type: 'object', properties: {}, additionalProperties: false };
function listTools() {
return Object.entries(load()).map(([name, t]) => ({ name, description: String(t.description ?? ''), inputSchema: schema, annotations: mode === 'instructions' ? { readOnlyHint: true } : {} }));
}
function runCommand(command, input) {
return new Promise(resolve => {
const child = spawn(command, { shell: true, windowsHide: true, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
let text = '';
const take = chunk => { if (text.length < OUT_MAX) text += chunk.toString(); };
child.stdout.on('data', take); child.stderr.on('data', take);
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, TIME_MS);
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start: ${e.message}` }); });
child.on('close', (code, signal) => {
clearTimeout(timer);
const out = text.slice(0, OUT_MAX).trim();
if (signal) resolve({ ok: false, text: `The command was stopped after ${TIME_MS / 1000} seconds.${out ? '\n' + out : ''}` });
else resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
});
});
}
async function call(name, args) {
const t = load()[name];
if (!t) return { isError: true, content: [{ type: 'text', text: 'That tool is no longer available.' }] };
if (mode === 'instructions') return { content: [{ type: 'text', text: String(t.text ?? '') }] };
const r = await runCommand(String(t.command ?? ''), args?.input);
const r = await runCommand({ command: String(t.command ?? ''), input: args?.input, callCwd: args?.cwd, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec });
return { isError: !r.ok, content: [{ type: 'text', text: r.text }] };
}
@@ -0,0 +1,11 @@
/**
* How far a local engine has got, as a short text that changes whenever it makes progress. The agent's stall watchdog
* compares it with the last one it saw: while a long prompt is read the Harness hears nothing for minutes, but the
* engine's /metrics (polled every 2 s) shows `live.prompt_read` moving (#3623). Null when the engine is idle or says nothing.
*/
export function engineProgressMark(instance) {
const live = instance?.metrics?.live;
if (!live || live.state === 'idle' || live.state === 'unloaded') return null;
const slots = Array.isArray(live.slots) ? live.slots.map(s => `${s?.state}:${s?.prompt_read ?? ''}:${s?.generated ?? ''}`).join(',') : '';
return `${live.state}|${live.prompt_read ?? ''}|${live.generated ?? ''}|${slots}`;
}
+36 -6
View File
@@ -9,6 +9,8 @@
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';
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
const WRITE = new Set(['write', 'edit']);
@@ -157,6 +159,9 @@ export function withPlain(raw) {
return plain ? `${plain}\n\nDetails: ${raw.replace(/^Error:\s*/, '')}` : raw;
}
/** What a launch that printed nothing proves, and no more. */
export const LAUNCHED_TEXT = 'The command finished without an error, so the program was started. Strata cannot confirm that a window or page actually opened.';
/** A command whose whole point is to open something: it has no output to show when it worked. */
const LAUNCH = /(^|[\s;|&(])(start-process|start|invoke-item|ii|explorer(?:\.exe)?|xdg-open|open|notepad(?:\.exe)?|code)(?=[\s;|&)]|$)/i;
@@ -172,9 +177,10 @@ export function outcomeText(tool, text, args = {}) {
text = text.replace(/\r?\n?\[stderr\]\r?\n(?:Cannot create type\. Only core types are supported in this language mode\.[\s\S]*?CannotCreateTypeConstrainedLanguage\s*)+$/, '');
if (!text.trim()) text = '(no output)';
}
// Starting a program or opening a folder prints nothing when it worked. The model used to read "finished, nothing to show" as "cannot tell
// whether it opened" and told the person so; a launch that returned without an error did start, and the result says that plainly.
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return 'Started. The program, folder or file was opened.';
// Starting a program or opening a folder prints nothing when it worked, and also when it did not (a browser given a file:// address, a
// program that closed at once). T9 / #3576: the card used to say "was opened", and the model repeated it ("I previewed it in a browser to
// confirm it renders"). It says what is known: the command returned without an error.
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return LAUNCHED_TEXT;
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim())) return 'The command finished without any error. It printed nothing, so there is nothing more to show.';
return text;
}
@@ -196,8 +202,10 @@ export class TurnProjector {
* no question ever reaches the person: the card goes straight from running to denied, without a moment where it
* offers Approve and Deny buttons that would do nothing.
*/
constructor({ startedAt = Date.now(), policy = 'ask' } = {}) {
constructor({ startedAt = Date.now(), policy = 'ask', window = 0 } = {}) {
this.startedAt = startedAt;
this.window = Number(window) || 0; // the model's window, for the figures in the line that says the chat was summarised
this.summarizing = false;
this.policy = policy;
this.byPolicy = new Set(); // callIds the approval policy rejected without asking
this.tools = new Map(); // callId -> { card, calledAt }
@@ -303,6 +311,7 @@ export class TurnProjector {
this.tokensIn += prompt;
this.tokensOut += Number(c.usage.outputTokens) || 0;
this.contextTokens = prompt + (Number(c.usage.outputTokens) || 0);
out.push({ type: 'context', contextTokens: this.contextTokens, contextMax: this.window || undefined });
}
}
return out;
@@ -371,6 +380,22 @@ export class TurnProjector {
if (frame) out.push(frame);
break;
}
case 'compaction/start':
// The Harness is summarising the older messages to make room: say so while it does, and again (once) when it is done.
this.summarizing = true;
this.#status({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
break;
case 'compaction/end':
if (!this.summarizing) break;
this.summarizing = false;
this.#afterTool();
if (!d.error) {
const before = this.window > 0 && this.contextTokens ? Math.min(100, Math.round(100 * this.contextTokens / this.window)) : null;
const text = before === null ? FIT_TEXT.summarizedShort : FIT_TEXT.summarizedWas;
const values = before === null ? [] : [before];
out.push({ type: 'delta', own: true, text: '\n\n' + fill(text, values), key: text, values, noteReason: 'compacted' });
}
break;
case 'turn/start':
// The parent woke up again (a helper finished and reported back): the message goes on, as a new paragraph.
this.ended = null;
@@ -420,7 +445,7 @@ export class TurnProjector {
case 'aborted':
return [{ type: 'done', ...base, ...usage, finishReason: 'aborted', cancelled: true }];
case 'error':
return [{ type: 'error', message: friendlyFailure(reason.error), code: reason.error?.code ?? 'UNKNOWN' }];
return [{ type: 'error', message: friendlyFailure(reason.error), code: isContextFailure(reason.error) ? 'CONTEXT_WINDOW_EXCEEDED' : reason.error?.code ?? 'UNKNOWN', ...(isContextFailure(reason.error) ? { key: FIT_TEXT.full, details: String(reason.error?.message ?? '') } : {}) }];
case 'blocked':
return [{ type: 'error', message: 'The agent stopped because this turn was blocked by policy.', code: 'BLOCKED' }];
default:
@@ -429,10 +454,15 @@ export class TurnProjector {
}
}
/** Whether a failure is the model server saying the conversation does not fit its window (its code, or the engine's own words). */
export function isContextFailure(failure) {
return isContextOverflow(failure) || /context window|too long for|exceeds the context/i.test(String(failure?.message ?? ''));
}
/** A model-route failure in words a user can act on. */
export function friendlyFailure(failure) {
const message = String(failure?.message ?? 'The model request failed.');
if (/context window|too long|exceeds/i.test(message)) return `The conversation no longer fits the model's context window. ${clip(message, 200)}`;
if (isContextFailure(failure)) return FIT_TEXT.full;
if (/ECONNREFUSED|fetch failed|connect/i.test(message)) return `The model is not reachable. ${clip(message, 200)}`;
if (/401|403|credential|api key/i.test(message)) return 'The model rejected the credentials. Check the connection in Settings.';
return clip(message, 400);
+1 -1
View File
@@ -33,7 +33,7 @@ export function agentEnvironment(base = process.env, extra = {}) {
}
// DSH_PERMISSION_MODE is the composition default for a session no chat mode has touched (a child agent, a
// seeded session): the strictest sandbox, which matches the Manual preset, never the Harness's wider default.
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', ...extra };
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', NODE_USE_SYSTEM_CA: '1', ...extra };
}
/**
+1 -1
View File
@@ -117,7 +117,7 @@ export function stepObjective(plan, index) {
`You are on step ${index + 1} of ${n}: ${s.text}${s.detail ? ` (${s.detail})` : ''}.`,
done ? `Already done: ${done}.` : '',
plan.briefing ? String(plan.briefing) : '',
isViewStep(s.text) ? 'This step is about seeing the result in a browser. Never wait for a program to close. If a person is at this computer, start the browser so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say it is open. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
isViewStep(s.text) ? 'This step is about seeing the result. To show a local image, SVG, HTML page or PDF, use the present tool (a browser cannot open file:// addresses). Never wait for a program to close. If a person is at this computer and you start a browser, start it so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say only that you started it, not that the page rendered. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
'Do only this step, check that it worked, and then finish the goal.',
index + 1 < n ? 'The next steps are handled separately: do not start them.' : '',
].filter(Boolean).join(' ');
@@ -0,0 +1,159 @@
/**
* Strata's context plugin for the Harness (plain ESM, loaded by absolute path from the profile, like strata-gate and strata-models).
*
* What it fixes (2026-10-22 hands-on test, Gyro-S at 64K):
* 1. Compaction decided from the Harness's own price of the conversation, 4 characters a token. Code, logs and JSON are about twice as
* dense, so a chat the engine had read as 59,048 tokens was priced at 31,794 and compaction started far too late. Here the token meter
* is calibrated by what the model server reports for each answer (its real prompt size): the measured size is exact, and what was
* added since is priced with the density that measurement showed. Automatic compaction and the manual /compact both measure through
* the token meter, so both read the same number as the context meter in the app.
* 2. The summarising request itself could be bigger than the window ("/compact" sent a 66,507-token prompt to a 65,536 window). The
* summary is now asked for in pieces that fit: a message bigger than a piece is cut for the request (the conversation keeps it until
* the summary replaces it), the pieces are summarised one after the other, each building on the summary before it, and a piece the
* server still refuses is halved and tried again.
*
* How. It wraps `tokenMeter.measure` and `llm.stream` in place, once each (the same way strata-models wraps the subagent tools). Only a
* 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';
export const name = 'strata-context';
export const inject = ['tokenMeter', 'llm'];
/** Real prompt measurements per session: { real, estimated } for the last answer the model server reported. */
const samples = new WeakMap();
const bySessionId = new Map();
const wrapped = new WeakSet();
/** Densities that made a summary request fail on this server, per session id (the next request starts from a more careful one). */
const careful = new Map();
export function apply(ctx) {
const meter = ctx.tokenMeter;
const llm = ctx.llm;
const log = (level, text) => { try { ctx.logger?.[level]?.(`strata-context: ${text}`); } catch { /* logging never blocks */ } };
// 1. The measured size of the prompt, from the model server's own report for each answer.
if (meter && typeof meter.measure === 'function' && !wrapped.has(meter)) {
wrapped.add(meter);
const original = meter.measure;
meter.measure = function measure(session, requestHeader) {
const m = original.call(this, session, requestHeader);
const sample = samples.get(session);
if (!sample) return m;
const now = m.surfaceTokens + estimateTools(requestHeader ?? session.requestHeader?.());
const total = Math.max(m.totalTokens, pressureNow({ sample, now }));
if (total <= m.totalTokens) return m;
const density = densityOf(sample.real, sample.estimated);
return { ...m, totalTokens: total, nodes: m.nodes.map(n => ({ ...n, tokens: Math.ceil(n.tokens * density) })) };
};
ctx.on('session/event', (session, event) => {
if (event.type !== 'assistant/message') return;
const real = promptTokensOf(usageOfEvent(event));
if (!(real > 0)) return;
try {
// The request that was just answered saw the conversation without this answer: its price is the surface before the answer's node.
const m = original.call(meter, session);
const heuristic = m.nodes.reduce((n, x) => n + (x.seq === event.seq ? 0 : x.heuristicTokens), 0);
const estimated = heuristic + estimateTools(session.requestHeader?.());
if (!(estimated > 0)) return;
const sample = { real, estimated };
samples.set(session, sample);
if (session.id) bySessionId.set(String(session.id), sample);
} catch (e) { log('warn', `could not record a measurement: ${e.message}`); }
});
}
// 2. A summary request that fits the window.
if (llm && typeof llm.stream === 'function' && !wrapped.has(llm)) {
wrapped.add(llm);
const original = llm.stream;
llm.stream = function stream(options) {
if (options?.purpose !== 'compaction') return original.call(this, options);
return summaryStream.call(this, options, original);
};
}
// 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) => {
commandCtx.commands.register({
definitionId: 'strata-context',
name: 'strata-context',
description: 'Reports how many tokens the conversation holds now and the size of the model window',
handler: async (invocation) => {
try {
const session = invocation.agent.session;
const tokens = meter.measure(session).totalTokens;
const config = session.requestHeader?.()?.config;
let window = 0;
if (config) window = (await llm.resolveModelInfo(config.provider, config.model))?.context?.contextWindow ?? 0;
return { kind: 'success', text: JSON.stringify({ tokens, window }) };
} catch (e) { return { kind: 'error', text: `strata-context: ${e.message}` }; }
},
});
});
/** A finished stream: its chunks, the text it wrote and why it ended. */
async function drain(iterable) {
let text = '', finish = null;
const chunks = [];
for await (const chunk of iterable) {
chunks.push(chunk);
if (chunk.type === 'text-delta') text += chunk.text ?? '';
else if (chunk.type === 'finish') finish = chunk.reason;
}
return { text, finish, chunks };
}
const refusedForSize = finish => finish?.kind === 'error' && isContextOverflow(finish.failure ?? finish);
async function* summaryStream(options, original) {
let window = 0;
try { window = (await llm.resolveModelInfo(options.provider, options.model, options.signal))?.context?.contextWindow ?? 0; } catch { window = 0; }
const messages = options.messages ?? [];
const key = String(options.sessionId ?? '');
const summaryTokens = options.maxTokens ?? 4096;
if (!(window > 0) || messages.length < 2) { yield* original.call(this, options); return; }
const own = bySessionId.get(key);
let density = Math.max(careful.get(key) ?? 1, own ? densityOf(own.real, own.estimated) : 1);
const price = list => list.reduce((n, m) => n + estimateMessage(m), 0) * density;
const limit = window - summaryTokens - WINDOW_SLACK;
// The whole request fits by this price: ask as the Harness would (the model server's cache of the conversation is reused).
if (price(messages) + estimateTools({ tools: options.tools }) * density <= limit) {
const first = await drain(original.call(this, options));
if (!refusedForSize(first.finish)) { for (const chunk of first.chunks) yield chunk; return; }
density = Math.min(MAX_DENSITY, density * 1.6);
careful.set(key, density);
log('warn', 'the summary request did not fit the window; asking in pieces');
}
// In pieces. The instruction is the last message; the system prompt, when there is one, the first.
const instruction = messages[messages.length - 1];
const system = messages[0]?.role === 'system' ? messages[0] : null;
const body = messages.slice(system ? 1 : 0, -1);
let tools = options.tools;
if (tools && estimateTools({ tools }) * density > window / 3) tools = undefined; // a long tool list only describes tools: the summary does not need it
const fixed = (system ? estimateMessage(system) : 0) * density + estimateTools({ tools }) * density + estimateMessage(instruction) * density;
let budget = summaryBudget({ window, summaryTokens: summaryTokens * 2, systemTokens: fixed, instructionTokens: 0 });
for (let attempt = 0; ; attempt++) {
const runs = splitMessages(body, Math.max(1024, budget), density);
log('info', `summarising ${body.length} messages in ${runs.length} piece(s) for a ${window}-token window (density ${density.toFixed(2)}, about ${Math.round(budget)} tokens a piece)`);
let failed = null, summary = '';
for (let i = 0; i < runs.length && !failed; i++) {
options.signal?.throwIfAborted?.();
const carried = summary ? [{ role: 'user', content: [{ type: 'text', text: `<compacted-summary>\n${summary}\n</compacted-summary>` }] }] : [];
const { tools: _drop, ...rest } = options;
const piece = { ...rest, ...(tools ? { tools } : {}), messages: [...(system ? [system] : []), ...carried, ...runs[i], instruction] };
const { text, finish, chunks } = await drain(original.call(this, piece));
if (refusedForSize(finish)) { failed = finish; break; }
// The last piece's answer is the summary the Harness reads; a piece that ended in another error ends the request the same way.
if (i === runs.length - 1 || finish?.kind === 'error' || finish?.kind === 'aborted') { for (const chunk of chunks) yield chunk; return; }
summary = text;
}
if (!failed || attempt >= 3) throw Object.assign(new Error(failed?.failure?.message ?? 'the summary request does not fit the window'), { code: 'CONTEXT_WINDOW_EXCEEDED' });
density = Math.min(MAX_DENSITY, density * 1.6);
careful.set(key, density);
budget = Math.max(1024, budget / 2);
log('warn', `a piece did not fit; asking again with pieces of about ${Math.round(budget)} tokens`);
}
}
}
+38 -3
View File
@@ -68,8 +68,37 @@ const SAFE_VERSION = /^(node|python|python3|py|npm|pnpm|git|dotnet|cargo|rustc|g
/** Anything that can chain, redirect, expand or start something else is not read-only. */
const UNSAFE_SYNTAX = /[;&<>`${}()]|\r|\n|--%|\bout-file\b|\bset-content\b|\badd-content\b|\binvoke-|\biex\b|\bstart-|\bnew-|\bremove-|\bcopy-|\bmove-|\brename-|\bclear-|\bset-|\bpush-|\bexpand-|\bsave-|\binstall-|\bcurl\b|\bwget\b|\bsudo\b|\bsh\b|\bbash\b|\bpwsh\b|\bpowershell\b|\bcmd\b|-exec\b|-delete\b|-execdir\b/i;
/**
* The command with every quoted text replaced by a plain stand-in, or null when it cannot be read safely. What is inside quotes is data
* (a path like "C:\Users\x\Q&A", a pattern like 'a|b'), so an & ; | ( ) < > or { in it must not decide anything. Two things still can:
* inside double quotes a $ or a backtick expands (a variable, a sub-command), and an unterminated quote means the text was not understood.
* Either one gives null, and the command asks as before.
* @param {string} text
* @returns {string|null}
*/
export function maskQuotes(text) {
let out = '';
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c !== '"' && c !== "'") { out += c; continue; }
let end = text.indexOf(c, i + 1);
// PowerShell writes a quote inside a quoted text by doubling it ('it''s'): the text goes on past the pair.
while (end >= 0 && text[end + 1] === c) end = text.indexOf(c, end + 2);
if (end < 0) return null;
if (c === '"' && /[$`]/.test(text.slice(i + 1, end))) return null;
// A quoted text is a whole word. One glued to other characters (--out"put"=f, "a"b) or one that starts with a dash could hide an option from the
// word checks below (git diff "--output=f" writes a file), so those are not understood either.
const before = text[i - 1], after = text[end + 1];
if ((before !== undefined && !/[\s|]/.test(before)) || (after !== undefined && !/[\s|]/.test(after)) || text[i + 1] === '-') return null;
out += '"_"';
i = end;
}
return out;
}
/**
* Whether a shell command only reads. Conservative by design: one unrecognised word and the answer is no.
* Quoted text is data and is looked at after it has been set aside (maskQuotes): Get-ChildItem "C:\Users\x\Q&A" only lists.
* @param {string} command
* @returns {boolean}
*/
@@ -77,8 +106,9 @@ export function isReadOnlyShell(command) {
const text = String(command ?? '').trim();
if (!text || text.length > 600) return false;
if (SAFE_VERSION.test(text)) return true;
if (UNSAFE_SYNTAX.test(text)) return false;
const segments = text.split('|').map(s => s.trim());
const masked = maskQuotes(text);
if (masked === null || UNSAFE_SYNTAX.test(masked)) return false;
const segments = masked.split('|').map(s => s.trim());
return segments.every((segment) => {
if (!segment) return false;
const words = segment.split(/\s+/);
@@ -219,8 +249,13 @@ export function apply(ctx, config = {}) {
ctx.on('tools/pre-execute', async (exec, next) => {
const agent = exec.agent;
if (!agent || exec.name === 'run_code') return next();
// Fail closed (T4): a session whose preset cannot be read (a reset in progress, a helper whose chat is gone) is treated as Manual, so
// anything this file cannot prove is read-only still asks. It used to run unasked.
let preset;
try { preset = effectivePreset(agent.session); } catch { return next(); }
try { preset = effectivePreset(agent.session); } catch (e) {
preset = MANUAL_PRESET;
try { console.warn(`[strata-gate] could not read the permission mode for this call (${e?.message ?? e}); treating it as Manual`); } catch { /* logging must never decide a permission */ }
}
// A helper that was set to ask while the chat was in Manual must stop asking the moment the chat leaves Manual,
// so a mode change applies to the very next tool call of a running goal and its helpers.
if (preset !== MANUAL_PRESET && agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) === 'ask') {
+2 -2
View File
@@ -42,8 +42,8 @@ export const AGENT_PROMPT = [
'You are a helpful assistant that can also work on this computer through your tools. You are running as {{model}}.',
'Answer questions directly and briefly; use a tool only when the task needs one. The person reads your replies in a chat: use plain words, not tool names or technical jargon.',
'Look before you change: read a file before you edit or overwrite it, and prefer a small edit to a rewrite. Search with the glob and grep tools and read files with the read tool; use the shell for running programs.',
'When you start a program or open a folder or file for the person, the command returning without an error means it worked: say plainly that it is open. Do not say you could not confirm it. If a command fails, say what went wrong in one sentence and what you will try next.',
'To open a page or a file for the person (a browser, an app, a folder), start it so the command returns at once and never wait for the program to close: on Windows use Start-Process, on macOS open, on Linux xdg-open. Then say plainly that it is open.',
'A command that starts a program, folder or page only tells you that it started. Never say a window opened, a page rendered or that you checked how it looks unless a tool confirmed it. If a command fails, say what went wrong in one sentence and what you will try next.',
'To show the person an image, SVG, HTML page or PDF from their folders, use the present tool: the app shows it next to the chat. A browser cannot open file:// addresses. If you do start a program, start it so the command returns at once and never wait for it to close: Start-Process on Windows, open on macOS, xdg-open on Linux.',
'When the person asks you to ask a subagent, a helper or another model ("ask a subagent", "ask model X"), use the subagent tool and name the model in its model field. Never look for a model with shell commands, and never wait for a helper with sleep: end your turn; its answer arrives as a new message.',
'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');
+42 -6
View File
@@ -20,6 +20,7 @@
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { standardPreset } from './preset.mjs';
import { validMcpName } from '../mcp-names.mjs';
import { BUDGETS, isPrivateHost } from './llm-shape.mjs';
import { DDG_PROVIDER_ID } from '../web-search-ddg.mjs';
@@ -55,6 +56,8 @@ export const modeOf = value => (Object.hasOwn(MODE_PRESETS, value) ? value : DEF
/** The approval gate plugin (plain ESM, loaded by absolute path). */
export const GATE_PLUGIN = fileURLToPath(new URL('./plugins/strata-gate.mjs', import.meta.url));
/** The app's web search (DuckDuckGo, or the person's own Exa / Perplexity key), registered with the Harness's web seam. */
/** Compaction that measures the real prompt and summarises in pieces that fit the window (plugins/strata-context.mjs). */
export const CONTEXT_PLUGIN = fileURLToPath(new URL('./plugins/strata-context.mjs', import.meta.url));
export const MODELS_PLUGIN = fileURLToPath(new URL('./plugins/strata-models.mjs', import.meta.url));
export const SEARCH_PLUGIN = fileURLToPath(new URL('./plugins/strata-search.mjs', import.meta.url));
/** The app's own tool server for the tools a person writes (instructions, commands). */
@@ -71,11 +74,17 @@ const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'b
* `mcp__<server>__<tool>`. A command or URL goes into the patch as text; a secret (an env value or a header) never does: the row
* carries `!!js process.env.<NAME>` and the value travels in the agent process's environment (returned in `env`).
*/
export function mcpRows(servers, env) {
export function mcpRows(servers, env, skipped = []) {
const rows = [];
const ids = new Set();
let index = 0;
for (const [name, server] of Object.entries(servers ?? {})) {
if (!server || server.disabled === true || !/^[A-Za-z0-9_-]{1,32}$/.test(name)) continue;
if (!server || server.disabled === true) continue; // switched off on purpose: not an error
// A server that cannot be wired is REPORTED (T6): the model never sees its tools, and a silent drop looked like "the tool does not work".
if (!validMcpName(name)) { skipped.push({ name, why: 'name' }); continue; }
if (!server.command && !server.url) { skipped.push({ name, why: 'address' }); continue; }
if (ids.has(`mcp-${slug(name)}`)) { skipped.push({ name, why: 'duplicate' }); continue; }
ids.add(`mcp-${slug(name)}`);
const token = (kind, key, value) => {
const variable = `STRATA_MCP_${index}_${kind}_${slug(key).replace(/-/g, '_').toUpperCase()}`;
env[variable] = String(value);
@@ -91,6 +100,24 @@ export function mcpRows(servers, env) {
return rows;
}
/**
* What the chat says when a tool server was left out (one notice per reason). The sentences are interface copy: they are in every language's
* catalog (src/locales/tools-<lang>.json), and the {0} is the list of names exactly as the person wrote them.
*/
const SKIPPED_TEXT = {
name: 'These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.',
address: 'These tool servers were left out because they have no program or web address: {0}.',
duplicate: 'These tool servers were left out because another one has almost the same name: {0}.',
};
export function mcpSkippedNotices(skipped) {
const out = [];
for (const why of Object.keys(SKIPPED_TEXT)) {
const names = (skipped ?? []).filter(s => s.why === why).map(s => String(s.name).slice(0, 40));
if (names.length) out.push({ code: `mcp-skipped-${why}-${names.join(',')}`.slice(0, 160), message: SKIPPED_TEXT[why].replace('{0}', names.join(', ')) });
}
return out;
}
const slug = value => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'x';
/** Identity of one route's wire configuration, secret included by hash only. */
@@ -166,7 +193,7 @@ export function outputBudget(context, maxOutput = null) {
/** What a local model, or an OpenAI-style server on this network, takes for the thinking level (see the comment where a local route is built). */
const THINKING_COMPAT = { supportsStore: false, thinkingFormat: 'chat-template', chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' }, reasoning_effort: { $var: 'thinking.effort', omitWhenOff: true } } };
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null }) {
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null, defaultLocalId = null }) {
const providers = {};
const env = {};
const routes = [];
@@ -202,6 +229,9 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
api: 'openai-completions',
baseURL: inst.baseUrl,
apiKeyEnv: keyEnv,
// A long prompt read sends only SSE comments, which pi-ai's OpenAI client drops, so its 300 s stream-idle timer would cut the
// request five minutes in (#3623). 60 minutes is a backstop: serve ends a hung engine and the bridge's watchdog ends a lost turn.
streamIdleTimeoutMs: 60 * 60_000,
// The Strata server reads the thinking level from `chat_template_kwargs` (enable_thinking, reasoning_effort) or from a top-level
// reasoning_effort; a top-level `enable_thinking` (pi-ai's `qwen` format) is ignored, so the model went on thinking with the menu on Off.
compat: THINKING_COMPAT,
@@ -257,9 +287,13 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
for (const m of conn.models) routes.push({ route, fp, kind: 'api', connectionId: conn.id, connectionName: conn.name, modelId: m.id, name: m.name || m.id, contextWindow: m.contextMax ?? ASSUMED_API_CONTEXT, ...(shaped ? { shaped } : {}) });
});
const defaultRoute = routes.find(r => r.kind === 'local') ?? null;
// The model the chat really runs on is the default instance, not whichever one was loaded first (T5: with two models loaded, the tool servers of the
// first one were wired to a chat on the second).
const defaultInstance = local.find(i => i.id === defaultLocalId) ?? local[0] ?? null;
const defaultRoute = routes.find(r => r.kind === 'local' && r.instanceId === defaultInstance?.id) ?? routes.find(r => r.kind === 'local') ?? null;
// One Harness, one set of tool servers: the default local model's.
const mcp = mcpRows({ ...(local[0]?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv);
const mcpSkipped = [];
const mcp = mcpRows({ ...(defaultInstance?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv, mcpSkipped);
Object.assign(env, mcpEnv);
// The person's own instruction and command tools come from the app's tool server; a command tool asks in Manual like any connected tool.
const own = (kind, on) => on && tools.toolFile ? [{ id: `mcp-strata-${kind}`, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: `strata-${kind}`, transport: 'stdio', command: process.execPath, args: [CUSTOM_TOOLS_SCRIPT, kind, tools.toolFile] } }] : [];
@@ -286,6 +320,8 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
// Manual asks before a change runs, so the person sees one Approve/Deny card and nothing has happened yet.
// Its web question names the search service the person chose (a keyed one only when its key is saved), and the person's switch skips the question.
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN, config: { web: { provider: SEARCH_NAMES[keyed ?? 'builtin'], skipAsk: tools.webWithoutAsking === true } } }] }] : []),
// Compaction decides from what the model server measured, and its summary requests always fit the window (plugins/strata-context.mjs).
{ insert: [{ id: 'strata-context', name: CONTEXT_PLUGIN, config: {} }] },
// Helper agents on a model the person names: every model the app can reach is allowed, and the plugin makes the choice forgiving (plugins/strata-models.mjs).
...(subagentRoutes.length ? [{ id: 'subagent-model-selection-settings', config: { enabled: true, allowedModels: subagentRoutes.map(r => ({ provider: r.provider, model: r.model })) } }] : []),
...(subagentRoutes.length ? [{ insert: [{ id: 'strata-models', name: MODELS_PLUGIN, config: { routes: subagentRoutes } }] }] : []),
@@ -309,7 +345,7 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
// What the agent is offered (tool groups, web search, tool servers, the person's own tools) decides whether a running agent must be
// restarted for the next message; the routes are compared separately (a changed context size alone does not restart it).
const toolsSignature = createHash('sha256').update(JSON.stringify([mcp, ownRows, webRows, entries.find(e => e.id === 'preset-standard'), env.STRATA_SEARCH_KEY ?? '', mcpEnv])).digest('hex').slice(0, 16);
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets };
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets, mcpSkipped };
}
/** Find the route for a chat's model choice. */
+14 -2
View File
@@ -13,6 +13,7 @@
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { GROUP_IDS, TOOL_GROUPS } from './tools-catalog.mjs';
import { SHELLS, clampTimeoutSec } from './custom-command-run.mjs';
export const SEARCH_PROVIDERS = ['builtin', 'exa', 'perplexity', 'deepseek'];
const KINDS = ['instructions', 'command', 'server'];
@@ -38,6 +39,8 @@ function normalize(raw) {
id: String(t.id), name: String(t.name), about: clean(t.about, 300), kind: t.kind, enabled: t.enabled !== false,
instructions: clean(t.instructions, 20000), command: clean(t.command, 2000), address: clean(t.address, 2000),
args: (Array.isArray(t.args) ? t.args : []).slice(0, 50).map(a => clean(a, 1000)),
// a command tool's folder, shell and time allowed (custom-command-run.mjs); '' / 'auto' / 120 when not set
cwd: clean(t.cwd, 1000).trim(), shell: SHELLS.includes(t.shell) ? t.shell : 'auto', timeoutSec: clampTimeoutSec(t.timeoutSec),
})),
};
}
@@ -49,7 +52,7 @@ function validateCustom(body, existing) {
if (!about) throw new Error('Say what the tool does, in a sentence. The assistant reads this to decide when to use it.');
const kind = body?.kind;
if (!KINDS.includes(kind)) throw new Error('Choose how the tool works.');
const out = { name, about, kind, instructions: '', command: '', address: '', args: [] };
const out = { name, about, kind, instructions: '', command: '', address: '', args: [], cwd: '', shell: 'auto', timeoutSec: clampTimeoutSec(undefined) };
if (kind === 'instructions') {
out.instructions = clean(body.instructions, 20000).trim();
if (!out.instructions) throw new Error('Write the instructions the assistant should follow when it uses this tool.');
@@ -57,6 +60,14 @@ function validateCustom(body, existing) {
out.command = clean(body.command, 2000).trim();
if (!out.command) throw new Error('Enter the command to run.');
if (/[\r\n]/.test(out.command)) throw new Error('Put the command on one line.');
// The edit form of Settings does not send these three: an edit keeps what the tool had.
const before = existing.find(t => t.id === body.id && t.kind === 'command');
out.cwd = body.cwd !== undefined ? clean(body.cwd, 1000).trim() : before?.cwd ?? '';
if (/[\r\n]/.test(out.cwd)) throw new Error('Put the folder on one line.');
if (body.shell !== undefined && !SHELLS.includes(body.shell)) throw new Error('Choose how the command runs: automatic, cmd, PowerShell, bash or sh.');
out.shell = body.shell !== undefined ? body.shell : before?.shell ?? 'auto';
if (body.timeoutSec !== undefined && !(Number(body.timeoutSec) >= 1 && Number(body.timeoutSec) <= 3600)) throw new Error('Give the time allowed in seconds, from 1 to 3600.');
out.timeoutSec = body.timeoutSec !== undefined ? clampTimeoutSec(body.timeoutSec) : before?.timeoutSec ?? clampTimeoutSec(undefined);
} else {
out.address = clean(body.address, 2000).trim();
if (!out.address) throw new Error('Enter the tool server’s web address, or the program that starts it.');
@@ -91,7 +102,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
for (const t of s.custom.filter(t => t.enabled)) {
const key = t.id;
if (t.kind === 'instructions') instructions[key] = { description: t.about, text: t.instructions };
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command };
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec };
}
mkdirSync(join(dataDir, 'agent'), { recursive: true });
const tmp = `${filePath}.tmp`;
@@ -115,6 +126,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
id: t.id, name: t.name, about: t.about, kind: t.kind, enabled: t.enabled,
summary: t.kind === 'instructions' ? t.instructions.slice(0, 80) : t.kind === 'command' ? t.command : t.address,
instructions: t.instructions, command: t.command, address: t.address, args: t.args,
...(t.kind === 'command' ? { cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec } : {}),
})),
};
}
+17 -1
View File
@@ -67,13 +67,29 @@ export function nvidiaDriverVersion() {
/** Where the builds are published; the wrong-make sentence points here. */
export const RELEASES_URL = 'https://github.com/Niko1221/Strata/releases';
/** The PCI vendor ids (`10de`, `1002`, `8086`) of the Windows display adapters; empty off Windows or when the registry cannot be read. */
export function windowsDisplayVendors({ platform = process.platform, run = execFileSync } = {}) {
const found = new Set();
if (platform !== 'win32') return found;
try {
const out = run('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}', '/s', '/v', 'MatchingDeviceId'], { encoding: 'utf8', timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
for (const match of String(out).matchAll(/VEN_([0-9A-F]{4})/gi)) found.add(match[1].toLowerCase());
} catch { /* no registry access: the tools decide */ }
return found;
}
/**
* The engine family this PC's graphics card needs ('cuda', 'hip', 'sycl'), or null when it cannot be told. The same order as Control
* (NVIDIA first, then AMD, then Intel); STRATA_ENGINE_BACKEND wins. Reads only the driver and (Linux) the PCI vendor of the display adapters.
*/
export function gpuBackendHere(env = process.env) {
export function gpuBackendHere(env = process.env, { vendors = windowsDisplayVendors() } = {}) {
const forced = String(env.STRATA_ENGINE_BACKEND ?? '').trim().toLowerCase();
if (forced) return ['cuda', 'hip', 'sycl'].includes(forced) ? forced : null;
// Windows: the display adapters are the machine's own statement of which cards it has; a leftover nvidia-smi from a card that was
// taken out must not outvote them (#3688). Same order as Control: NVIDIA, then AMD, then Intel.
if (vendors.size) {
for (const [vendor, backend] of [['10de', 'cuda'], ['1002', 'hip'], ['8086', 'sycl']]) if (vendors.has(vendor)) return backend;
}
if (nvidiaDriverVersion()) return 'cuda';
try {
const base = '/sys/class/drm';
+16
View File
@@ -244,6 +244,22 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.ok) return result.json;
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.
*/
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 });
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'];
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' });
},
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);
+22
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node
import { engineProgressMark } from './agent/engine-progress.mjs';
import './system-ca.mjs'; // #3722: the operating system's certificates, before any HTTPS request
import { createCloudflare } from './cloudflare.mjs';
import { createConnectorInstaller } from './cloudflared-install.mjs';
import { findRunningConnector } from './cloudflared-find.mjs';
@@ -15,6 +17,7 @@ import { materializeSkills, parseSkillFile, skillSlug, writeCreatedSkill } from
import { systemFonts } from './fonts.mjs';
import { tuningDir } from './platform-paths.mjs';
import { createAgentBridge, plainTurnFailure } from './agent/agent-bridge.mjs';
import { mcpSkippedNotices } from './agent/profile.mjs';
import { createAgentTools } from './agent/tools-settings.mjs';
import { withPlainStatus } from './agent/status.mjs';
import { findBrowser, findPlaywrightMcp } from './agent/browser-probe.mjs';
@@ -55,6 +58,7 @@ import { RequestQueue } from './request-queue.mjs';
import { selectSkills, skillInstructions } from './skill-selection.mjs';
import { createModelProviders } from './model-providers.mjs';
import { createForkTimeskip } from './timeskip-fork.mjs';
import { prepareOversized } from './oversize.mjs';
import { createChatFolders, chatRootOf, openFile, resolveChatFile, previewFile, rawFile, createAttachments, withAttachments } from './chat-files.mjs';
import { createLlmProxy, samplingFromExpert } from './agent/llm-shape.mjs';
import { checkUpdates, checkComponentUpdates } from './updates.mjs';
@@ -174,12 +178,15 @@ const agent = createAgentBridge({
// The person's Response settings (thinking budget, Expert sampling) reach the agent's model through this host's own forwarding endpoint.
proxyBase: `http://127.0.0.1:${PORT}/api/internal/agent-llm`,
expertSampling: () => samplingFromExpert(store.get().expert),
// How far a local engine has got (the 2 s /metrics poll), for the agent's stall watchdog: it moves while a long prompt is read.
engineProgress: instanceId => engineProgressMark(native.readyInstances().find(i => i.id === instanceId)),
onPlan: (chatId, event, plan) => hub.send('plan', { chatId, event, plan }),
onQueue: (chatId, queue) => hub.send('queue', { chatId, queue }),
dataDir: store.DATA_DIR,
componentsDir: componentsDirectory(),
// The installer ships the Harness next to the host (<install>/host/server -> <install>/harness).
bundledDir: resolve(HERE, '..', '..', 'harness'),
defaultLocalId: () => native.defaultInstanceId(),
localInstances: () => native.readyInstances().map(i => ({
id: i.id, label: i.label, baseUrl: `http://127.0.0.1:${i.port}/v1`, modelId: i.apiModelId,
context: i.metrics?.engine?.max_context ?? i.context ?? undefined, key: i.key, vision: i.vision,
@@ -601,8 +608,11 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
const goalLine = /^\/goal(?:\s+([\s\S]*))?$/i.exec(message.trim());
const planLine = /^\/plan(?:\s+([\s\S]*))?$/i.exec(message.trim());
const researchLine = /^\/research(?:\s+([\s\S]*))?$/i.exec(message.trim());
// "/compact" summarises the older messages of this chat now (it used to go to the model as text, which answered it as a question).
const compactLine = /^\/compact\s*$/i.test(message.trim());
const turnOptions = { chatId, message, workspacePath: cwd, choice, images, mode: typeof body.mode === 'string' ? body.mode : null, thinking: typeof body.thinking === 'string' ? body.thinking : null, thinkingBudget: Number.isFinite(body.thinkingBudget) ? body.thinkingBudget : 0, history: Array.isArray(body.history) ? body.history : [], onFrame: write, signal: controller.signal };
const result = typeof body.planId === 'string' && body.message === '/plan start' ? await agent.planGoal({ ...turnOptions, planId: body.planId })
: compactLine ? await agent.compactCommand(turnOptions)
: researchLine ? await agent.researchCommand({ ...turnOptions, line: researchLine[1] ?? '' })
: planLine ? await agent.planCommand({ ...turnOptions, line: planLine[1] ?? '' })
: goalLine ? await agent.goalCommand({ ...turnOptions, line: goalLine[1] ?? '' }) : await agent.runTurn(turnOptions);
@@ -1717,6 +1727,16 @@ async function api(req, res, path, method, url) {
try { const f = rawFile(chatRoot(url.searchParams.get('sessionId') ?? ''), url.searchParams.get('path')); res.writeHead(200, { 'content-type': f.mime, 'content-length': f.bytes.length, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(f.bytes); return undefined; }
catch (e) { return error(res, e.code === 'outside' ? 403 : 400, e.message); }
}
/* A message too large for the model's window, made small enough to send: its text as a file in the chat's folder, or a shortened preview (oversize.mjs). */
if (path === '/api/chat/oversize' && method === 'POST') {
const body = await readBody(req, 12 * 1024 * 1024);
const text = String(body.text ?? '');
if (!text.trim()) return error(res, 400, 'text is required');
let instance = null;
try { instance = native.resolveInstance(body.nativeModelId ?? native.state().defaultId); } catch { /* no local model: the length alone decides */ }
try { return json(res, 200, await prepareOversized({ mode: body.mode === 'file' ? 'file' : 'preview', text, root: chatRoot(String(body.sessionId ?? '')), instance })); }
catch (e) { return error(res, 400, 'The message could not be prepared. Try a shorter message.'); }
}
/* "Attach as context": the file is named in the next message of the chat, so the assistant reads it again. */
if (path === '/api/chat/attachments' && method === 'GET') return json(res, 200, { files: chatAttachments.list(url.searchParams.get('sessionId') ?? '') });
if (path === '/api/chat/attachments' && method === 'POST') {
@@ -1934,6 +1954,8 @@ async function api(req, res, path, method, url) {
if (chatId && !agentSkip) {
const capability = agent.status();
if (capability.state !== 'unavailable') {
const skipped = mcpSkippedNotices(capability.skippedServers);
for (const notice of skipped) res.write(`${JSON.stringify({ type: 'notice', ...notice })}\n`);
await runAgentTurn({ res, controller, chatId, body, message, workspaceId, images });
res.end();
return undefined;
+10
View File
@@ -0,0 +1,10 @@
// The one rule for the name of a tool server (MCP), shared by the Settings editor (src/lib/mcp-names.ts keeps a copy; tests/mcp-rows.test.mjs
// checks they agree), the save check (native-settings.mjs) and the agent profile (agent/profile.mjs).
//
// The model sees every tool as `mcp__<server>__<tool>`, and an OpenAI-style API takes at most 64 characters for that name, so the server's
// part is limited to 32 (T6, tools and images review 2026-10-22). Before, the editor and the save check accepted 64 and the profile quietly
// dropped everything over 32 (or with a dot): the model never saw the tool and nothing said why.
export const MCP_NAME_MAX = 32;
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
export const validMcpName = name => typeof name === 'string' && MCP_NAME_RE.test(name);
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
+30
View File
@@ -0,0 +1,30 @@
/**
* Which files of a model are missing, in words a person can act on (#3478). Control's model list says which required parts of the run
* config are absent (`missing`: part + file name), and the installer's status says which GGUF files and shards it could not find
* (`missing_files`, full paths). Together they give the names; the app shows them instead of only "some files are missing".
*/
import { basename } from 'node:path';
const PART_WORDS = { engine: 'the engine program', 'tokenizer/vocab.json': 'the tokenizer', 'tokenizer/merges.txt': 'the tokenizer', 'tokenizer/token_type.json': 'the tokenizer' };
/** Short readable names, no duplicates, most useful first. */
export function missingNames(controlParts, installerFiles) {
const names = [];
const add = text => { const t = String(text ?? '').trim(); if (t && !names.includes(t)) names.push(t); };
for (const file of Array.isArray(installerFiles) ? installerFiles : []) add(basename(String(file).split(String.fromCharCode(92)).join('/')));
for (const item of Array.isArray(controlParts) ? controlParts : []) {
add(PART_WORDS[item?.part] ?? item?.file ?? item?.part);
}
return names;
}
/** The error a load or a tune raises for a model that is not whole. Names are in the sentence (at most four) and all of them in `details`. */
export function missingFilesError(model, names) {
const list = names ?? [];
const shown = list.slice(0, 4).join(', ') + (list.length > 4 ? ` and ${list.length - 4} more` : '');
const error = new Error(list.length
? `Some model files are missing: ${shown}. Use Download / repair to restore them.`
: 'Some model files are missing. Use Download / repair to restore them.');
error.details = [list.length ? `Missing for ${model}: ${list.join(', ')}` : `Missing for ${model}: Strata could not tell which files`];
return error;
}
+38 -10
View File
@@ -1,4 +1,5 @@
/** Native Strata control plane. All user actions are explicit; opening the UI only reads state. */
import {missingFilesError,missingNames} from './missing-files.mjs';
import { spawn } from 'node:child_process';
import {chooseParallel,applyParallel,withoutParallel,FALLBACK_NOTE} from './native-parallel.mjs';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
@@ -54,7 +55,10 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
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;}}
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}};}
/* 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 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)}));}
@@ -73,7 +77,7 @@ 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,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,/* 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 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,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'};})};}
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');}
@@ -106,8 +110,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
packagedInstall=list.install_dir;
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}]));
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),images:'off',port:undefined,last_used:null}));
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 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},
@@ -125,6 +129,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
/* A read that is not forced never makes the window wait for the (slow) status of Control: it gets the last state at once, and a stale one
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
if(refreshing)return !force&&snapshot.available?state():refreshing;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;
}
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);}
@@ -139,7 +144,10 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
function settings(model){const {config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true};}
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);return tool('strata_install',installArgs(args,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 */}}
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};}
@@ -176,7 +184,7 @@ 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});}
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}}:{})});}
return finalResult;
}
}
@@ -218,7 +226,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(mine.length){cfg.args=mergeUserArgs(cfg.args??[],mine);cfg[USER_KEY]=mine;}else delete cfg[USER_KEY];
if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
/* The model's own choices win over what setup wrote into the arguments: "Draft layer (MTP)" off leaves --mtp out of every start, and a picture reading that is off leaves --vision out (setup writes both again whenever it runs; the saved settings are applied here, on every load). */
if(cfg.mtp===false&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if(!cfg.vision&&Array.isArray(cfg.args))cfg.args=cfg.args.filter(a=>a!=='--vision');
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. */
@@ -229,12 +237,32 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(parallelChoice){const applied=applyParallel(cfg,parallelChoice);for(const k of Object.keys(cfg))if(!(k in applied.cfg))delete cfg[k];Object.assign(cfg,applied.cfg);parallelNote=applied.note;}
return {cfg,resources,visionPreferences,parallelChoice,parallelNote};
}
/* 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){
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;
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();}}});
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();}}
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 loadEngine(job,args){
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready)throw new Error('Some model files are missing. Use Download / repair to restore them.');
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);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}=buildConfig(args,config);
let parallelNote=chosenNote;
await prepareDrafter(job,args,cfg,configFile);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};
@@ -289,7 +317,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}
const deadline=Date.now()+300000;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';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{}
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{}
await new Promise(resolve=>setTimeout(resolve,1200));
}
if(job.state!=='running'){await unload(id);return;}
@@ -482,5 +510,5 @@ 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'),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,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};
}
+9 -4
View File
@@ -28,12 +28,16 @@ export function defaultReserves(config,hardware,baselineReserve=0){
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
const placed=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)),gpuMemory=placed.map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
const headless=placed.length>0&&placed.every(computeOnly); // no display on the card (the desktop is on an integrated GPU): no desktop to keep room for
const floor=headless?(config.vision?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=headless?floor:Math.max(config.vision?2048:floor,baselineReserve);
/* Only a picture reader that runs on the GPU needs VRAM of its own: the CPU reader (vision.gpu false) took the same 2048 floor and cost a 12 GB card a quarter of its expert cache (0.2.3 tuning, notes-023-tune.md). */
const pictures=config.vision?(typeof config.vision==='object'?Boolean(config.vision.gpu):true):false;
const floor=headless?(pictures?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=headless?floor:Math.max(pictures?2048:floor,baselineReserve);
/* A card that shares the PC's memory (Strix Halo, other APUs) has no memory of its own to keep a fifth of: that fifth is RAM, and "Leave 20% for my work" already keeps it
* through the RAM budget. A fifth of the shared pool here held back 16 GB of expert cache for nothing (#3510); the display floor stays. */
const shared=placed.length>0&&placed.some(g=>g.uma===true||g.unified===true);
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety,performance:safety};
/* "work" keeps a fifth of the card, but on a card of 16 GB or less no more than 1536 MiB: a fifth of 12 GB (2457) cost 20% of the speed against 1536, which already leaves 1.1 GiB of VRAM free after the load (RTX 5070, Gyro-S 64K, 5 pairs; notes-023-tune.md). */
const fifth=gpuMemory.length?Math.floor(Math.min(...gpuMemory)*1024*.2):0,cap=gpuMemory.length&&Math.min(...gpuMemory)<=16?1536:Infinity;
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.min(fifth,cap)):safety,performance:safety};
}
/** The reserve the person asked for (null: nothing) and the default for each profile. A 0.2.0-beta file has the key and the same number in "args" with no note that the
@@ -63,7 +67,8 @@ export function resourceSettings(config,hardware,profile,model={},values={}){
const cores=Number(hardware?.cpu?.cores)||0;
if(profile==='work'&&cores>1){
const at=args.indexOf('--pool-workers'),previous=at>=0?Number(args[at+1]):0;
const workers=Math.max(1,Math.floor(cores*.8));remove('--pool-workers');
/* 80% of the physical cores rounded UP, never more than the cores less the host thread (the engine's own count): floor(0.8 x 6) = 4 ran 8-10% slower than the engine's 5 on a 6-core Ryzen (notes-023-tune.md); 12 logical threads gave 9 in 0.2.1. */
const workers=Math.max(1,Math.min(cores-1,Math.ceil(cores*.8)));remove('--pool-workers');
args.push('--pool-workers',String(previous>0?Math.min(previous,workers):workers));
}
// Preserve calibrated worker counts and the installation's safety/vision reserve.
+6 -2
View File
@@ -1,3 +1,4 @@
import { validMcpName, MCP_NAME_HELP } from './mcp-names.mjs';
/** Editable native keys. Unknown config keys and program paths stay intact. */
export const NATIVE_FIELDS=[
{id:'reasoning_budget_tokens',label:'Thinking token budget',group:'Responses',type:'number',min:0,max:1048576,hint:'Leave blank for the model default.'},
@@ -8,6 +9,7 @@ export const NATIVE_FIELDS=[
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
{id:'mtp',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:'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},
@@ -24,7 +26,7 @@ export const NATIVE_FIELDS=[
{id:'mcp_max_result_chars',label:'Maximum tool result characters',group:'Tools',type:'number',min:100,max:1000000},
];
/** 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'];
export const APP_OWNED_FIELDS=['mtp','batchMtp','drafting'];
/** 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);
@@ -56,7 +58,9 @@ export function publicMcpServers(servers){return Object.fromEntries(Object.entri
export function validateMcpServers(servers,previous={}){
if(!servers||typeof servers!=='object'||Array.isArray(servers)||Object.keys(servers).length>30)throw new Error('MCP servers must be an object with at most 30 entries.');
const out={};for(const [name,s] of Object.entries(servers)){
if(!/^[a-z0-9_-]{1,64}$/i.test(name)||!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
if(!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
// a name that was saved before the 32-character rule stays saveable (unrelated settings must not be blocked); the agent reports it as left out
if(!validMcpName(name)&&!Object.hasOwn(previous??{},name))throw new Error(MCP_NAME_HELP);
if(!!s.command===!!s.url)throw new Error(`${name}: choose a command or HTTP URL.`);
for(const key of Object.keys(s))if(!['command','args','env','cwd','url','headers','disabled'].includes(key))throw new Error(`${name}: unsupported property ${key}.`);
if(s.command&&(typeof s.command!=='string'||s.command.length>1000||/[\n\r\0]/.test(s.command)))throw new Error(`${name}: choose a valid program.`);
+30
View File
@@ -0,0 +1,30 @@
/**
* A message too large for the model's window, made small enough to send (agent/context-fit.mjs decides that it is too large).
* The person picks how, on the error card:
* file the whole text is saved in the chat's folder and the message names the file; the assistant reads it in parts
* preview the start and the end of the text, cut to what the window can hold
* Nothing is sent from here: the page sends the returned message as the person's next message.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { countTokens, oversizedReplacement } from './agent/context-fit.mjs';
const stamp = d => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
/**
* @param {{mode:'file'|'preview', text:string, root:string|null, instance?:{port:number, key?:string, apiModelId?:string, context?:number, metrics?:any}|null, now?:Date, fetchImpl?:typeof fetch}} input
* @returns {Promise<{message:string, mode:string, tokens:number|null, kept:number, path?:string}>}
*/
export async function prepareOversized({ mode, text, root, instance = null, now = new Date(), fetchImpl = fetch }) {
const window = instance?.metrics?.engine?.max_context ?? instance?.context ?? 32768;
const tokens = instance ? await countTokens({ baseUrl: `http://127.0.0.1:${instance.port}/v1`, key: instance.key ?? '', model: instance.apiModelId ?? '', text, fetchImpl }) : null;
const counted = tokens ?? Math.ceil(text.length / 3); // not countable: three characters a token is on the safe side for code and logs
if (mode === 'file') {
if (!root) throw new Error('This chat has no folder yet. Choose one first.');
mkdirSync(root, { recursive: true });
const path = join(root, `pasted-message-${stamp(now)}.txt`);
writeFileSync(path, text, { encoding: 'utf8', flag: 'wx' });
return { mode, tokens, path, ...oversizedReplacement({ text, mode, tokens: counted, window, path }) };
}
return { mode: 'preview', tokens, ...oversizedReplacement({ text, mode: 'preview', tokens: counted, window }) };
}
+14 -3
View File
@@ -88,10 +88,21 @@ export let MODELS_DIR = MODELS_PICK.directory;
/** Native setup writes weights to data_dir/models. Keep its data root one level up. */
export function nativeDataDirectory(input) {
if(!input||process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY))return input;
if(!input||!existsSync(input))return input;
if(MODELS_DIR_NOTICE)return input;
const root=realpathSync(DEFAULT_MODELS_DIRECTORY);
if(resolve(input)!==resolve(DEFAULT_MODELS_DIRECTORY)&&realpathSync(input)!==root)return input;
const root=realpathSync(input),legacy=realpathSync(join(HOME,'models'));
// Retained legacy libraries are model folders too, not native data roots.
// A named data root satisfies the installer's guard; its alias leaves weights in place.
if(root===legacy){
const data=join(HOME,'Strata-data'),nativeModels=join(data,'models');
mkdirSync(data,{recursive:true});
// Unlike a fresh default, a retained library must not fall back to an empty folder if linking fails.
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:relative(data,root),nativeModels,process.platform==='win32'?'junction':'dir');
const a=statSync(nativeModels),b=statSync(root);
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The model folder conflicts with another library. Choose a different folder.');
return data;
}
if(process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY)||root!==realpathSync(DEFAULT_MODELS_DIRECTORY))return input;
const data=dirname(root);
// `models` and `Models` are one folder on Windows and macOS; where they are not (Linux) a link makes them one, and where no link can be made (exFAT, a share) models go in `models`.
alignModels(data,root);
+24
View File
@@ -0,0 +1,24 @@
// The host's own HTTPS downloads (engine releases from GitHub, web search, a cloud model's API) check the server against the operating
// system's certificates as well as Node's bundled list (#3722). Node carries its own list of roots; Chrome and curl.exe use the Windows
// store, which also holds the roots an antivirus's HTTPS scanning, a company proxy or an OS preview build adds. Nothing is turned off:
// the system roots are ADDED to the bundled ones, and a connection that still fails keeps failing.
//
// Node 24.5 and newer: tls.getCACertificates('system') and tls.setDefaultCACertificates. An older Node (the app bundles 24.x) does nothing here;
// the agent process gets NODE_USE_SYSTEM_CA=1 (harness-runtime.mjs) for the same reason.
import tlsModule from 'node:tls';
/** @returns {'system'|'bundled'} what the process trusts after the call */
export function useSystemCertificates(tls = tlsModule, env = process.env) {
if (env.STRATA_NO_SYSTEM_CA === '1') return 'bundled'; // a way back for a PC where reading the store misbehaves; not a way to skip checks
try {
if (typeof tls.getCACertificates !== 'function' || typeof tls.setDefaultCACertificates !== 'function') return 'bundled';
const system = tls.getCACertificates('system');
if (!Array.isArray(system) || !system.length) return 'bundled';
tls.setDefaultCACertificates([...new Set([...tls.getCACertificates('bundled'), ...system])]);
return 'system';
} catch {
return 'bundled';
}
}
export const trust = useSystemCertificates();
+1 -1
View File
@@ -35,7 +35,7 @@ export function plainUpdateError(error, subject = 'engine') {
} else if (/another update is already running/i.test(raw)) {
message = 'An update is already running.';
next = `Wait a moment, then press ${button} in Settings.`;
} else if (/test start|did not answer a one-token|did not become ready/i.test(raw)) {
} else if (/test start|did not start correctly|did not answer a one-token|did not become ready/i.test(raw)) {
message = `The new ${subject} didn't start correctly, so nothing was changed. ${keep}`;
} else if (looksOffline(error) || looksOffline(raw)) {
message = 'No internet connection.';
+1
View File
@@ -67,6 +67,7 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
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 });
else if (engine.status === 'not-in-feed') Object.assign(engineView, { status: 'current', message: 'No engine update is listed right now.' });
else Object.assign(engineView, { status: 'unavailable', message: "There isn't an engine for this PC's graphics card yet.", details: (engine.reasons ?? []).join('\n') || undefined });
const control_ = report.control ?? {};
+25 -10
View File
@@ -5,8 +5,21 @@ import { Btn, IconBtn } from './ui';
import { SyntaxCode } from './SyntaxCode';
import { MathView } from './MathView';
import { displayBlock, hasMath, splitMath } from '../lib/math';
function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNode {
return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
import { imagePlan } from '../lib/markdown-images';
function inlineMarks(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
return text.split(/(!\[[^\]]*\]\([^\s)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
const image = part.match(/^!\[([^\]]*)\]\(([^\s)]+)\)$/);
if (image) {
const plan = imagePlan(image[2], imageSession);
if (plan.kind === 'img')
return <img key={i} src={plan.src} alt={image[1]} className="md-image" loading="lazy" referrerPolicy="no-referrer"/>;
if (plan.kind === 'link')
return <a key={i} href={plan.href} target="_blank" rel="noreferrer" onClick={e => { if (onOpenLink) {
e.preventDefault();
onOpenLink(plan.href);
} }}>{image[1] || plan.href}</a>;
return <Fragment key={i}>{image[1] || part}</Fragment>;
}
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="md-code">{part.slice(1, -1)}</code>;
if (part.startsWith('**') && part.endsWith('**'))
@@ -23,21 +36,23 @@ function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNod
});
}
/** Text with its inline marks and, outside code spans, its math (#3268). Math is cut out first: a * or _ inside a formula is not emphasis. */
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
function inline(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
if (!hasMath(text))
return inlineMarks(text, onOpenLink);
return inlineMarks(text, onOpenLink, imageSession);
return text.split(/(`[^`]+`)/g).map((chunk, c) => {
if (chunk.length > 1 && chunk.startsWith('`') && chunk.endsWith('`'))
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink)}</Fragment>;
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink, imageSession)}</Fragment>;
return <Fragment key={c}>{splitMath(chunk).map((part, p) => part.kind === 'math'
? <MathView key={p} tex={part.tex} display={part.display}/>
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink)}</Fragment>)}</Fragment>;
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink, imageSession)}</Fragment>)}</Fragment>;
});
}
export function Markdown({ text, onOpenLink, onPreviewHtml }: {
export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
text: string;
onOpenLink?: (url: string) => void;
onPreviewHtml?: (html: string) => void;
/** The chat whose folder a picture path in the answer is read from (the host serves only files inside it). */
imageSession?: string | null;
}) {
const nodes: ReactNode[] = [];
const lines = text.split('\n');
@@ -66,7 +81,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const Tag = heading[1].length <= 2 ? 'h2' : heading[1].length === 3 ? 'h3' : 'h4';
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink))}</Tag>);
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink, imageSession))}</Tag>);
i++;
continue;
}
@@ -90,7 +105,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
i += 2;
while (i < lines.length && lines[i].includes('|'))
rows.push(cells(lines[i++]));
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink))}</td>))}</tr>))}</tbody></table></div>);
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink, imageSession))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink, imageSession))}</td>))}</tr>))}</tbody></table></div>);
continue;
}
if (/^>\s?/.test(line)) {
@@ -98,7 +113,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
i++;
continue;
}
nodes.push(<p key={i}>{tr(inline(line, onOpenLink))}</p>);
nodes.push(<p key={i}>{tr(inline(line, onOpenLink, imageSession))}</p>);
i++;
}
return <>{tr(nodes)}</>;
+3 -2
View File
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useState } from 'react';
import { Btn, Dialog, ErrorNotice, IconBtn, Select } from './ui';
import { MCP_NAME_HELP, validMcpName } from '../lib/mcp-names';
export function NativeMcpEditor({ text, onChange }: {
text: string;
onChange: (text: string) => void;
@@ -19,8 +20,8 @@ export function NativeMcpEditor({ text, onChange }: {
function open(id: string) { const server = servers[id] ?? {}; setEditing(id); setName(id); setTransport(server.url ? 'url' : 'command'); setAddress(server.url ?? server.command ?? ''); setArgs((server.args ?? []).join('\n')); setAdvanced(JSON.stringify(Object.fromEntries(Object.entries(server).filter(([k]) => !['url', 'command', 'args', 'disabled'].includes(k))), null, 2)); setError(''); }
function save() {
try {
if (!/^[a-z0-9_-]{1,64}$/i.test(name))
throw new Error('Use a simple server name: letters, numbers, hyphens or underscores.');
if (!validMcpName(name))
throw new Error(MCP_NAME_HELP);
if (!address.trim())
throw new Error('Enter the server URL or program.');
if (name !== editing && servers[name])
+12 -4
View File
@@ -20,6 +20,7 @@ import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/sto
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { tuneEffective } from '../lib/native-tune';
import { tunedFields } from '../lib/tune-values';
import { draftingChoices as draftingOptions } from '../lib/drafting';
import { NativeSettings } from './NativeSettings';
import { NativeSettingsFile } from './NativeSettingsFile';
import { RunImageModel } from './RunImageModel';
@@ -41,6 +42,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [roles, setRoles] = useKept<Roles | null>('models.roles', null), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto');
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
const [drafting, setDrafting] = useState('auto');
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
const presetPending = useRef(false);
@@ -99,8 +101,11 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
// A layer split written for another number of cards no longer fits: back to automatic.
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
// Drafting (which drafter guesses ahead): DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families), so no other model is offered it.
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
const downloadValues = { ...values, ...(draftingChoices ? { drafting } : {}), vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
useEffect(() => { if (!native?.catalog || family)
return; const current = native.instances.find(i => i.id === native.defaultId); const rec = native.catalog.recommendation ?? { family: null, model: null }; const loadedFamily = families.find(f => f.sizes.some(s => s.id === current?.model)); const have = new Set((native.installed?.models ?? []).filter(m => m.ready).map(m => m.model)); const installedFamily = families.find(f => f.sizes.some(s => have.has(s.id))); const f = loadedFamily ?? installedFamily ?? families.find(f => f.family === rec.family || f.sizes.some(s => s.sourceFamily === rec.family)) ?? families[0]; if (f) {
setFamily(f.family);
@@ -117,7 +122,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
}
if (!selectedSize)
return;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
if (sig === appliedSig)
return;
setAppliedSig(sig);
@@ -128,6 +133,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setVision(installed.visionEnabled === false ? 'no' : installed.visionDevice ?? (installed.images && installed.images !== 'off' ? installed.images : 'yes'));
setKv(installed.kv ?? 'int8');
setLowRam(installed.lowRamMode ?? 'auto');
setDrafting(installed.drafting ?? 'auto');
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
setResourceProfile(installed.resourceProfile);
@@ -151,7 +157,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
} }
async function load() { if (!selectedSize)
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}), values: {} } });
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(draftingChoices ? { values: { drafting } } : {}), ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}) } });
setJobId(result.id);
}
else
@@ -179,8 +185,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
<div className="native-choice__row">
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144], context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => setContext(Number(n))}/></div>
{tr(draftingChoices ? <div className="native-choice__col"><span className="native-choice__label">{tr("Drafting")}</span><Select ariaLabel={tr("Drafting")} value={drafting} disabled={!!active} options={draftingChoices.map(v => ({ value: v, label: tr(draftingWords[v]) }))} onChange={v => setDrafting(String(v))}/></div> : null)}
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave 20% for my work")}</strong><small>{tr("Headroom for your other apps")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
</div>
{tr(loadedSelected?.metrics?.engine?.drafting_note ? <p className="small muted native-spill-note">{tr(String(loadedSelected.metrics.engine.drafting_note))}</p> : null)}
{roleCards.length > 1 ? <GpuRoles cards={roleCards} roles={effectiveRoles} custom={customRoles} recommendedRoles={recommended.roles} vendor={engineVendor} disabled={!!active} onChange={changeRoles} onBack={() => setRoles(null)} memoryGb={selectedSize ? memoryNeeded(selectedSize) || null : null} ramGb={pcRam} spillGb={selectedSize ? fitOn(selectedSize, fitHardware).spillGb : 0} usedBy={i => usedBy([i])}/> : null}
{tr(roleCards.length > 1 && (native?.hardware?.gpus ?? []).length > roleCards.length ? <p className="small muted native-cards__note">{tr('Not used: {0} (another make of graphics card than this engine drives).', [(native?.hardware?.gpus ?? []).filter(g => !roleCards.some(r => r.index === g.index && r.vendor === g.vendor)).map(g => g.name).join(', ')])}</p> : null)}
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
@@ -199,7 +207,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
</div>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(plan?.drafting?.needed ? <p className="small muted">{tr("Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.", [plan.drafting.gb])}</p> : null)}{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
<Dialog open={manage} title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
{tr(downloaded.length ? <div className="native-manage-list" role="list">{tr(downloaded.map(m => { const v = allSizes.find(x => x.id === m.model), loaded = native?.instances.some(i => i.model === m.model && i.status === 'ready'); return <div className="native-manage-row" role="listitem" key={m.model} data-selected={selectedSize?.id === m.model || undefined}>
@@ -6,6 +6,7 @@ import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpda
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { Btn, Dialog, ErrorNotice, Select } from './ui';
import { NativeProgress } from './NativeModels';
import { jobErrorText } from '../lib/native-job-diagnostics';
export function NativeBenchmark({ open, onClose }: {
open: boolean;
onClose: () => void;
@@ -114,7 +115,7 @@ export function NativeOperations() {
}
return <div className="stack stack--lg native-engine-settings">
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={j.error ?? j.summary} detail={j.details}/>))}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={jobErrorText(j, native.jobs)} detail={j.details}/>))}</section>
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
{tr(saved ? <p className="small" role="status">{tr("Saved to {0}", [saved])}</p> : null)}
+3 -2
View File
@@ -6,6 +6,7 @@ import { useStore } from '../lib/store';
import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
import { nativeAction, useNative } from '../lib/native';
import { NativeProgress } from './NativeModels';
import { jobErrorText } from '../lib/native-job-diagnostics';
import { appVersionLabel } from '../lib/app-version';
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
/** A check from an earlier day shows its date too, because the result is kept between visits. */
@@ -89,12 +90,12 @@ export function SettingsUpdates() {
<Btn variant="primary" disabled={component !== 'engine' || !native?.capabilities.rollback || !!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate('rollback')}>{tr("Restore previous engine")}</Btn>
</div>
</Dialog>
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(j.error ?? j.summary)}</p>))}
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(jobErrorText(j, native.jobs))}</p>))}
<Dialog open={!!confirm} compact title={tr(confirm === 'update-strata' ? 'Update Strata?' : 'Update engine?')} onClose={() => setConfirm('')} actions={<><Btn onClick={() => setConfirm('')}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate(confirm)}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p>{tr((native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</Dialog>
</>;
}
/** "Strata 0.2.2 · Engine 0.1.47": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.3 · Engine 0.1.48": the app's own version first, the engine's beside it (About). */
export function AboutVersions() {
const native = useNative();
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
+4 -2
View File
@@ -518,18 +518,20 @@ export function MetricGrid({ children, min = 200 }: {
</div>);
}
/** Shared, announced error with a cause and a concrete recovery action. */
export function ErrorNotice({ message, next, action, onRetry, detail }: {
export function ErrorNotice({ message, next, action, onRetry, detail, secondary }: {
message: string;
next?: string;
action?: string;
onRetry?: () => void;
detail?: string;
/** A second way out, beside the first (a context error offers two). */
secondary?: { label: string; onClick: () => void };
}) {
// A known failure is shown as a sentence and a next step; the original text stays under Details for experts.
const plain = plainFailure(message);
const known = plain.kind !== 'plain';
const nextStep = known ? (plain.next ? tr(plain.next) : next) : next;
const raw = detail ?? (known ? plain.detail : undefined);
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn> : null)}</div>;
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? (secondary ? <div className="row"><Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn><Btn size="sm" variant="ghost" onClick={secondary.onClick}>{tr(secondary.label)}</Btn></div> : <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn>) : null)}</div>;
}
export function PreviewBadge() { const demo = useStore(s => s.library?.mode === 'simulated'); return demo ? <span className="preview-badge">{tr("Preview build")}</span> : null; }
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.2), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.2 app.
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.3), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.3 app.
* Without a real app version (a source checkout reports 'dev') the other number is the best there is.
*/
export function appVersionLabel(app: string | null | undefined, control?: string | null): string | undefined {
+4 -1
View File
@@ -13,7 +13,10 @@ export function chatMetrics(session:Session|null, monitor:MonitorState|null, con
const finished=latest?(latest.contextUsed??latest.inputTokens!+(latest.tokens??0)):null;
const liveUsed=active&&Number.isFinite(monitor!.context.usedTokens)&&monitor!.context.usedTokens>0?monitor!.context.usedTokens:null;
const live=replies.some(m=>m.streaming)||(active&&['reading','thinking','generating'].includes(monitor!.phase));
const used=live?(liveUsed??finished):(finished??liveUsed);
// While a turn runs, its own figure comes first: the host reports what the context holds after each model request of the turn. The engine's live
// figure belongs to the request in flight (a small one between two tools read as 1%), and `finished` to an earlier reply.
const turnUsed=replies.find(m=>m.streaming&&Number.isFinite(m.contextUsed)&&m.contextUsed!>0)?.contextUsed??null;
const used=live?(turnUsed??liveUsed??finished):(finished??liveUsed);
const max=remote?(replies.at(-1)?.contextMax||null):configuredContext??(active?monitor!.context.maxTokens:latest?.contextMax??null);
const streaming=replies.find(m=>m.streaming);
// Answer speed: everything the model generated over its decode time. While a reply streams it is that reply's; after it, the LAST reply's own
+32
View File
@@ -0,0 +1,32 @@
import { tr } from './i18n';
import { sendOversized, startNewChat, summarizeAndContinue } from './store';
/**
* What the error card of a chat offers when the model's window is the problem. A conversation that no longer fits can be summarised and
* carried on, or left for a new chat; a single message that is too large can go as a file the assistant reads in parts, or as a shortened
* preview. "Try the message again" would only fail the same way, so the card never says it for these.
*/
export function contextRecovery(code: string | undefined): {
next: string;
action?: string;
onRetry?: () => void;
secondary?: { label: string; onClick: () => void };
} {
if (code === 'CONTEXT_MESSAGE_TOO_LARGE') {
return {
next: tr('Attach it as a file for the assistant to read in parts, or send a shortened preview.'),
action: 'Attach as a file',
onRetry: () => void sendOversized('file'),
secondary: { label: 'Send a shortened preview', onClick: () => void sendOversized('preview') },
};
}
if (code === 'CONTEXT_WINDOW_EXCEEDED') {
return {
next: tr('Summarize it now and continue, or start a new chat.'),
action: 'Summarize and continue',
onRetry: () => void summarizeAndContinue(),
secondary: { label: 'Start a new chat', onClick: () => startNewChat() },
};
}
return { next: tr('Try sending the message again.') };
}
+10
View File
@@ -0,0 +1,10 @@
/**
* The Drafting choices the Models screen offers for a model. DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families) AND only while
* the engine serves it: strata_control/drafting.py ENGINE_SERVES_DFLASH2 (Control reports it as `capabilities.dflash2`). Until then the setting
* is Auto / MTP / Off; DFlash2 appears by itself when the engine can run it. The Terminal's --drafting flag has it either way, gated with a note.
*/
export const DRAFTING_FAMILIES = ['qwen', 'gyro'];
export function draftingChoices(family: string | null | undefined, dflash2Served: boolean | null | undefined): string[] {
return DRAFTING_FAMILIES.includes(String(family)) && dflash2Served === true ? ['auto', 'mtp', 'dflash2', 'off'] : ['auto', 'mtp', 'off'];
}
+5 -2
View File
@@ -89,17 +89,20 @@ export function pickMain(cs: RoleCard[]): { main: RoleCard | null; why: MainWhy
export type OffWhy = 'cannot_run' | 'igpu' | 'small' | 'helper';
export interface Recommendation { roles: Roles; main: number | null; why: MainWhy; reasons: Record<number, MainWhy | OffWhy> }
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less, built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less when a bigger card is there (or it has under 4 GB), built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
export function recommend(cards: RoleCard[] | null | undefined, vendor?: string | null): Recommendation {
const cs = rows(cards, vendor);
const { main, why } = pickMain(cs);
const roles: Roles = {}, reasons: Recommendation['reasons'] = {};
// An 8 GB card is a poor Helper beside a bigger card, but a perfectly good one when 8 GB is all there is: two GTX 1080s are a working
// 16 GB pool (#3655). A card with 8 GB or less starts Off only when a bigger usable card is there, or when it is too small to help.
const hasBigger = cs.some(c => canRun(c) && !shares(c) && rnd(mem(c)) > OFF_AT_OR_BELOW_GB);
for (const c of cs) {
const i = c.index;
if (main && c === main) { roles[i] = 'main'; reasons[i] = why; }
else if (!canRun(c)) { roles[i] = 'off'; reasons[i] = 'cannot_run'; }
else if (shares(c) && main) { roles[i] = 'off'; reasons[i] = 'igpu'; }
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB) { roles[i] = 'off'; reasons[i] = 'small'; }
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB && (hasBigger || mem(c) < MIN_CARD_GB)) { roles[i] = 'off'; reasons[i] = 'small'; }
else { roles[i] = 'helper'; reasons[i] = 'helper'; }
}
return { roles, main: main ? main.index : null, why, reasons };
+11 -2
View File
@@ -7,6 +7,14 @@ import tune_es from '../locales/tune-es.json';
import tune_ru from '../locales/tune-ru.json';
import tune_pt from '../locales/tune-pt.json';
import tune_ko from '../locales/tune-ko.json';
import tools_en from '../locales/tools-en.json';
import tools_zh from '../locales/tools-zh.json';
import tools_ja from '../locales/tools-ja.json';
import tools_de from '../locales/tools-de.json';
import tools_es from '../locales/tools-es.json';
import tools_ru from '../locales/tools-ru.json';
import tools_pt from '../locales/tools-pt.json';
import tools_ko from '../locales/tools-ko.json';
import base_zh from '../locales/zh.json';
import base_ja from '../locales/ja.json';
import base_de from '../locales/de.json';
@@ -18,8 +26,9 @@ import base_ko from '../locales/ko.json';
export const LANGUAGES=[{value:'en',label:'English'},{value:'zh',label:'中文(简体)'},{value:'ja',label:'日本語'},{value:'de',label:'Deutsch'},{value:'es',label:'Español'},{value:'ru',label:'Русский'},{value:'pt',label:'Português'},{value:'ko',label:'한국어'}] as const;
export type Language=typeof LANGUAGES[number]['value'];
/* The Tune model dialog's strings live in tune-<lang>.json (apps/host/scripts/make-tune-locales.py) so other interface work does not collide with them. */
const english:Record<string,string>={...baseEnglish,...tune_en};
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh},{...base_ja,...tune_ja},{...base_de,...tune_de},{...base_es,...tune_es},{...base_ru,...tune_ru},{...base_pt,...tune_pt},{...base_ko,...tune_ko}] as Record<string,string>[];
/* The tool-server, picture and agent-result strings of the tools and images fixes live in tools-<lang>.json for the same reason. */
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en};
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh,...tools_zh},{...base_ja,...tune_ja,...tools_ja},{...base_de,...tune_de,...tools_de},{...base_es,...tune_es,...tools_es},{...base_ru,...tune_ru,...tools_ru},{...base_pt,...tune_pt,...tools_pt},{...base_ko,...tune_ko,...tools_ko}] as Record<string,string>[];
const catalogs:Record<Language,Record<string,string>>={en:english,zh,ja,de,es,ru,pt,ko};
const entities:Record<string,string>={amp:'&',lt:'<',gt:'>',quot:'"',apos:"'",nbsp:'\u00a0'};
const decode=(text:string)=>text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi,(whole,key:string)=>key[0]==='#'?String.fromCodePoint(key[1].toLowerCase()==='x'?parseInt(key.slice(2),16):parseInt(key.slice(1),10)):entities[key]??whole);
+32
View File
@@ -0,0 +1,32 @@
import { rawUrlOf } from './deliverables';
/**
* Which picture addresses a model's Markdown may put in an <img> (tools and images review O3, 2026-10-22).
*
* An answer can carry `![chart](...)`. It is shown only when the picture is already on this computer or inside the message:
* - a data: address of a PNG, JPEG, WebP or GIF (never SVG: an SVG can carry script and styles);
* - the host's own /api/files/raw address (a file inside the chat's folder, served by the host and by nobody else);
* - a path without a scheme, which is read as a file of the chat's folder through that same address.
* Nothing is fetched from the internet by default (an address in the answer would tell its owner that the answer was read, and
* which IP read it): an http(s) picture stays a link the person can open. file:, javascript:, blob: and every other scheme show as text.
*/
export type ImagePlan =
| { kind: 'img'; src: string }
| { kind: 'link'; href: string }
| { kind: 'text' };
const DATA_IMAGE = /^data:image\/(?:png|jpe?g|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/i;
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
export function imagePlan(src: string, sessionId?: string | null): ImagePlan {
const value = src.trim();
if (!value) return { kind: 'text' };
if (DATA_IMAGE.test(value)) return { kind: 'img', src: value };
if (/^https?:\/\//i.test(value)) return { kind: 'link', href: value };
if (value.startsWith('/api/files/raw?')) return { kind: 'img', src: value };
// a drive letter (C:\x.png, C:/x.png) is a path, not a scheme
if (SCHEME.test(value) && !/^[a-z]:[\\/]/i.test(value)) return { kind: 'text' };
if (value.startsWith('//') || value.startsWith('\\\\')) return { kind: 'text' }; // another computer's share
if (!sessionId) return { kind: 'text' };
return { kind: 'img', src: rawUrlOf(sessionId, value) };
}
+5
View File
@@ -0,0 +1,5 @@
/** The one rule for the name of a tool server. A copy of server/mcp-names.mjs (tests/mcp-rows.test.mjs checks that they agree). */
export const MCP_NAME_MAX = 32;
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
export const validMcpName = (name: string) => MCP_NAME_RE.test(name);
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
@@ -0,0 +1,22 @@
import type { NativeJob } from './native';
/** What the host says about a job a restart cut short. */
export const INTERRUPTED = 'The host restarted before completion. Check diagnostics, then retry; completed files are kept.';
const updateKinds = new Set(['update-strata', 'update-engine']);
/**
* A later finished update of the same component settles the restart interruption of an earlier one (ported from #3578 by medking82).
* The records are never changed: a running retry, another component or an ordinary failure stays a plain failure.
*/
export function recoveredInterruptions(jobs: readonly NativeJob[]): Set<string> {
const time = (job: NativeJob) => Date.parse(job.startedAt);
return new Set(jobs.filter(job => job.state === 'error' && updateKinds.has(job.kind) && job.error === INTERRUPTED && Number.isFinite(time(job))
&& jobs.some(later => later.state === 'done' && later.kind === job.kind && Number.isFinite(time(later)) && time(later) > time(job))).map(job => job.id));
}
/** The text of a failed job's alert. The alert stays red; an interruption a later update has settled says so in plain words. */
export function jobErrorText(job: NativeJob, jobs: readonly NativeJob[]): string {
return recoveredInterruptions(jobs).has(job.id)
? 'This update was cut short when the app restarted. A later update finished, so nothing needs doing.'
: job.error ?? job.summary;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export interface NativeState {
/** The picture reader (strata-vision) is installed with the engine, so models marked for pictures can read them here. */
/** Strata Control's view of the engine for this computer (the app): usable false = none found or recognised; `notice` says why in plain words. */
engine?:{usable:boolean;notice?:string|null;wrongMake?:boolean;version?:string|null};
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;/** Free bytes on the drive that holds the models folder. */downloadFreeBytes?:number|null;downloadTotalBytes?:number|null;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;/** SMs x clock: only the order between cards matters (strata_control/gpu_roles.py). */speed?:number;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;/** Free bytes on the drive that holds the models folder. */downloadFreeBytes?:number|null;downloadTotalBytes?:number|null;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';/** The Drafting choice saved with the model: auto | mtp | dflash2 | off. */drafting?:string;dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;/** SMs x clock: only the order between cards matters (strata_control/gpu_roles.py). */speed?:number;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{/** The engine serves DFlash2 (Control says): the Drafting choice then offers it. */dflash2?:boolean;responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
let state:NativeState|null=null;
const listeners=new Set<()=>void>();
function put(value:NativeState){if(state&&JSON.stringify(state)===JSON.stringify(value))return;state=value;listeners.forEach(fn=>fn());}
+5 -2
View File
@@ -8,13 +8,15 @@ import { tr } from './i18n';
export interface PlainFailure { message: string; next?: string; detail?: string; kind: string }
interface Rule { kind: string; match: RegExp; message: string; next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
interface Rule { kind: string; match: RegExp; message: string | ((m: RegExpMatchArray) => string); next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
const MEMORY = 'The model stopped while starting, most likely because the PC ran low on memory.';
const MEMORY_NEXT = 'Close other programs and try again, or choose “Leave 20% 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.';
/** Order matters: the first rule that matches wins. */
const RULES: Rule[] = [
// The host names the files it could not find (#3478). The sentence with the names is a template in every language: tr() fills it.
{ kind: 'model-files-missing', match: /Some model files are missing: (.+?)\. Use Download/i, message: m => `These model files are missing: ${m[1]}.`, next: 'Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.' },
{ kind: 'tunnel-own-running', match: /Strata’s tunnel for this address is already running \(PID/i, message: 'Strata’s tunnel for this address is already running.', next: 'Stop it first, or use it as it is, then connect again here.' },
{ kind: 'gpu-shared', match: /GPU \d+ is already used by .*, so this model did not fit there/i, message: 'Another model on the same graphics card left no room for this one.', next: 'Load it on a different GPU, or unload the other model first.' },
{ kind: 'gpu-no-code', match: /none of this computer.s graphics cards has code in this Strata engine|not an architecture this Strata engine was compiled for/i, message: 'This Strata engine has no code for the graphics card it tried to use.', next: 'Pick another card under Run on, or open Details to see which cards were found and what the engine is built for.' },
@@ -34,6 +36,7 @@ const RULES: Rule[] = [
{ kind: 'blocked', match: /0xC0000022|3221225506|\bEACCES\b|\bEPERM\b|operation not permitted/i, message: 'Windows or your security software stopped the engine from starting.', next: 'Allow Strata in your security software, then try again.' },
{ kind: 'file-missing', match: /\bENOENT\b|no such file or directory|cannot find the (?:file|path)|0x80070002|exit code 127|file not found/i, message: 'Strata could not find a file it needs to start the engine.', next: 'Reinstall Strata or press Repair.' },
{ kind: 'memory', match: /out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate memory|not enough memory|bad_alloc|pinned-memory-exhausted|mapped staging|cudaErrorMemoryAllocation|insufficient memory/i, message: MEMORY, next: MEMORY_NEXT },
{ kind: 'previous-session', match: /lost host child is still present|retry after it exits/i, message: 'The last Strata session is still closing down.', next: 'Wait a moment and try again. If it keeps happening, restart the computer.' },
{ kind: 'start-failed', match: /server_exited|engine stopped|exited before|died before/i, message: 'The model stopped while starting.', next: 'Try again. If it keeps happening, open Details and share them when you ask for help.' },
{ kind: 'slow-load', match: /loading took more than/i, message: 'Loading is taking too long.', next: 'The PC may be short on memory. Close other programs and try again.' },
{ kind: 'engine-missing', match: /no engine for this pc|engine component|never downloads an engine|engine .*not installed|no strata engine/i, message: 'Strata’s engine for this computer isn’t installed yet.', next: 'It is added when you download a model. Download your model again to add it.' },
@@ -50,7 +53,7 @@ export function plainFailure(raw: string | null | undefined): PlainFailure {
for (const rule of RULES) {
const m = text.match(rule.match);
if (!m) continue;
return { kind: rule.kind, message: rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
return { kind: rule.kind, message: typeof rule.message === 'function' ? rule.message(m) : rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
}
return { kind: 'plain', message: text.replace(/^./, c => c.toUpperCase()) };
}
+30 -2
View File
@@ -74,7 +74,7 @@ function normalise(loc: Location): void {
* Navigate, morphing between screens when the browser can.
*
* A view transition needs the DOM update to happen inside its callback, and
* React commits asynchronously — so we wait two frames before resolving. It is
* React commits asynchronously — so we wait for the commit (never for a frame: see afterRouteUpdate). It is
* skipped while a response is streaming (the transcript is being appended to on
* every token, and snapshotting it mid-stream would fight the SSE updates) and
* when the user has asked for reduced motion.
@@ -100,6 +100,34 @@ export function isWebKitGtk(ua: string = navigator.userAgent): boolean {
return /AppleWebKit/.test(ua) && /Linux|X11/.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Android/.test(ua);
}
/**
* Resolve once the new route has been drawn into the page, or after a short deadline, whichever comes first.
*
* The update callback of a view transition must not wait for requestAnimationFrame: while the callback is pending the browser holds
* rendering (Chromium and Firefox both do), so a frame callback never runs, and every click waits for the browser's own guard,
* about 4 s in Chromium and the WebView2 window, about 5 s in Firefox (#3673). A DOM change after the hash flipped means React has
* committed the new screen; the deadline covers a route that changes nothing visible.
*/
export function afterRouteUpdate(done: () => void, timeoutMs = 150): void {
let settled = false;
let observer: MutationObserver | undefined;
const finish = () => {
if (settled) return;
settled = true;
observer?.disconnect();
done();
};
try {
if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) {
observer = new MutationObserver(() => setTimeout(finish, 0));
observer.observe(document.body, { childList: true, subtree: true });
}
} catch {
/* the deadline alone is enough */
}
setTimeout(finish, timeoutMs);
}
function navigateHash(next: string, morph = true) {
if (window.location.hash === next) return;
@@ -121,7 +149,7 @@ function navigateHash(next: string, morph = true) {
() =>
new Promise<void>((resolve) => {
window.location.hash = next;
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
afterRouteUpdate(resolve);
}),
);
// A transition is *expected* to be skipped when one is already running (rapid
+110
View File
@@ -0,0 +1,110 @@
import type { Session } from './types';
/**
* Saving the chat list without hurting the page (#3604, #3603).
*
* Every streamed token used to stringify every chat and write the lot to localStorage. On a long history that is
* megabytes of string work per token, and the WebView2 renderer grew to gigabytes before it died (the window goes
* white). Three rules fix it, and the stored format is unchanged, so older builds and the flow scripts still read it:
*
* 1. Coalesce: the newest state wins and at most one write happens per `delayMs`. The caller flushes when a turn ends
* or the page goes away.
* 2. Only the chats that changed are serialised again. A chat is replaced by a new object when it changes (the store
* never mutates), so the previous JSON of every other chat is reused by object identity.
* 3. The stored copy is capped: a huge tool result or an old chat that no longer fits is left out of the saved copy,
* never out of memory. The saved list is a cache, not the source of truth.
*/
/** Longest text kept for one field in the saved copy. */
export const MAX_FIELD_CHARS = 200_000;
/** Longest saved copy of one chat (JSON characters). A longer chat is shortened from its oldest messages. */
export const MAX_SESSION_CHARS = 1_500_000;
/** Longest saved chat list (JSON characters). localStorage allows about 5M; keep a margin for everything else. */
export const MAX_TOTAL_CHARS = 3_500_000;
type Json = unknown;
function clipFields(value: Json, depth = 0): Json {
if (typeof value === 'string') return value.length > MAX_FIELD_CHARS ? `${value.slice(0, MAX_FIELD_CHARS)}\n[shortened in the saved copy]` : value;
if (depth > 6 || value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(v => clipFields(v, depth + 1));
const out: Record<string, Json> = {};
for (const [k, v] of Object.entries(value as Record<string, Json>)) out[k] = clipFields(v, depth + 1);
return out;
}
/** The JSON text kept for one chat: whole when it fits, otherwise clipped and then shortened from the oldest message. */
export function serializeSession(session: Session): string {
const whole = JSON.stringify(session);
if (whole.length <= MAX_SESSION_CHARS) return whole;
let copy = clipFields(session) as Session;
let text = JSON.stringify(copy);
if (text.length <= MAX_SESSION_CHARS) return text;
let messages = copy.messages ?? [];
// Drop the oldest messages in halves until it fits; always keep the latest two.
while (text.length > MAX_SESSION_CHARS && messages.length > 2) {
messages = messages.slice(Math.max(1, Math.floor(messages.length / 2)));
copy = { ...copy, messages };
text = JSON.stringify(copy);
}
return text;
}
export interface SessionWriterOptions {
/** Where the list is saved (the storage write). Receives the finished JSON text. */
write: (json: string) => void;
delayMs?: number;
maxTotalChars?: number;
/** Counts a serialisation of one chat; for tests. */
onSerialize?: (session: Session) => void;
}
export function createSessionWriter(options: SessionWriterOptions) {
const delayMs = options.delayMs ?? 1_000;
const maxTotal = options.maxTotalChars ?? MAX_TOTAL_CHARS;
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Session[] | null = null;
// Chat object -> its JSON. WeakMap: a replaced chat is forgotten with its object.
const cache = new WeakMap<Session, string>();
const textOf = (s: Session): string => {
let text = cache.get(s);
if (text === undefined) {
options.onSerialize?.(s);
text = serializeSession(s);
cache.set(s, text);
}
return text;
};
const flush = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
const sessions = pending;
pending = null;
if (!sessions) return;
const parts = sessions.map(s => ({ s, text: textOf(s) }));
let total = parts.reduce((n, p) => n + p.text.length + 1, 2);
if (total > maxTotal) {
// Leave the oldest unpinned chats out of the saved copy until the rest fits. The newest chat is always kept.
const order = parts
.map((p, i) => ({ i, p }))
.sort((a, b) => Number(!!a.p.s.pinned) - Number(!!b.p.s.pinned) || (a.p.s.updatedAt < b.p.s.updatedAt ? -1 : 1));
const drop = new Set<number>();
for (const { i, p } of order) {
if (total <= maxTotal || drop.size >= parts.length - 1) break;
drop.add(i);
total -= p.text.length + 1;
}
options.write(`[${parts.filter((_, i) => !drop.has(i)).map(p => p.text).join(',')}]`);
return;
}
options.write(`[${parts.map(p => p.text).join(',')}]`);
};
const schedule = (sessions: Session[]) => {
pending = sessions;
if (timer === null) timer = setTimeout(flush, delayMs);
};
return { schedule, flush, hasPending: () => pending !== null };
}
+89 -8
View File
@@ -19,6 +19,7 @@ import { noModelNotice } from './no-model';
import { taskHandoverMessage, taskHandoverTitle, chatHandoverMessage, chatHandoverTitle } from './task-handover';
import type { Task } from './tasks';
import { postWithFolderFallback } from './folder-pick';
import { createSessionWriter } from './session-persist';
import { setTranslationLanguage, tr, validLanguage, type Language } from './i18n';
/**
* Strata app store.
@@ -1008,12 +1009,33 @@ export async function scopeSkill(id: string, workspaceId: string | null) {
/* ---------------------------------------------------------------- sessions */
function persistSessions(sessions: Session[]) {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(sessions));
} catch {
/* quota — sessions are a cache, not the source of truth */
}
const sessionWriter = createSessionWriter({
write: (json) => {
try {
localStorage.setItem(SESSION_KEY, json);
} catch {
/* quota — sessions are a cache, not the source of truth */
}
},
});
/**
* Save the chat list. A one-off change (new, rename, delete, pin) is written now; `soon` is for the streaming
* path, where a write per token grew the renderer to gigabytes (#3604, #3603): those are coalesced to one a second.
*/
function persistSessions(sessions: Session[], soon = false) {
sessionWriter.schedule(sessions);
if (!soon) sessionWriter.flush();
}
/** Write the newest chat list now: a turn ended, or the page is going away. */
export function flushSessions() {
sessionWriter.flush();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' && window.addEventListener) {
window.addEventListener('pagehide', flushSessions);
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') flushSessions(); });
}
export function newSession(): Session {
@@ -1120,7 +1142,7 @@ export function renameSession(id: string, title: string) {
function patchSession(id: string, fn: (s: Session) => Session) {
const sessions = state.sessions.map((s) => (s.id === id ? fn(s) : s));
persistSessions(sessions);
persistSessions(sessions, true);
set({ sessions });
}
@@ -1366,6 +1388,55 @@ export async function retryLast(messageId?: string) {
}
}
/** The open chat's last question and the answer that failed under it (null when the chat does not end that way). */
function failedExchange(): { prompt: string; sessionId: string } | null {
const sessionId = state.activeSession;
const chat = state.sessions.find(s => s.id === sessionId);
if (!sessionId || !chat || state.streaming) return null;
const reply = chat.messages[chat.messages.length - 1], ask = chat.messages[chat.messages.length - 2];
return reply?.role === 'assistant' && reply.error && ask?.role === 'user' ? { prompt: ask.content, sessionId } : null;
}
/** Takes the failed question and its error out of the transcript, so asking again does not show the question twice. */
function dropFailedExchange(sessionId: string) {
patchSession(sessionId, s => ({ ...s, messages: s.messages.slice(0, -2) }));
}
/**
* The error card of a conversation that no longer fits the model's window: summarise the older messages (the same as typing /compact), then
* ask the assistant to answer. The refused question is already in the assistant's conversation (it is the newest message, and a summary keeps
* the newest message as it is), so it is not sent a second time: a large one twice would not fit either.
*/
export async function summarizeAndContinue() {
if (!failedExchange()) return;
await sendMessage('/compact');
await sendMessage('Please answer my last message now.');
}
/**
* The error card of a message that is too large to send: the host makes it small enough (its text as a file in the chat's folder that the
* assistant reads in parts, or a shortened preview), and that is sent in its place.
*/
export async function sendOversized(mode: 'file' | 'preview') {
const failed = failedExchange();
if (!failed) return;
let reply: { message?: string };
try {
reply = await api.post<{ message?: string }>('/api/chat/oversize', { sessionId: failed.sessionId, mode, text: failed.prompt, nativeModelId: nativeState()?.instances.length ? nativeState()?.defaultId : undefined });
} catch {
pushToast('error', 'The message could not be prepared. Try a shorter message.');
return;
}
if (!reply.message) return;
dropFailedExchange(failed.sessionId);
await sendMessage(reply.message);
}
/** A new, empty chat, opened. */
export function startNewChat() {
const session = newSession();
go('chat', session.id);
}
/**
* Forks a conversation at a message, leaving the original untouched.
*
@@ -1733,9 +1804,15 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
}));
} else if (type === 'context') {
// What the context holds after each model request of an agent turn: the meter follows the turn instead of waiting for its end.
const used = Number(frame.contextTokens), max = Number(frame.contextMax);
if (Number.isFinite(used) && used > 0) patchAssistant((m) => ({ ...m, contextUsed: used, ...(Number.isFinite(max) && max > 0 ? { contextMax: max } : {}) }));
} else if (type === 'error') {
clearStatus(sessionId);
patchAssistant((m) => ({ ...m, error: plainFailure(String(frame.message ?? 'Engine error')).message, streaming: false }));
// A sentence the host wrote for the person (it carries its catalog key) is shown in their language as it is; anything else is made plain here.
const said = typeof frame.key === 'string' ? tr(frame.key, Array.isArray(frame.values) ? frame.values : undefined) : plainFailure(String(frame.message ?? 'Engine error')).message;
patchAssistant((m) => ({ ...m, error: said, streaming: false, ...(typeof frame.code === 'string' ? { errorCode: frame.code } : {}), ...(typeof frame.details === 'string' && frame.details ? { errorDetail: frame.details } : {}) }));
} else if (type === 'done') {
clearStatus(sessionId);
patchSession(sessionId, (sess) => ({
@@ -1756,6 +1833,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
}));
// The final text is in `state`: write it once now instead of waiting for the coalescing timer.
flushSessions();
}
},
turnAbort.signal,
@@ -1772,6 +1851,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
patchAssistant((m) => ({ ...m, streaming: false }));
}
} finally {
// A cancelled or failed turn is still worth keeping.
flushSessions();
clearStatus(sessionId);
markTurn(sessionId, null);
const next = releasedMessages.get(sessionId)?.shift();
+4
View File
@@ -358,6 +358,10 @@ export interface ChatMessage {
promptTps?: number;
durationMs?: number;
error?: string;
/** What went wrong, as a code the page acts on (a context error offers its own recovery buttons). */
errorCode?: string;
/** The engine's own text of the error, kept behind Details. */
errorDetail?: string;
tools?: ToolCall[];
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Nur Ihr Prozessor und Arbeitsspeicher werden verwendet.",
"s8644": "Für Experten: Die Engine startet auf den Karten {0} (Hauptkarte zuletzt).",
"s8645": "Wird nicht gespeichert, solange Sie die Empfehlung behalten: Strata wählt neu, wenn Sie eine Karte hinzufügen oder entfernen.",
"s8646": "Nicht genutzt: {0} (anderer Grafikkarten-Hersteller als der, den diese Engine ansteuert)."
"s8646": "Nicht genutzt: {0} (anderer Grafikkarten-Hersteller als der, den diese Engine ansteuert).",
"s9001": "Diese Nachricht ist etwa {0} Tokens lang, und dieses Modell kann etwa {1} auf einmal lesen. Sie ist zu groß, um so gesendet zu werden.",
"s9002": "Hängen Sie sie als Datei an, die der Assistent in Teilen liest, oder senden Sie eine gekürzte Vorschau.",
"s9003": "Diese Unterhaltung passt nicht mehr in das Fenster des Modells, und das Zusammenfassen der älteren Nachrichten hat nicht genug Platz geschaffen.",
"s9004": "Fassen Sie sie jetzt zusammen und machen Sie weiter, oder beginnen Sie einen neuen Chat.",
"s9005": "Frühere Nachrichten werden zusammengefasst …",
"s9006": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll und ist jetzt zu {1} % voll.",
"s9007": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll.",
"s9008": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen.",
"s9009": "Dieser Chat ist zu {0} % voll, aber es gibt nichts Älteres zusammenzufassen: Die neueste Nachricht füllt ihn. Beginnen Sie einen neuen Chat oder senden Sie eine kürzere Nachricht.",
"s9010": "Es gibt noch nichts Älteres zusammenzufassen. Alles in diesem Chat braucht der Assistent noch.",
"s9011": "Es gibt noch nichts zusammenzufassen. In diesem Chat hat noch keine Unterhaltung mit dem Assistenten begonnen.",
"s9012": "Die älteren Nachrichten konnten gerade nicht zusammengefasst werden. Versuchen Sie es gleich noch einmal oder beginnen Sie einen neuen Chat.",
"s9013": "In diesem Chat läuft noch etwas. Halten Sie ihn an oder warten Sie, bis er fertig ist, und versuchen Sie es dann erneut.",
"s9014": "Als Datei anhängen",
"s9015": "Gekürzte Vorschau senden",
"s9016": "Zusammenfassen und weitermachen",
"s9017": "Neuen Chat beginnen",
"s9018": "Die Nachricht konnte nicht vorbereitet werden. Versuchen Sie es mit einer kürzeren Nachricht.",
"s9019": "Die letzte Strata-Sitzung wird noch beendet.",
"s9020": "Warten Sie einen Moment und versuchen Sie es erneut. Wenn es wieder passiert, starten Sie den Computer neu.",
"s9021": "Vorausraten",
"s9022": "Automatisch (empfohlen)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Vorausraten: MTP (DFlash2 hat nicht hineingepasst)",
"s9026": "Vorausraten: MTP (DFlash2 ist in diesem Modus nicht verfügbar)",
"s9027": "Vorausraten: MTP (DFlash2 gibt es nur für Qwen3.8-Flash-Next)",
"s9028": "Vorausraten: MTP (der DFlash2-Entwurfsbaustein ist noch nicht heruntergeladen)",
"s9029": "Wenn Sie DFlash2 wählen, wird auch sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig. Passt er nicht in den Grafikspeicher, nutzt das Modell stattdessen MTP.",
"s9030": "DFlash2-Entwurfsbaustein wird heruntergeladen …",
"s9031": "DFlash2-Entwurfsbaustein wird für die Engine vorbereitet …",
"s9032": "Der DFlash2-Entwurfsbaustein ist bereit.",
"s9033": "Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell mit MTP. Das Modell wird geladen …",
"s9034": "Das Modell wird geladen …",
"s9035": "Diese Modelldateien fehlen: {0}.",
"s9036": "Drücken Sie noch einmal auf „Herunterladen und laden“, um sie zu holen. Vorhandene Dateien bleiben erhalten. Werden dieselben Dateien wieder aufgelistet, öffnen Sie die Details und teilen Sie sie mit, wenn Sie um Hilfe bitten.",
"s9037": "Dieses Update wurde beim Neustart der App unterbrochen. Ein späteres Update ist abgeschlossen, Sie müssen nichts tun."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Only your processor and memory are used.",
"s8644": "For experts: the engine starts on cards {0} (Main last).",
"s8645": "Not saved while you keep the recommendation: Strata picks again if you add or remove a card.",
"s8646": "Not used: {0} (another make of graphics card than this engine drives)."
"s8646": "Not used: {0} (another make of graphics card than this engine drives).",
"s9001": "This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.",
"s9002": "Attach it as a file for the assistant to read in parts, or send a shortened preview.",
"s9003": "This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.",
"s9004": "Summarize it now and continue, or start a new chat.",
"s9005": "Summarizing earlier messages…",
"s9006": "Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.",
"s9007": "Older messages were summarized to make room. The chat was {0}% full.",
"s9008": "Older messages were summarized to make room.",
"s9009": "This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.",
"s9010": "There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.",
"s9011": "There is nothing to summarize yet. This chat has not started a conversation with the assistant.",
"s9012": "The older messages could not be summarized right now. Try again in a moment, or start a new chat.",
"s9013": "This chat is still working. Stop it or wait for it to finish, then try again.",
"s9014": "Attach as a file",
"s9015": "Send a shortened preview",
"s9016": "Summarize and continue",
"s9017": "Start a new chat",
"s9018": "The message could not be prepared. Try a shorter message.",
"s9019": "The last Strata session is still closing down.",
"s9020": "Wait a moment and try again. If it keeps happening, restart the computer.",
"s9021": "Drafting",
"s9022": "Auto (recommended)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Drafting: MTP (DFlash2 didn't fit)",
"s9026": "Drafting: MTP (DFlash2 isn't available in this mode)",
"s9027": "Drafting: MTP (DFlash2 is for Qwen3.8-Flash-Next only)",
"s9028": "Drafting: MTP (the DFlash2 drafter isn't downloaded yet)",
"s9029": "Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.",
"s9030": "Downloading the DFlash2 drafter…",
"s9031": "Packing the DFlash2 drafter for the engine…",
"s9032": "The DFlash2 drafter is ready.",
"s9033": "The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…",
"s9034": "Loading the model…",
"s9035": "These model files are missing: {0}.",
"s9036": "Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.",
"s9037": "This update was cut short when the app restarted. A later update finished, so nothing needs doing."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Solo se usan tu procesador y tu memoria.",
"s8644": "Para expertos: el motor arranca en las tarjetas {0} (la principal al final).",
"s8645": "No se guarda mientras mantengas la recomendación: Strata elige de nuevo si añades o quitas una tarjeta.",
"s8646": "No se usa: {0} (otra marca de tarjeta gráfica distinta de la que maneja este motor)."
"s8646": "No se usa: {0} (otra marca de tarjeta gráfica distinta de la que maneja este motor).",
"s9001": "Este mensaje tiene unos {0} tokens y este modelo puede leer unos {1} a la vez. Es demasiado grande para enviarlo tal cual.",
"s9002": "Adjúntalo como archivo para que el asistente lo lea por partes, o envía una vista previa acortada.",
"s9003": "Esta conversación ya no cabe en la ventana del modelo, y resumir los mensajes antiguos no dejó espacio suficiente.",
"s9004": "Resúmela ahora y sigue, o empieza un chat nuevo.",
"s9005": "Resumiendo mensajes anteriores…",
"s9006": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} % y ahora está al {1} %.",
"s9007": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} %.",
"s9008": "Se resumieron los mensajes antiguos para hacer sitio.",
"s9009": "Este chat está al {0} %, pero no hay nada antiguo que resumir: el mensaje más reciente es lo que lo llena. Empieza un chat nuevo o envía un mensaje más corto.",
"s9010": "Todavía no hay nada antiguo que resumir. El asistente aún necesita todo lo que hay en este chat.",
"s9011": "Todavía no hay nada que resumir. Este chat aún no ha empezado una conversación con el asistente.",
"s9012": "No se pudieron resumir los mensajes antiguos ahora mismo. Inténtalo de nuevo en un momento o empieza un chat nuevo.",
"s9013": "Este chat sigue trabajando. Deténlo o espera a que termine y vuelve a intentarlo.",
"s9014": "Adjuntar como archivo",
"s9015": "Enviar una vista previa acortada",
"s9016": "Resumir y continuar",
"s9017": "Empezar un chat nuevo",
"s9018": "No se pudo preparar el mensaje. Prueba con un mensaje más corto.",
"s9019": "La última sesión de Strata todavía se está cerrando.",
"s9020": "Espera un momento y vuelve a intentarlo. Si sigue pasando, reinicia el ordenador.",
"s9021": "Predicción",
"s9022": "Automático (recomendado)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Predicción: MTP (DFlash2 no cabía)",
"s9026": "Predicción: MTP (DFlash2 no está disponible en este modo)",
"s9027": "Predicción: MTP (DFlash2 es solo para Qwen3.8-Flash-Next)",
"s9028": "Predicción: MTP (el predictor de DFlash2 aún no está descargado)",
"s9029": "Elegir DFlash2 también descarga su predictor: unos {0} GB, una sola vez. Si no cabe en la memoria gráfica, el modelo usa MTP.",
"s9030": "Descargando el predictor de DFlash2…",
"s9031": "Preparando el predictor de DFlash2 para el motor…",
"s9032": "El predictor de DFlash2 está listo.",
"s9033": "No se pudo descargar el predictor de DFlash2, así que el modelo arranca con MTP. Cargando el modelo…",
"s9034": "Cargando el modelo…",
"s9035": "Faltan estos archivos del modelo: {0}.",
"s9036": "Pulse de nuevo «Descargar y cargar» para obtenerlos. Los archivos que ya están se conservan. Si vuelven a aparecer los mismos archivos, abra los detalles y compártalos cuando pida ayuda.",
"s9037": "Esta actualización se interrumpió cuando la aplicación se reinició. Una actualización posterior terminó, así que no hay nada que hacer."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "プロセッサとメモリのみを使います。",
"s8644": "上級者向け:エンジンはカード {0} で起動します(メインが最後)。",
"s8645": "おすすめのままの間は保存されません。カードを追加・取り外すと Strata が選び直します。",
"s8646": "未使用:{0}(このエンジンが対応するのとは別メーカーのカードです)。"
"s8646": "未使用:{0}(このエンジンが対応するのとは別メーカーのカードです)。",
"s9001": "このメッセージは約{0}トークンで、このモデルは一度に約{1}トークンまで読めます。このまま送るには大きすぎます。",
"s9002": "ファイルとして添付してアシスタントに分けて読んでもらうか、短くしたプレビューを送ってください。",
"s9003": "この会話はモデルのウィンドウに収まらなくなり、古いメッセージを要約しても十分な空きができませんでした。",
"s9004": "今すぐ要約して続けるか、新しいチャットを始めてください。",
"s9005": "これまでのメッセージを要約しています…",
"s9006": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていましたが、現在は{1}%です。",
"s9007": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていました。",
"s9008": "空きを作るために古いメッセージを要約しました。",
"s9009": "このチャットは{0}%埋まっていますが、要約できる古いメッセージはありません。最新のメッセージで埋まっています。新しいチャットを始めるか、もっと短いメッセージを送ってください。",
"s9010": "まだ要約できる古いメッセージはありません。このチャットの内容はどれもアシスタントがまだ必要としています。",
"s9011": "まだ要約するものがありません。このチャットではアシスタントとの会話が始まっていません。",
"s9012": "今は古いメッセージを要約できませんでした。しばらくしてからもう一度試すか、新しいチャットを始めてください。",
"s9013": "このチャットはまだ作業中です。停止するか、終わるのを待ってからもう一度試してください。",
"s9014": "ファイルとして添付",
"s9015": "短くしたプレビューを送る",
"s9016": "要約して続ける",
"s9017": "新しいチャットを始める",
"s9018": "メッセージを準備できませんでした。もっと短いメッセージを試してください。",
"s9019": "前回の Strata セッションがまだ終了処理中です。",
"s9020": "しばらく待ってからもう一度試してください。繰り返し起こる場合はコンピューターを再起動してください。",
"s9021": "先読み",
"s9022": "自動(おすすめ)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "先読み:MTP(DFlash2 は収まりませんでした)",
"s9026": "先読み:MTP(このモードでは DFlash2 を使えません)",
"s9027": "先読み:MTP(DFlash2 は Qwen3.8-Flash-Next 専用です)",
"s9028": "先読み:MTP(DFlash2 のドラフトモデルはまだダウンロードされていません)",
"s9029": "DFlash2 を選ぶと、そのドラフトモデルも一度だけダウンロードします(約 {0} GB)。グラフィックメモリに収まらない場合は、代わりに MTP を使います。",
"s9030": "DFlash2 のドラフトモデルをダウンロードしています…",
"s9031": "DFlash2 のドラフトモデルをエンジン用に準備しています…",
"s9032": "DFlash2 のドラフトモデルの準備ができました。",
"s9033": "DFlash2 のドラフトモデルをダウンロードできなかったため、MTP で起動します。モデルを読み込んでいます…",
"s9034": "モデルを読み込んでいます…",
"s9035": "次のモデルファイルが見つかりません: {0}。",
"s9036": "もう一度「ダウンロードして読み込む」を押すと取得します。すでにあるファイルはそのまま残ります。同じファイルがまた表示される場合は、詳細を開き、助けを求めるときに共有してください。",
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。"
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "프로세서와 메모리만 사용합니다.",
"s8644": "전문가용: 엔진은 카드 {0}에서 시작합니다 (메인이 마지막).",
"s8645": "추천 설정을 유지하는 동안은 저장되지 않습니다. 카드를 추가하거나 빼면 Strata가 다시 고릅니다.",
"s8646": "사용 안 함: {0} (이 엔진이 다루는 것과 다른 제조사의 그래픽 카드입니다)."
"s8646": "사용 안 함: {0} (이 엔진이 다루는 것과 다른 제조사의 그래픽 카드입니다).",
"s9001": "이 메시지는 약 {0}토큰이고, 이 모델은 한 번에 약 {1}토큰까지 읽을 수 있습니다. 그대로 보내기에는 너무 큽니다.",
"s9002": "파일로 첨부해 어시스턴트가 나눠 읽게 하거나, 줄인 미리보기를 보내세요.",
"s9003": "이 대화는 더 이상 모델의 창에 맞지 않으며, 오래된 메시지를 요약해도 충분한 공간이 나오지 않았습니다.",
"s9004": "지금 요약하고 계속하거나, 새 채팅을 시작하세요.",
"s9005": "이전 메시지를 요약하는 중…",
"s9006": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼던 것이 지금은 {1}%입니다.",
"s9007": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼습니다.",
"s9008": "공간을 만들기 위해 오래된 메시지를 요약했습니다.",
"s9009": "이 채팅은 {0}% 찼지만 요약할 오래된 메시지가 없습니다. 가장 최근 메시지가 공간을 차지하고 있습니다. 새 채팅을 시작하거나 더 짧은 메시지를 보내세요.",
"s9010": "아직 요약할 오래된 메시지가 없습니다. 이 채팅의 내용은 어시스턴트가 아직 모두 필요로 합니다.",
"s9011": "아직 요약할 내용이 없습니다. 이 채팅은 아직 어시스턴트와 대화를 시작하지 않았습니다.",
"s9012": "지금은 오래된 메시지를 요약할 수 없었습니다. 잠시 후 다시 시도하거나 새 채팅을 시작하세요.",
"s9013": "이 채팅은 아직 작업 중입니다. 중지하거나 끝날 때까지 기다린 뒤 다시 시도하세요.",
"s9014": "파일로 첨부",
"s9015": "줄인 미리보기 보내기",
"s9016": "요약하고 계속하기",
"s9017": "새 채팅 시작",
"s9018": "메시지를 준비하지 못했습니다. 더 짧은 메시지를 시도해 보세요.",
"s9019": "지난 Strata 세션이 아직 종료되는 중입니다.",
"s9020": "잠시 기다렸다가 다시 시도하세요. 계속 반복되면 컴퓨터를 다시 시작하세요.",
"s9021": "초안 예측",
"s9022": "자동(권장)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "초안 예측: MTP(DFlash2가 들어가지 않았습니다)",
"s9026": "초안 예측: MTP(이 모드에서는 DFlash2를 쓸 수 없습니다)",
"s9027": "초안 예측: MTP(DFlash2는 Qwen3.8-Flash-Next 전용입니다)",
"s9028": "초안 예측: MTP(DFlash2 초안 모델이 아직 내려받아지지 않았습니다)",
"s9029": "DFlash2를 고르면 초안 모델도 한 번 내려받습니다(약 {0}GB). 그래픽 메모리에 들어가지 않으면 모델은 대신 MTP를 씁니다.",
"s9030": "DFlash2 초안 모델을 내려받는 중…",
"s9031": "엔진용 DFlash2 초안 모델을 준비하는 중…",
"s9032": "DFlash2 초안 모델이 준비되었습니다.",
"s9033": "DFlash2 초안 모델을 내려받지 못해 모델을 MTP로 시작합니다. 모델을 불러오는 중…",
"s9034": "모델을 불러오는 중…",
"s9035": "다음 모델 파일이 없습니다: {0}.",
"s9036": "'다운로드 및 불러오기'를 다시 누르면 가져옵니다. 이미 있는 파일은 그대로 둡니다. 같은 파일이 다시 표시되면 자세히 보기를 열어 도움을 요청할 때 함께 알려 주세요.",
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Apenas o processador e a memória são usados.",
"s8644": "Para especialistas: o motor inicia nas placas {0} (a principal por último).",
"s8645": "Não é guardado enquanto mantiver a recomendação: o Strata escolhe de novo se adicionar ou remover uma placa.",
"s8646": "Não usada: {0} (outra marca de placa de vídeo diferente da que este motor controla)."
"s8646": "Não usada: {0} (outra marca de placa de vídeo diferente da que este motor controla).",
"s9001": "Esta mensagem tem cerca de {0} tokens e este modelo consegue ler cerca de {1} de cada vez. É grande demais para ser enviada assim.",
"s9002": "Anexe-a como arquivo para o assistente ler por partes, ou envie uma prévia reduzida.",
"s9003": "Esta conversa já não cabe na janela do modelo, e resumir as mensagens mais antigas não liberou espaço suficiente.",
"s9004": "Resuma agora e continue, ou comece um novo chat.",
"s9005": "Resumindo as mensagens anteriores…",
"s9006": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio e agora está {1}% cheio.",
"s9007": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio.",
"s9008": "As mensagens antigas foram resumidas para liberar espaço.",
"s9009": "Este chat está {0}% cheio, mas não há nada antigo para resumir: a mensagem mais recente é o que o enche. Comece um novo chat ou envie uma mensagem mais curta.",
"s9010": "Ainda não há nada antigo para resumir. O assistente ainda precisa de tudo o que está neste chat.",
"s9011": "Ainda não há nada para resumir. Este chat ainda não iniciou uma conversa com o assistente.",
"s9012": "Não foi possível resumir as mensagens antigas agora. Tente novamente em instantes ou comece um novo chat.",
"s9013": "Este chat ainda está trabalhando. Pare-o ou espere terminar e tente novamente.",
"s9014": "Anexar como arquivo",
"s9015": "Enviar uma prévia reduzida",
"s9016": "Resumir e continuar",
"s9017": "Começar um novo chat",
"s9018": "Não foi possível preparar a mensagem. Tente uma mensagem mais curta.",
"s9019": "A última sessão do Strata ainda está sendo encerrada.",
"s9020": "Aguarde um momento e tente novamente. Se continuar acontecendo, reinicie o computador.",
"s9021": "Previsão",
"s9022": "Automático (recomendado)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Previsão: MTP (o DFlash2 não coube)",
"s9026": "Previsão: MTP (o DFlash2 não está disponível neste modo)",
"s9027": "Previsão: MTP (o DFlash2 é só para o Qwen3.8-Flash-Next)",
"s9028": "Previsão: MTP (o preditor do DFlash2 ainda não foi baixado)",
"s9029": "Escolher o DFlash2 também baixa o preditor dele: cerca de {0} GB, uma vez. Se não couber na memória gráfica, o modelo usa o MTP.",
"s9030": "Baixando o preditor do DFlash2…",
"s9031": "Preparando o preditor do DFlash2 para o motor…",
"s9032": "O preditor do DFlash2 está pronto.",
"s9033": "Não foi possível baixar o preditor do DFlash2, então o modelo inicia com o MTP. Carregando o modelo…",
"s9034": "Carregando o modelo…",
"s9035": "Estes arquivos do modelo estão faltando: {0}.",
"s9036": "Pressione Baixar e carregar de novo para buscá-los. Os arquivos que já estão aí são mantidos. Se os mesmos arquivos aparecerem de novo, abra os Detalhes e compartilhe-os ao pedir ajuda.",
"s9037": "Esta atualização foi interrompida quando o aplicativo reiniciou. Uma atualização posterior terminou, então não há nada a fazer."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Используются только процессор и память.",
"s8644": "Для опытных: движок запускается на картах {0} (основная — последняя).",
"s8645": "Не сохраняется, пока вы оставляете рекомендацию: Strata выберет заново, если вы добавите или уберёте карту.",
"s8646": "Не используется: {0} (другой производитель видеокарт, чем тот, с которым работает этот движок)."
"s8646": "Не используется: {0} (другой производитель видеокарт, чем тот, с которым работает этот движок).",
"s9001": "Это сообщение состоит примерно из {0} токенов, а эта модель за раз читает примерно {1}. Оно слишком большое, чтобы отправить его как есть.",
"s9002": "Прикрепите его как файл, чтобы ассистент прочитал его по частям, или отправьте сокращённый предпросмотр.",
"s9003": "Этот разговор больше не помещается в окно модели, а краткое изложение старых сообщений не освободило достаточно места.",
"s9004": "Сжать его сейчас и продолжить или начать новый чат.",
"s9005": "Кратко пересказываю предыдущие сообщения…",
"s9006": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%, а теперь на {1}%.",
"s9007": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%.",
"s9008": "Старые сообщения были кратко пересказаны, чтобы освободить место.",
"s9009": "Этот чат заполнен на {0}%, но пересказывать старые сообщения нечего: место занимает самое новое сообщение. Начните новый чат или отправьте сообщение покороче.",
"s9010": "Пока нечего пересказывать. Всё, что есть в этом чате, ассистенту ещё нужно.",
"s9011": "Пока нечего пересказывать. В этом чате ещё не начался разговор с ассистентом.",
"s9012": "Старые сообщения сейчас не удалось кратко пересказать. Попробуйте ещё раз через минуту или начните новый чат.",
"s9013": "Этот чат ещё работает. Остановите его или дождитесь окончания и попробуйте снова.",
"s9014": "Прикрепить как файл",
"s9015": "Отправить сокращённый предпросмотр",
"s9016": "Сжать и продолжить",
"s9017": "Начать новый чат",
"s9018": "Не удалось подготовить сообщение. Попробуйте сообщение покороче.",
"s9019": "Прошлый сеанс Strata ещё завершается.",
"s9020": "Подождите немного и попробуйте снова. Если это повторяется, перезагрузите компьютер.",
"s9021": "Предсказание",
"s9022": "Авто (рекомендуется)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Предсказание: MTP (DFlash2 не поместился)",
"s9026": "Предсказание: MTP (DFlash2 недоступен в этом режиме)",
"s9027": "Предсказание: MTP (DFlash2 только для Qwen3.8-Flash-Next)",
"s9028": "Предсказание: MTP (черновая модель DFlash2 ещё не скачана)",
"s9029": "Если выбрать DFlash2, будет также скачана его черновая модель: около {0} ГБ, один раз. Если она не поместится в видеопамять, модель будет работать с MTP.",
"s9030": "Скачивается черновая модель DFlash2…",
"s9031": "Черновая модель DFlash2 готовится для движка…",
"s9032": "Черновая модель DFlash2 готова.",
"s9033": "Не удалось скачать черновую модель DFlash2, поэтому модель запускается с MTP. Загружается модель…",
"s9034": "Загружается модель…",
"s9035": "Не хватает этих файлов модели: {0}.",
"s9036": "Нажмите «Скачать и загрузить» ещё раз, чтобы получить их. Уже имеющиеся файлы сохраняются. Если те же файлы снова появятся в списке, откройте «Подробности» и приложите их, когда будете просить о помощи.",
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Verwende einen einfachen Servernamen mit höchstens 32 Zeichen: Buchstaben, Zahlen, Bindestriche oder Unterstriche.",
"tl02": "Diese Tool-Server wurden weggelassen, weil ihre Namen nicht erlaubt sind: {0}. Ein Name darf bis zu 32 Buchstaben, Zahlen, Bindestriche oder Unterstriche enthalten.",
"tl03": "Diese Tool-Server wurden weggelassen, weil sie kein Programm und keine Webadresse haben: {0}.",
"tl04": "Diese Tool-Server wurden weggelassen, weil ein anderer fast den gleichen Namen hat: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.",
"tl02": "These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.",
"tl03": "These tool servers were left out because they have no program or web address: {0}.",
"tl04": "These tool servers were left out because another one has almost the same name: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Usa un nombre de servidor sencillo de hasta 32 caracteres: letras, números, guiones o guiones bajos.",
"tl02": "Estos servidores de herramientas se han omitido porque sus nombres no están permitidos: {0}. Un nombre puede tener hasta 32 letras, números, guiones o guiones bajos.",
"tl03": "Estos servidores de herramientas se han omitido porque no tienen programa ni dirección web: {0}.",
"tl04": "Estos servidores de herramientas se han omitido porque otro tiene casi el mismo nombre: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "サーバー名は32文字以内で、英字、数字、ハイフン、アンダースコアを使ってください。",
"tl02": "名前が使えないため、次のツールサーバーは除外されました: {0}。名前には、英字、数字、ハイフン、アンダースコアを32文字まで使えます。",
"tl03": "プログラムまたはWebアドレスがないため、次のツールサーバーは除外されました: {0}。",
"tl04": "ほぼ同じ名前の別のサーバーがあるため、次のツールサーバーは除外されました: {0}。"
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "32자 이내의 간단한 서버 이름을 사용하세요. 영문자, 숫자, 하이픈, 밑줄을 쓸 수 있습니다.",
"tl02": "이름을 사용할 수 없어 다음 도구 서버를 제외했습니다: {0}. 이름은 영문자, 숫자, 하이픈, 밑줄로 최대 32자까지 지을 수 있습니다.",
"tl03": "프로그램이나 웹 주소가 없어 다음 도구 서버를 제외했습니다: {0}.",
"tl04": "이름이 거의 같은 다른 서버가 있어 다음 도구 서버를 제외했습니다: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Use um nome de servidor simples com até 32 caracteres: letras, números, hífens ou sublinhados.",
"tl02": "Estes servidores de ferramentas foram deixados de fora porque os nomes não são permitidos: {0}. Um nome pode ter até 32 letras, números, hífens ou sublinhados.",
"tl03": "Estes servidores de ferramentas foram deixados de fora porque não têm programa nem endereço web: {0}.",
"tl04": "Estes servidores de ferramentas foram deixados de fora porque outro tem quase o mesmo nome: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Используйте простое имя сервера не длиннее 32 символов: буквы, цифры, дефисы или подчёркивания.",
"tl02": "Эти серверы инструментов пропущены, потому что их имена недопустимы: {0}. Имя может содержать до 32 букв, цифр, дефисов или подчёркиваний.",
"tl03": "Эти серверы инструментов пропущены, потому что у них нет программы или веб-адреса: {0}.",
"tl04": "Эти серверы инструментов пропущены, потому что у другого почти такое же имя: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "请使用不超过 32 个字符的简单服务器名称:字母、数字、连字符或下划线。",
"tl02": "以下工具服务器已被忽略,因为名称不被允许:{0}。名称最多可包含 32 个字母、数字、连字符或下划线。",
"tl03": "以下工具服务器已被忽略,因为没有程序或网址:{0}。",
"tl04": "以下工具服务器已被忽略,因为另一个服务器的名称几乎相同:{0}。"
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "只使用处理器和内存。",
"s8644": "专家信息:引擎在显卡 {0} 上启动(主卡在最后)。",
"s8645": "沿用推荐设置时不会保存:增减显卡后 Strata 会重新选择。",
"s8646": "未使用:{0}(与此引擎驱动的显卡品牌不同)。"
"s8646": "未使用:{0}(与此引擎驱动的显卡品牌不同)。",
"s9001": "这条消息约有 {0} 个令牌,而这个模型一次大约能读 {1} 个。它太长了,不能原样发送。",
"s9002": "把它作为文件附上,让助手分段阅读,或者发送缩短的预览。",
"s9003": "这段对话已经放不进模型的窗口,总结较早的消息也没有腾出足够的空间。",
"s9004": "现在总结并继续,或者开始一个新的聊天。",
"s9005": "正在总结较早的消息…",
"s9006": "为腾出空间,较早的消息已被总结。聊天之前已用 {0}%,现在已用 {1}%。",
"s9007": "为腾出空间,较早的消息已被总结。聊天之前已用 {0}%。",
"s9008": "为腾出空间,较早的消息已被总结。",
"s9009": "这个聊天已用 {0}%,但没有更早的消息可总结:占满它的是最新的消息。请开始一个新的聊天,或发送更短的消息。",
"s9010": "还没有更早的消息可总结。这个聊天里的内容助手都还需要。",
"s9011": "还没有可总结的内容。这个聊天还没有开始和助手的对话。",
"s9012": "暂时无法总结较早的消息。请稍后再试,或开始一个新的聊天。",
"s9013": "这个聊天仍在工作。请停止它,或等它完成后再试。",
"s9014": "作为文件附上",
"s9015": "发送缩短的预览",
"s9016": "总结并继续",
"s9017": "开始新的聊天",
"s9018": "无法准备这条消息。请试试更短的消息。",
"s9019": "上一次 Strata 会话仍在关闭中。",
"s9020": "请稍等片刻再试。如果一直这样,请重启电脑。",
"s9021": "预测草稿",
"s9022": "自动(推荐)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "预测草稿:MTP(DFlash2 放不下)",
"s9026": "预测草稿:MTP(此模式下无法使用 DFlash2)",
"s9027": "预测草稿:MTP(DFlash2 仅适用于 Qwen3.8-Flash-Next)",
"s9028": "预测草稿:MTP(DFlash2 草稿模型尚未下载)",
"s9029": "选择 DFlash2 还会下载它的草稿模型:约 {0} GB,只下载一次。如果显存放不下,模型会改用 MTP。",
"s9030": "正在下载 DFlash2 草稿模型…",
"s9031": "正在为引擎准备 DFlash2 草稿模型…",
"s9032": "DFlash2 草稿模型已就绪。",
"s9033": "无法下载 DFlash2 草稿模型,因此模型将使用 MTP 启动。正在加载模型…",
"s9034": "正在加载模型…",
"s9035": "缺少这些模型文件:{0}。",
"s9036": "再按一次“下载并加载”即可获取。已有的文件会保留。如果再次列出同样的文件,请打开详情,并在求助时一并提供。",
"s9037": "此更新在应用重启时被中断。之后的更新已完成,无需任何操作。"
}
+5 -4
View File
@@ -28,6 +28,7 @@ import { DropZone, DROP_EVENT, pastedFiles } from '../components/DropZone';
import { ActivityLine, EditedFiles, FileCard, SkillsUsed } from '../components/Deliverables';
import { queueMessage, usePlan, usePlanQuiet } from '../lib/plan';
import { plainFailureText } from '../lib/plain-errors';
import { contextRecovery } from '../lib/context-recovery';
import { useLiveStatus } from '../lib/live-status';
import { askQuestion, askSub, askIcon } from '../lib/ask';
import { artifactFromUrl, type PreviewArtifact } from '../lib/artifacts';
@@ -615,7 +616,7 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
const [sentTime, setSentTime] = useState(() => relTime(m.at));
if (m.role === 'user') {
return (<div className="st-msg st-msg--user" onMouseEnter={() => setSentTime(relTime(m.at))} onFocus={() => setSentTime(relTime(m.at))}>
<div className="st-bubble">{tr(m.photos?.length ? <ChatPhotos photos={m.photos}/> : null)}{m.markdown ? <Markdown text={m.content} onOpenLink={onOpenLink}/> : m.content}</div>
<div className="st-bubble">{tr(m.photos?.length ? <ChatPhotos photos={m.photos}/> : null)}{m.markdown ? <Markdown text={m.content} onOpenLink={onOpenLink} imageSession={activeSession}/> : m.content}</div>
<div className="st-msg__meta">
<time dateTime={m.at} title={tr(new Date(m.at).toLocaleString(localeTag()))}>{tr(sentTime)}</time>
<span className="st-msg__actions">
@@ -638,9 +639,9 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
{tr(!(m.timeSkipId && timeSkipReal && !timeSkipDone) && (m.content || m.error || !m.streaming || !liveStatus) ? <div className="st-bubble">
{tr(planCard ? <PlanCard chatId={activeSession} planId={m.planId!}/> : null)}
{tr(m.error ? (<ErrorNotice message={m.error} next={tr("Try sending the message again.")}/>) : null)}
{tr(grouped && !planCard && !m.timeSkipId ? m.blocks!.map((b, i) => b.kind === 'group' ? <ActivityLine key={b.id} group={b} sessionId={activeSession ?? ''} onPreview={onPreview}/> : <Markdown key={i} text={b.text} onOpenLink={onOpenLink} onPreviewHtml={content => onPreview({ id: m.id + '-html-' + i, kind: 'website', title: 'HTML preview', content })}/>) : null)}
{tr(m.content && !planCard && !grouped ? (<Markdown text={m.timeSkipId && !timeSkipReal ? hostLive ? tr('This was a sample run made while the app could not reach its host.') : timeSkipDone ? tr('The result is ready. Switch between Original and Updated, or compare them side by side. This example uses sample outputs and simulated checks.') : tr('Revising the saved draft. Follow the result in the sidebar. This example uses sample outputs and simulated checks.') : m.content} onOpenLink={onOpenLink} onPreviewHtml={content => onPreview({ id: m.id + '-html', kind: 'website', title: 'HTML preview', content })}/>) : !m.error && m.streaming && !liveStatus ? (<span className="typing" aria-label={tr("Generating")}>
{tr(m.error ? (<ErrorNotice message={m.error} detail={m.errorDetail} {...contextRecovery(m.errorCode)}/>) : null)}
{tr(grouped && !planCard && !m.timeSkipId ? m.blocks!.map((b, i) => b.kind === 'group' ? <ActivityLine key={b.id} group={b} sessionId={activeSession ?? ''} onPreview={onPreview}/> : <Markdown key={i} text={b.text} onOpenLink={onOpenLink} imageSession={activeSession} onPreviewHtml={content => onPreview({ id: m.id + '-html-' + i, kind: 'website', title: 'HTML preview', content })}/>) : null)}
{tr(m.content && !planCard && !grouped ? (<Markdown text={m.timeSkipId && !timeSkipReal ? hostLive ? tr('This was a sample run made while the app could not reach its host.') : timeSkipDone ? tr('The result is ready. Switch between Original and Updated, or compare them side by side. This example uses sample outputs and simulated checks.') : tr('Revising the saved draft. Follow the result in the sidebar. This example uses sample outputs and simulated checks.') : m.content} onOpenLink={onOpenLink} imageSession={activeSession} onPreviewHtml={content => onPreview({ id: m.id + '-html', kind: 'website', title: 'HTML preview', content })}/>) : !m.error && m.streaming && !liveStatus ? (<span className="typing" aria-label={tr("Generating")}>
<i />
<i />
<i />
+6
View File
@@ -2467,6 +2467,12 @@ textarea.st-input {
border: 1px solid var(--st-line);
}
.md-image {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.typing {
display: inline-flex;
gap: 4px;
+1 -1
View File
@@ -36,7 +36,7 @@ test('a long chat on a small window compacts before it overflows, and the conver
for (let i = 0; i < 6; i++) {
const frames = [];
await bridge.runTurn({ chatId: 'long', message: `SCRIPT:echo round${i} ${big}`, workspacePath: ws, mode: 'bypass', onFrame: f => frames.push(f) });
replies.push(frames.filter(f => f.type === 'delta').map(f => f.text).join('').slice(0, 12));
replies.push(frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('').slice(0, 12));
assert.equal(frames.at(-1).type, 'done', `round ${i} ended normally`);
}
assert.ok(replies.every(r => /^round\d/.test(r)), 'every round was answered: ' + replies.join(' | '));
+286
View File
@@ -0,0 +1,286 @@
// Context pressure and compaction (2026-10-22 hands-on test): compaction reads the size the model server measured, a summary request
// always fits the window, and a refused prompt is recovered from. The Harness is real; the model server is the scripted worker, set to
// count a prompt the way the engine does (characters over a density) and to refuse one that leaves no room.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startScriptedWorker } from './helpers/scripted-worker.mjs';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { resolveHarness } from '../server/agent/harness-runtime.mjs';
import { buildProfile } from '../server/agent/profile.mjs';
import { clipMessage, densityOf, estimateMessage, isContextOverflow, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent } from '../server/agent/context-budget.mjs';
const HARNESS = process.env.STRATA_HARNESS_DIR;
const skip = !HARNESS || !resolveHarness({ env: process.env }).ok ? 'STRATA_HARNESS_DIR is not set to a built Harness' : false;
test('the profile loads the context plugin, so compaction and /compact read the measured prompt', () => {
const profile = buildProfile({ local: [{ id: 'a', modelId: 'm', baseUrl: 'http://x', context: 65536 }], connections: [] });
assert.match(profile.patch, /"id": "strata-context"/);
assert.match(profile.patch, /strata-context\.mjs/);
});
test('density: the engine said 59,048 tokens for a prompt the Harness priced at 31,794', () => {
const d = densityOf(59048, 31794);
assert.ok(d > 1.85 && d < 1.87, `density ${d}`);
assert.equal(densityOf(0, 100), 1);
assert.equal(densityOf(100, 0), 1);
assert.equal(densityOf(10, 100), 1, 'plain prose is never priced lower than the Harness already does');
assert.equal(densityOf(10000, 100), 6, 'a wild sample is capped');
});
test('pressure: the measured part is exact, what came after is priced at the measured density, and it never reads lower than the Harness', () => {
const sample = { real: 59048, estimated: 31794 };
assert.equal(pressureNow({ sample, now: 31794 }), 59048, 'nothing added since the sample: the measured number');
const grown = pressureNow({ sample, now: 31794 + 1000 });
assert.ok(grown > 59048 + 1800 && grown < 59048 + 1900, `1000 added tokens are worth about 1,860 real ones (${grown})`);
assert.ok(pressureNow({ sample, now: 31794 - 5000 }) < 59048, 'a compaction lowers it');
assert.equal(pressureNow({ sample: null, now: 1234 }), 1234, 'no measurement yet: the Harness price');
assert.equal(pressureNow({ sample: { real: 100, estimated: 5000 }, now: 5000 }), 5000, 'never below the Harness price');
});
test('the compaction threshold on a 64K window leaves room for the reply even when the prompt is measured in real tokens', () => {
// Gyro-S at 64K: the reply keeps 19,660 tokens and compaction keeps 5,734 of headroom (profile.mjs), so it starts at 40,142 REAL tokens.
const window = 65536, reply = 19660, headroom = 5734;
const threshold = Math.floor(Math.min(window * 0.8, window - reply - headroom));
assert.equal(threshold, 40142);
assert.ok(threshold + reply < window, 'the prompt at the threshold plus a full reply still fits');
// The 2026-10-22 chat: 31,794 priced = 59,048 real. It is over the threshold now (before: under it, and the engine refused the next request).
assert.ok(pressureNow({ sample: { real: 59048, estimated: 31794 }, now: 31794 }) > threshold);
});
test('usage helpers: prompt side only, from a message or from its stream', () => {
assert.equal(promptTokensOf({ inputTokens: 100, outputTokens: 50, cacheReadTokens: 30, cacheWriteTokens: 5 }), 135);
assert.equal(promptTokensOf(null), 0);
assert.deepEqual(usageOfEvent({ data: { usage: { inputTokens: 7 } } }), { inputTokens: 7 });
assert.deepEqual(usageOfEvent({ data: { stream: [{ type: 'chunk', chunk: { type: 'text-delta' } }, { type: 'chunk', chunk: { type: 'usage', usage: { inputTokens: 9 } } }] } }), { inputTokens: 9 });
assert.equal(usageOfEvent({ data: {} }), null);
});
test('a refusal for the context window is recognised by its code and by the engine\'s own words', () => {
assert.ok(isContextOverflow({ code: 'CONTEXT_WINDOW_EXCEEDED', message: 'x' }));
assert.ok(isContextOverflow(new Error('400: {"type":"invalid_request_error","message":"request exceeds the context window: prompt (70141 tokens) leaves no room to answer in the context (65536); requests are never truncated"}')));
assert.ok(!isContextOverflow(new Error('fetch failed')));
assert.ok(!isContextOverflow(null));
});
test('a summary request is split into pieces that fit, each message whole when it can be, a huge one cut in the middle', () => {
const text = n => ({ role: 'user', content: [{ type: 'text', text: 'x'.repeat(n) }] });
const many = Array.from({ length: 12 }, () => text(4000)); // 1,000 tokens each by the Harness price
const runs = splitMessages(many, 3500, 1);
assert.deepEqual(runs.map(r => r.length), [3, 3, 3, 3]);
assert.deepEqual(runs.flat().length, 12, 'nothing is dropped');
const dense = splitMessages(many, 3500, 2);
assert.ok(dense.length > runs.length, 'a denser text means more pieces');
const huge = clipMessage(text(400000), 8000, 1);
assert.ok(estimateMessage(huge) <= 8000, `cut to its share (${estimateMessage(huge)})`);
assert.match(huge.content[0].text, /left out of this summary request/);
assert.equal(clipMessage(text(100), 8000, 1).content[0].text.length, 100, 'a small message is untouched');
const oneBig = splitMessages([text(10), text(600000), text(10)], 6000, 1);
assert.ok(oneBig.every(r => r.reduce((n, m) => n + estimateMessage(m), 0) <= 6000), 'every piece fits after the cut');
// A tool call and its result stay together when a clean cut exists.
const call = { role: 'assistant', content: [{ type: 'tool-call', id: 'c', name: 'read', arguments: '{}' }] };
const result = { role: 'tool', toolCallId: 'c', content: [{ type: 'text', text: 'y'.repeat(2000) }] };
const paired = splitMessages([text(2000), call, result, text(2000)], 1200, 1);
assert.ok(paired.every(r => !r.includes(call) || r.includes(result)), 'the call and its result are in one piece');
assert.ok(summaryBudget({ window: 65536, summaryTokens: 5734, toolsTokens: 10133, systemTokens: 400 }) > 40000);
});
/** The scripted model server counting like the engine (3 characters a token here: logs and code) in a 32K window. */
async function rig(t, { window = 32768, charsPerToken = 3, noCount = false } = {}) {
const worker = await startScriptedWorker({ window, charsPerToken, noCount });
const dir = mkdtempSync(join(tmpdir(), 'strata-ctx-'));
const ws = join(dir, 'ws'); mkdirSync(ws, { recursive: true });
const bridge = createAgentBridge({ dataDir: dir, localInstances: () => [{ id: 'i', label: 'S', baseUrl: worker.url, modelId: 'scripted-model', context: window, slots: 2 }], connections: () => [], env: process.env, startTimeoutMs: 90_000 });
t.after(async () => { await bridge.close(); await worker.close(); try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ } });
const send = async (message) => { const frames = []; await bridge.runTurn({ chatId: 'c', message, workspacePath: ws, mode: 'bypass', onFrame: f => frames.push(f) }); return frames; };
const harnessLog = () => { try { return readFileSync(join(dir, 'agent', 'harness.log'), 'utf8'); } catch { return ''; } };
return { worker, bridge, send, ws, harnessLog };
}
const answerOf = frames => frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('');
const dense = 'q=0x1f ok;'; // 10 characters: 2.5 tokens by the Harness price, 3.3 by the model server's count here
test('the plugin loads in the real Harness (a plugin that fails to import would leave compaction as it was, silently)', { skip }, async (t) => {
const { send, harnessLog } = await rig(t);
const frames = await send('SCRIPT:echo hello');
assert.equal(frames.at(-1).type, 'done');
assert.doesNotMatch(harnessLog(), /did not activate|failed to import|strata-context/, harnessLog());
});
test('dense text compacts in time: no request is refused, and every message is answered', { skip }, async (t) => {
const { worker, send } = await rig(t);
const replies = [];
for (let i = 0; i < 8; i++) {
const frames = await send(`SCRIPT:echo round${i} ${dense.repeat(900)}`);
assert.equal(frames.at(-1).type, 'done', `round ${i} ended normally: ${JSON.stringify(frames.at(-1)).slice(0, 300)}`);
replies.push(answerOf(frames).slice(0, 12));
}
assert.ok(replies.every(r => /^round[0-9]/.test(r)), replies.join(' | '));
const refused = worker.requests.filter(r => r.refused);
assert.equal(refused.length, 0, `the model server refused ${refused.length} request(s): ${refused.map(r => r.refused).join(', ')} tokens`);
assert.ok(worker.requests.some(r => r.summary), 'the older history was summarised');
// Compaction starts before the next message would push the prompt past the threshold, so no answer was asked for from a nearly full window.
const peak = Math.max(...worker.requests.filter(r => !r.summary).map(r => Math.round(r.promptChars / 3)));
assert.ok(peak < 32768 - 4000, `no prompt came within 4,000 tokens of the window (peak ${peak})`);
});
test('a chat the model server refused once is not stuck: the next message compacts (the summary itself fits the window) and goes through', { skip }, async (t) => {
// (An engine that cannot count a message before it is sent: the check lets it through, and this is the recovery that remains.)
// 2026-10-22: a 103 KB message made a 70K-token prompt, the 400 left it in the history, and then neither the next message nor /compact
// could go through, because the summary request carried the same history and was refused for its size too.
const { worker, send } = await rig(t, { noCount: true });
assert.equal((await send(`SCRIPT:echo one ${dense.repeat(300)}`)).at(-1).type, 'done');
const huge = await send(`SCRIPT:echo two ${dense.repeat(7000)}`);
assert.equal(huge.at(-1).type, 'done', 'the Harness compacted after the refusal and asked again: ' + JSON.stringify(huge.at(-1)).slice(0, 200));
const small = await send('SCRIPT:echo three please');
assert.equal(small.at(-1).type, 'done', JSON.stringify(small.at(-1)).slice(0, 300));
assert.match(answerOf(small), /three/);
const again = await send('SCRIPT:echo four please');
assert.equal(again.at(-1).type, 'done');
const refused = worker.requests.filter(r => r.refused);
assert.equal(refused.length, 1, `only the first, too large, prompt was refused: ${refused.map(r => r.refused).join(', ')}`);
assert.ok(worker.requests.filter(r => r.summary).every(r => Math.round(r.promptChars / 3) < 32768 - 64), 'every summary request fitted the window');
});
// ------------------------------------------------------------------------------------------------ the message is checked before it is sent
import { COUNT_ABOVE_CHARS, FIT_TEXT, countTokens, decideFit, fill, oversizedReplacement } from '../server/agent/context-fit.mjs';
import { TurnProjector, friendlyFailure, isContextFailure } from '../server/agent/events.mjs';
test('fit: a message that fits is sent, one that fits after a summary is summarised first, one that never fits is not sent', () => {
// Gyro-S at 64K: 65,536 window, a new chat already holds about 12,000 (system prompt and tools), the reply needs 4,096.
assert.equal(decideFit({ window: 65536, pressure: 12000, messageTokens: 5000 }).action, 'send');
assert.equal(decideFit({ window: 65536, pressure: 30000, messageTokens: 25000 }).action, 'send', 'it fits with the reply room');
assert.equal(decideFit({ window: 65536, pressure: 59048, messageTokens: 5000 }).action, 'summarize', 'the chat is full but the message alone is small');
const huge = decideFit({ window: 65536, pressure: 0, messageTokens: 70141 });
assert.equal(huge.action, 'too-large', 'the 103 KB message of 2026-10-22 (70,141 tokens) can never fit a 65,536 window');
assert.equal(decideFit({ window: 65536, pressure: 0, messageTokens: 49000 }).action, 'send');
assert.equal(decideFit({ window: 65536, pressure: 20000, messageTokens: 49000 }).action, 'summarize');
assert.equal(decideFit({ window: 65536, pressure: 20000, messageTokens: 50000 }).action, 'too-large', 'with 12,000 for the empty chat and 4,096 for the reply, 49,440 is the most');
assert.equal(decideFit({ window: 0, messageTokens: 99999 }).action, 'send', 'no window known: nothing is blocked');
assert.equal(decideFit({ window: 8192, pressure: 0, messageTokens: 5000 }).action, 'too-large', 'a small window has less room for a message');
});
test('fit: a big message on a chat already near the compaction threshold summarises first, so the window is never come close to', () => {
// 64K window: the Harness starts summarising at 40,142 (profile.mjs), but looks only before its NEXT step; a message that arrives now is not in that count.
const at = { window: 65536, threshold: 40142, messageTokens: 14400 };
const near = decideFit({ ...at, pressure: 39840 });
assert.equal(near.action, 'summarize'); assert.equal(near.why, 'threshold');
assert.equal(decideFit({ ...at, pressure: 15000 }).action, 'send', 'still under the threshold with the message on top');
assert.equal(decideFit({ ...at, pressure: 13000, messageTokens: 30000 }).action, 'send', 'a chat that holds little beyond its first prompt has nothing worth summarising');
assert.equal(decideFit({ window: 65536, messageTokens: 14400, pressure: 39840 }).action, 'send', 'no threshold given: only the window decides');
assert.equal(decideFit({ ...at, pressure: 59048, messageTokens: 5000 }).why, undefined, 'a refusal-sized case is the old rule, not the threshold');
});
test('count: the engine\'s own number, or nothing when it cannot be had (then nothing is blocked)', async () => {
const sent = [];
const ok = async (url, init) => { sent.push([url, JSON.parse(init.body), init.headers]); return { ok: true, json: async () => ({ input_tokens: 70141 }) }; };
assert.equal(await countTokens({ baseUrl: 'http://127.0.0.1:18500/v1', key: 'k', model: 'm', text: 'hello', fetchImpl: ok }), 70141);
assert.equal(sent[0][0], 'http://127.0.0.1:18500/v1/messages/count_tokens');
assert.deepEqual(sent[0][1].messages, [{ role: 'user', content: 'hello' }]);
assert.equal(sent[0][2].authorization, 'Bearer k');
assert.equal(await countTokens({ baseUrl: 'http://x/v1', text: 'a', fetchImpl: async () => ({ ok: false }) }), null);
assert.equal(await countTokens({ baseUrl: 'http://x/v1', text: 'a', fetchImpl: async () => { throw new Error('refused'); } }), null);
assert.equal(await countTokens({ baseUrl: 'http://x/v1', text: 'a', fetchImpl: async () => ({ ok: true, json: async () => ({}) }) }), null);
assert.ok(COUNT_ABOVE_CHARS >= 1000);
});
test('the two ways out for a message that is too large: a file the assistant reads in parts, or a shortened preview', () => {
const text = 'q=0x1f ok;'.repeat(11000); // 110,000 characters
const file = oversizedReplacement({ text, mode: 'file', tokens: 36000, window: 32768, path: 'C:\\work\\pasted-message.txt' });
assert.match(file.message, /saved it as a file: C:\\work\\pasted-message\.txt/);
assert.ok(file.message.length < 2000, 'the file message is short');
const preview = oversizedReplacement({ text, mode: 'preview', tokens: 36000, window: 32768, pressure: 12000 });
assert.ok(preview.message.length < text.length / 2, 'cut to what the window can hold');
assert.match(preview.message, /characters from the middle are left out/);
assert.ok(preview.message.startsWith('q=0x1f ok;') && preview.message.endsWith('q=0x1f ok;'), 'the start and the end are kept');
const small = oversizedReplacement({ text: 'short', mode: 'preview', tokens: 5, window: 32768 });
assert.equal(small.message, 'short');
assert.equal(fill(FIT_TEXT.tooLarge, ['70,141', '65,536']), 'This message is about 70,141 tokens long, and this model can read about 65,536 at a time. It is too large to send as it is.');
});
test('a context failure is told in plain words, with the engine\'s text kept for Details and a code the page acts on', () => {
const raw = '400: {"type":"invalid_request_error","message":"request exceeds the context window: prompt (85340 tokens) leaves no room to answer in the context (65536); requests are never truncated"}';
assert.ok(isContextFailure({ message: raw }));
assert.ok(isContextFailure({ code: 'CONTEXT_WINDOW_EXCEEDED', message: 'x' }));
assert.ok(!isContextFailure({ message: 'fetch failed' }));
const said = friendlyFailure({ message: raw });
assert.equal(said, FIT_TEXT.full);
assert.doesNotMatch(said, /\{|invalid_request|400/, 'no raw JSON in what the person reads');
const projector = new TurnProjector();
projector.consume({ type: 'event', event: { type: 'turn/end', data: { reason: { kind: 'error', error: { message: raw } } } } });
const [frame] = projector.finish({ model: 'm', contextMax: 65536 });
assert.equal(frame.type, 'error');
assert.equal(frame.code, 'CONTEXT_WINDOW_EXCEEDED');
assert.equal(frame.message, FIT_TEXT.full);
assert.equal(frame.details, raw);
});
test('compaction shows in the chat: a status line while it runs and one plain line when it is done, with how full the chat was', () => {
const projector = new TurnProjector({ window: 65536 });
const statuses = [];
projector.onStatus = u => statuses.push(u);
const usage = { type: 'assistant-stream', frame: { type: 'chunk', attemptId: 'a', chunk: { type: 'usage', usage: { inputTokens: 58000, outputTokens: 1048 } } } };
const live = projector.consume(usage);
assert.deepEqual(live.find(f => f.type === 'context'), { type: 'context', contextTokens: 59048, contextMax: 65536 }, 'the page is told what the context holds after every request');
assert.deepEqual(projector.consume({ type: 'event', event: { type: 'compaction/start', data: { compactionId: 'c' } } }), []);
assert.equal(statuses.at(-1).code, 'compacting');
assert.equal(statuses.at(-1).text, FIT_TEXT.summarizing);
const done = projector.consume({ type: 'event', event: { type: 'compaction/end', data: { compactionId: 'c' } } });
assert.equal(done.length, 1);
assert.equal(done[0].key, FIT_TEXT.summarizedWas);
assert.deepEqual(done[0].values, [90]);
assert.match(done[0].text, /Older messages were summarized to make room\. The chat was 90% full\./);
assert.equal(done[0].own, true);
// A summary that failed says nothing here (the turn's own error does), and an end with no start (an older log) says nothing.
const failed = new TurnProjector({ window: 65536 });
failed.consume({ type: 'event', event: { type: 'compaction/start', data: {} } });
assert.deepEqual(failed.consume({ type: 'event', event: { type: 'compaction/end', data: { error: 'x' } } }), []);
assert.deepEqual(new TurnProjector().consume({ type: 'event', event: { type: 'compaction/end', data: {} } }), []);
});
test('a message too large for the window is not sent: the person is told plainly and nothing reaches the model', { skip }, async (t) => {
const { worker, send } = await rig(t);
const frames = await send(`SCRIPT:echo big ${dense.repeat(11000)}`); // 110,000 characters: about 36,000 tokens in a 32,768 window
const last = frames.at(-1);
assert.equal(last.type, 'error');
assert.equal(last.code, 'CONTEXT_MESSAGE_TOO_LARGE');
assert.match(last.message, /^This message is about 3\d,\d{3} tokens long, and this model can read about 32,768 at a time/);
assert.equal(last.key, FIT_TEXT.tooLarge);
assert.equal(worker.requests.length, 0, 'no prompt was sent to the model');
assert.equal(worker.counts.length, 1, 'the model server counted it once');
});
test('a message that fits only after a summary is sent after one, and the chat says so', { skip }, async (t) => {
const { worker, send } = await rig(t);
for (let i = 0; i < 2; i++) assert.equal((await send(`SCRIPT:echo part${i} ${dense.repeat(900)}`)).at(-1).type, 'done');
const before = worker.requests.length;
const frames = await send(`SCRIPT:echo then ${dense.repeat(4000)}`); // 40,000 characters: about 13,000 tokens on top of about 18,000
assert.equal(frames.at(-1).type, 'done', JSON.stringify(frames.at(-1)).slice(0, 300));
const note = frames.find(f => f.type === 'delta' && f.noteReason === 'compacted');
assert.ok(note, 'a line says the older messages were summarized');
assert.match(note.text, /summarized to make room/);
const later = worker.requests.slice(before);
const firstAnswer = later.findIndex(r => !r.summary);
assert.ok(later.slice(0, firstAnswer).some(r => r.summary), 'the summary was asked for before the message was sent');
assert.equal(worker.requests.filter(r => r.refused).length, 0, 'nothing was refused');
});
test('/compact summarises the older messages with the real figures, and says so plainly when there is nothing to summarise', { skip }, async (t) => {
const { worker, bridge, ws } = await rig(t);
const run = async (line) => { const frames = []; await bridge.compactCommand({ chatId: 'c', message: line, workspacePath: ws, mode: 'bypass', choice: null, onFrame: f => frames.push(f) }); return frames; };
const fresh = await run('/compact');
assert.equal(fresh.find(f => f.type === 'delta').key, FIT_TEXT.noChatYet, 'a chat that has not started has nothing to summarise');
const frames = [];
for (let i = 0; i < 3; i++) { await bridge.runTurn({ chatId: 'c', message: `SCRIPT:echo r${i} ${dense.repeat(700)}`, workspacePath: ws, mode: 'bypass', onFrame: f => frames.push(f) }); }
const done = await run('/compact');
const said = done.find(f => f.type === 'delta');
assert.equal(said.key, FIT_TEXT.summarized, 'the figures are the measured ones: ' + said.text);
const [was, now] = said.values;
assert.ok(was > now && now > 0 && was <= 100, `the chat was ${was}% full and is now ${now}%`);
assert.equal(done.at(-1).type, 'done');
assert.ok(worker.requests.some(r => r.summary));
const again = await run('/compact');
assert.ok([FIT_TEXT.nothingToSummarize, FIT_TEXT.nothingFull, FIT_TEXT.summarized].includes(again.find(f => f.type === 'delta').key), 'a second /compact is answered in plain words');
});
+1 -1
View File
@@ -60,7 +60,7 @@ test('routeFor picks the chat\'s own choice', () => {
test('the agent environment keeps what a shell needs and drops ambient credentials', () => {
const env = agentEnvironment({ PATH: '/bin', HOME: '/h', OPENAI_API_KEY: 'x', GITHUB_TOKEN: 'y', AWS_SECRET_ACCESS_KEY: 'z', SSH_AUTH_SOCK: 's', SystemRoot: 'C:\\Windows' }, { STRATA_API_KEY_X: 'k' });
assert.deepEqual(Object.keys(env).sort(), ['DSH_PERMISSION_MODE', 'DSH_TELEMETRY_DISABLED', 'HOME', 'PATH', 'STRATA_API_KEY_X', 'SystemRoot']);
assert.deepEqual(Object.keys(env).sort(), ['DSH_PERMISSION_MODE', 'DSH_TELEMETRY_DISABLED', 'HOME', 'NODE_USE_SYSTEM_CA', 'PATH', 'STRATA_API_KEY_X', 'SystemRoot']);
});
test('redact removes secret values and launch tokens from log text', () => {
+37 -1
View File
@@ -15,7 +15,7 @@ import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { resolveHarness } from '../server/agent/harness-runtime.mjs';
import { buildProfile, GATE_PLUGIN } from '../server/agent/profile.mjs';
import { STANDARD_PLUGIN_IDS, REMOVED_PLUGIN_IDS, standardPreset } from '../server/agent/preset.mjs';
import { isReadOnlyShell, manualDecision, READ_ONLY_TOOLS } from '../server/agent/plugins/strata-gate.mjs';
import { isReadOnlyShell, manualDecision, maskQuotes, READ_ONLY_TOOLS } from '../server/agent/plugins/strata-gate.mjs';
import { plainToolError, withPlain } from '../server/agent/events.mjs';
const HARNESS = process.env.STRATA_HARNESS_DIR;
@@ -35,6 +35,42 @@ test('the gate: only commands that provably just look are allowed without a ques
assert.equal(isReadOnlyShell('x'.repeat(700)), false, 'a long command is never trusted');
});
test('the gate: quoted text is data, so an & ( ) ; { } in a quoted path does not make a look-only command ask (2026-10-22)', () => {
for (const ok of [
'Get-ChildItem "C:\\Users\\AI-Server\\Downloads\\GUI Q&A" | Select-Object -First 5 Name',
'Get-Content "C:\\Users\\AI-Server\\Downloads\\GUI Q&A\\a.txt"',
"Get-ChildItem 'C:\\Users\\x\\Notes (old) {draft}; v2'",
'Select-String -Path a.txt "a|b"',
'Select-String needle "C:\\x\\a & b.txt" | Measure-Object',
'git log --oneline "docs/Q&A.md"',
'cat "my file (1).txt"',
"Get-ChildItem 'it''s here'",
]) assert.equal(isReadOnlyShell(ok), true, ok);
// Quoting does not hide anything that acts: outside the quotes the same characters still ask, and inside double quotes an expansion does.
for (const bad of [
'Get-ChildItem C:\\Q&A',
'Get-ChildItem "C:\\a" ; Remove-Item x',
'Get-ChildItem "C:\\a" & calc',
'Get-ChildItem "C:\\a" > out.txt',
'Get-ChildItem "$env:USERPROFILE"',
'Get-ChildItem "$(whoami)"',
'Get-ChildItem "a`b"',
"Get-ChildItem 'unterminated",
'Get-ChildItem "unterminated',
'git diff "--output=out.txt"',
'git diff --out"put"=out.txt',
'git diff --"output"=out.txt',
'Get-Content "a"b',
'"Remove-Item" x',
'Set-Content "a.txt" "x"',
'Get-ChildItem "a" | Remove-Item',
'curl "http://x"',
]) assert.equal(isReadOnlyShell(bad), false, bad);
assert.equal(maskQuotes('Get-ChildItem "C:\\Q&A" | Select-Object Name'), 'Get-ChildItem "_" | Select-Object Name');
assert.equal(maskQuotes("echo 'a' \"b\""), 'echo "_" "_"');
assert.equal(maskQuotes('echo "$HOME"'), null);
});
test('the gate: every tool outside the read-only list asks, including unknown and connected ones', () => {
for (const name of READ_ONLY_TOOLS) assert.equal(manualDecision({ name, arguments: {} }), null, name);
for (const name of ['write', 'edit', 'job_kill', 'mcp__github__create_issue', 'something_new']) assert.equal(manualDecision({ name, arguments: {} })?.kind, 'ask', name);
+16
View File
@@ -26,3 +26,19 @@ test('a silent model ends the turn with a plain message, and the chat works agai
await bridge.runTurn({ chatId: 'w1', message: 'SCRIPT:echo fine', workspacePath: ws, mode: 'bypass', onFrame: f => again.push(f) });
assert.match(again.filter(f => f.type === 'delta').map(f => f.text).join(''), /fine/);
});
test('engine progress keeps a long prompt read alive past the stall limit, then the normal limit applies (#3623)', { skip }, async (t) => {
const worker = await startScriptedWorker({});
const dir = mkdtempSync(join(tmpdir(), 'strata-wd-')); const ws = join(dir, 'ws'); mkdirSync(ws, { recursive: true });
const started = Date.now();
const bridge = createAgentBridge({ dataDir: dir, localInstances: () => [{ id: 'i', label: 'S', baseUrl: worker.url, modelId: 'scripted-model', context: 32768, slots: 2 }], connections: () => [], env: process.env, startTimeoutMs: 90_000, stallMs: 2500, stallToolMs: 6000, engineGraceMs: 60_000,
// The engine moves for 7 s (a prompt being read), then goes quiet.
engineProgress: () => (Date.now() - started < 7_000 ? `reading:${Date.now()}` : null) });
t.after(async () => { await bridge.close(); await worker.close(); try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ } });
const out = [];
const t0 = Date.now();
await bridge.runTurn({ chatId: 'w2', message: 'SCRIPT:hang', workspacePath: ws, mode: 'bypass', onFrame: f => out.push(f) });
assert.ok(Date.now() - started > 7_000, 'the 2.5 s stall limit did not fire while the engine was reading');
assert.ok(Date.now() - t0 < 40_000, 'and it still ended by itself once the engine went quiet');
assert.match(out.find(f => f.type === 'error').message, /stopped answering/);
});
+12
View File
@@ -173,3 +173,15 @@ test('an AMD build on an NVIDIA PC is not unpacked and the sentence names the ri
test('wrongMakeNotice names the make, not a list of reasons', () => {
assert.equal(wrongMakeNotice({ backend: 'cuda', archs: ['sm_86'] }, 'hip'), 'This Strata is for NVIDIA cards. Your PC has an AMD card: download the AMD version from https://github.com/Niko1221/Strata/releases');
});
test('Windows: the display adapters decide the make, and a leftover nvidia-smi does not outvote them (#3688)', async () => {
const { gpuBackendHere, windowsDisplayVendors } = await import('../server/bundled-engine.mjs');
assert.equal(gpuBackendHere({}, { vendors: new Set(['1002']) }), 'hip');
assert.equal(gpuBackendHere({}, { vendors: new Set(['10de', '1002']) }), 'cuda');
assert.equal(gpuBackendHere({}, { vendors: new Set(['8086']) }), 'sycl');
assert.equal(gpuBackendHere({ STRATA_ENGINE_BACKEND: 'cuda' }, { vendors: new Set(['1002']) }), 'cuda', 'an explicit choice wins');
const out = 'MatchingDeviceId REG_SZ PCI/VEN_1002&DEV_7551&SUBSYS_0' + String.fromCharCode(10) + 'MatchingDeviceId REG_SZ PCI/VEN_8086&DEV_46A6';
assert.deepEqual([...windowsDisplayVendors({ platform: 'win32', run: () => out })].sort(), ['1002', '8086']);
assert.equal(windowsDisplayVendors({ platform: 'linux', run: () => { throw new Error('must not run'); } }).size, 0);
assert.equal(windowsDisplayVendors({ platform: 'win32', run: () => { throw new Error('no registry'); } }).size, 0);
});
@@ -122,6 +122,11 @@ test('the Settings update check maps the feed to the existing result shape', asy
assert.equal(result.engine.status, 'unavailable');
assert.doesNotMatch(result.engine.message, /windows-x86_64/);
assert.match(result.engine.details, /windows-x86_64/, 'the technical reason is kept for the Details disclosure');
result = await checkComponentUpdates({ control: make(report({ engine: { installed: { version: '0.1.46', backend: 'hip' }, offered: null, status: 'wrong-make', note: 'The engine installed here is for AMD cards, but this update would give your PC NVIDIA one. Nothing was changed.', reasons: ['x'] } })) });
assert.equal(result.engine.status, 'unavailable');
assert.equal(result.engine.canInstall, false);
assert.match(result.engine.message, /Nothing was changed/, 'the app says why in plain words, not that no engine exists for the card');
assert.doesNotMatch(result.engine.message, /isn't an engine for this PC/);
result = await checkComponentUpdates({ control: make(new Error('this build trusts no update key')) });
assert.equal(result.strata.status, 'error');
assert.equal(result.engine.status, 'error');
+119
View File
@@ -0,0 +1,119 @@
// The page side of the context fixes (2026-10-22 hands-on test): what the error card offers, what the context meter reads while a turn runs,
// and that every sentence the host or the page says about the context is in the catalog in all eight languages.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
import { FIT_TEXT } from '../server/agent/context-fit.mjs';
const here = rel => fileURLToPath(new URL(rel, import.meta.url));
const read = rel => readFileSync(here(rel), 'utf8').replace(/\r\n/g, '\n');
globalThis.window ??= globalThis; globalThis.localStorage ??= { getItem: () => null, setItem: () => {} };
globalThis.navigator ??= { language: 'en' };
async function load(file, stubs = {}) {
const result = await build({
entryPoints: [here(`../src/lib/${file}`)], bundle: true, format: 'esm', platform: 'node', write: false, logLevel: 'silent', loader: { '.json': 'json' },
plugins: [{ name: 'stubs', setup(b) { b.onResolve({ filter: /^\.\/(store)$/ }, () => ({ path: 'store', namespace: 'stub' })); b.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ contents: stubs.store, loader: 'js' })); } }],
});
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`);
}
const calls = [];
globalThis.__calls = calls;
const recovery = await load('context-recovery.ts', {
store: `export const sendOversized = (mode) => globalThis.__calls.push(['oversized', mode]);
export const summarizeAndContinue = () => globalThis.__calls.push(['summarize']);
export const startNewChat = () => globalThis.__calls.push(['new-chat']);`,
});
const { chatMetrics } = await load('chat-metrics.ts');
test('a conversation that no longer fits offers "Summarize and continue" and "Start a new chat", never "try again"', () => {
const r = recovery.contextRecovery('CONTEXT_WINDOW_EXCEEDED');
assert.equal(r.action, 'Summarize and continue');
assert.equal(r.secondary.label, 'Start a new chat');
assert.match(r.next, /Summarize it now and continue, or start a new chat\./);
assert.doesNotMatch(r.next, /again/i);
calls.length = 0;
r.onRetry(); r.secondary.onClick();
assert.deepEqual(calls, [['summarize'], ['new-chat']]);
});
test('a message that is too large offers the two ways that fit: a file the assistant reads in parts, or a shortened preview', () => {
const r = recovery.contextRecovery('CONTEXT_MESSAGE_TOO_LARGE');
assert.equal(r.action, 'Attach as a file');
assert.equal(r.secondary.label, 'Send a shortened preview');
calls.length = 0;
r.onRetry(); r.secondary.onClick();
assert.deepEqual(calls, [['oversized', 'file'], ['oversized', 'preview']]);
});
test('any other error keeps the card as it was: one sentence, no buttons of ours', () => {
for (const code of [undefined, 'UNKNOWN', 'AGENT_STOPPED']) {
const r = recovery.contextRecovery(code);
assert.equal(r.next, 'Try sending the message again.');
assert.equal(r.onRetry, undefined);
assert.equal(r.secondary, undefined);
}
});
test('the chat shows the error card with Details and the recovery buttons, and the card can hold a second button', () => {
const chat = read('../src/screens/Chat.tsx');
assert.match(chat, /<ErrorNotice message=\{m\.error\} detail=\{m\.errorDetail\} \{\.\.\.contextRecovery\(m\.errorCode\)\}\/>/);
const ui = read('../src/components/ui.tsx');
assert.match(ui, /secondary\?: \{ label: string; onClick: \(\) => void \}/);
assert.match(ui, /<Btn size="sm" variant="ghost" onClick=\{secondary\.onClick\}>/);
});
test('the store keeps the code and the engine\'s text of an error, shows a sentence the host wrote in the person\'s language, and follows the context live', () => {
const store = read('../src/lib/store.ts');
assert.match(store, /typeof frame\.key === 'string' \? tr\(frame\.key,/);
assert.match(store, /errorCode: frame\.code/);
assert.match(store, /errorDetail: frame\.details/);
assert.match(store, /type === 'context'/);
const summarize = store.slice(store.indexOf('export async function summarizeAndContinue'), store.indexOf('export async function sendOversized'));
assert.ok(summarize.indexOf("sendMessage('/compact')") > 0 && summarize.indexOf("sendMessage('Please answer my last message now.')") > summarize.indexOf("sendMessage('/compact')"), 'summarise first, then ask for the answer');
assert.doesNotMatch(summarize, /failed.prompt|dropFailedExchange/, 'the refused question is not sent a second time (it is already in the conversation, and a large one twice would not fit)');
const oversized = store.slice(store.indexOf('export async function sendOversized'), store.indexOf('export function startNewChat'));
assert.match(oversized, /'\/api\/chat\/oversize'/);
assert.ok(oversized.indexOf('dropFailedExchange') > oversized.indexOf("'/api/chat/oversize'"), 'the failed message stays until the shorter one is ready');
});
const reply = (extra) => ({ id: 'r', role: 'assistant', content: '', at: '2026-10-22T12:00:00Z', ...extra });
const session = messages => ({ id: 's', title: 't', createdAt: '', updatedAt: '', messages, tokensIn: 0, tokensOut: 0, statsAvailable: true });
test('the context meter follows the turn that is running: a tool step does not read as 1%', () => {
// The 2026-10-22 chat: an earlier reply left the meter at 1% (about 600 tokens), then a turn with tools ran on a 65,536 window.
const earlier = reply({ id: 'a', inputTokens: 590, tokens: 20, contextUsed: 610, contextMax: 65536 });
const running = reply({ id: 'b', streaming: true, contextMax: 65536 });
const before = chatMetrics(session([earlier, running]), null, 65536);
assert.equal(Math.round(before.pct), 1, 'with nothing reported yet for the turn, the earlier reply is all there is');
const stepped = reply({ id: 'b', streaming: true, contextMax: 65536, contextUsed: 43000 });
const during = chatMetrics(session([earlier, stepped]), null, 65536);
assert.equal(during.used, 43000);
assert.equal(Math.round(during.pct), 66);
const monitor = { phase: 'idle', context: { sessionId: 's', usedTokens: 600, maxTokens: 65536 } };
assert.equal(chatMetrics(session([earlier, stepped]), monitor, 65536).used, 43000, 'the engine\'s figure for the request in flight does not override the turn\'s own');
const done = reply({ id: 'b', inputTokens: 43000, tokens: 90, contextUsed: 43090, contextMax: 65536 });
assert.equal(chatMetrics(session([earlier, done]), null, 65536).used, 43090, 'when the turn has ended its final figure stands');
});
test('every sentence about the context is in the catalog in all eight languages, with the same slots', () => {
const langs = ['en', 'zh', 'ja', 'de', 'es', 'ru', 'pt', 'ko'];
const catalogs = Object.fromEntries(langs.map(l => [l, JSON.parse(read(`../src/locales/${l}.json`))]));
const idOf = text => Object.entries(catalogs.en).find(([, v]) => v === text)?.[0];
const sentences = [...Object.values(FIT_TEXT), 'Attach as a file', 'Send a shortened preview', 'Summarize and continue', 'Start a new chat',
'The message could not be prepared. Try a shorter message.', 'The last Strata session is still closing down.', 'Wait a moment and try again. If it keeps happening, restart the computer.'];
const slots = t => (t.match(/\{\d+\}/g) ?? []).sort().join();
for (const text of sentences) {
const id = idOf(text);
assert.ok(id, `"${text.slice(0, 60)}" is in the English catalog`);
for (const l of langs) {
const t = catalogs[l][id];
assert.ok(typeof t === 'string' && t.trim(), `${id} in ${l}`);
assert.equal(slots(t), slots(text), `${id} in ${l} has the same slots`);
if (l !== 'en' && text.length > 25) assert.notEqual(t, text, `${id} is translated in ${l}`);
}
}
});
@@ -0,0 +1,144 @@
// T7 (tools and images review, 2026-10-22): a custom command tool runs in the shell that suits it, in the folder it needs, and with a time
// limit the person can raise. These tests run real commands; they use only what every machine has (node).
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createAgentTools } from '../server/agent/tools-settings.mjs';
import { runCommand, planShell, looksLikePowerShell, resolveCwd, clampTimeoutSec, DEFAULT_TIMEOUT_SEC } from '../server/agent/custom-command-run.mjs';
const SERVER = fileURLToPath(new URL('../server/agent/custom-tools-mcp.mjs', import.meta.url));
const node = JSON.stringify(process.execPath);
const WIN = process.platform === 'win32';
test('a Windows command that reads as PowerShell goes to PowerShell, the rest to cmd.exe, and a named shell is honoured', () => {
for (const c of ['Get-Content notes.txt', 'Start-Process notepad', 'Get-ChildItem | Where-Object { $_.Length -gt 1 }', '$env:HOME', 'echo $(Get-Date)'])
assert.ok(looksLikePowerShell(c), c);
for (const c of ['dir', 'type notes.txt', 'python tools\\foo.py --x', 'npm run build && echo done', 'echo %PATH%', 'git status'])
assert.ok(!looksLikePowerShell(c), c);
const ps = planShell('Get-Content x', { platform: 'win32' });
assert.match(ps.file, /^(pwsh|powershell\.exe)$/);
assert.deepEqual(ps.args.slice(0, 5), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']);
assert.equal(ps.args.at(-1), 'Get-Content x');
assert.deepEqual(planShell('dir', { platform: 'win32' }), { file: 'dir', args: [], shell: true, name: 'cmd.exe' });
assert.equal(planShell('dir', { platform: 'win32', shell: 'powershell' }).args.at(-1), 'dir');
assert.equal(planShell('Get-Content x', { platform: 'win32', shell: 'cmd' }).shell, true);
assert.deepEqual(planShell('ls -la', { platform: 'linux' }), { file: 'ls -la', args: [], shell: true, name: 'sh' });
assert.deepEqual(planShell('ls | wc -l', { platform: 'linux', shell: 'bash' }), { file: 'bash', args: ['-c', 'ls | wc -l'], shell: false, name: 'bash' });
assert.equal(planShell('Get-Content x', { platform: 'linux' }).name, 'sh', 'on Linux and macOS a PowerShell-looking command is not rerouted');
});
test('the time allowed is 2 minutes unless the tool says more, at most an hour', () => {
assert.equal(DEFAULT_TIMEOUT_SEC, 120);
assert.equal(clampTimeoutSec(undefined), 120);
assert.equal(clampTimeoutSec(600), 600);
assert.equal(clampTimeoutSec(99999), 3600);
assert.equal(clampTimeoutSec(0), 120);
assert.equal(clampTimeoutSec('abc'), 120);
});
test('a command runs in the folder the call names, then the tool\'s, then the home folder; a missing folder is a plain refusal', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmd-')));
const other = join(dir, 'other'); mkdirSync(other);
try {
const pwd = `${node} -e "console.log(process.cwd())"`;
const here = await runCommand({ command: pwd, cwd: dir });
assert.equal(realpathSync(here.text), dir);
const named = await runCommand({ command: pwd, cwd: dir, callCwd: other });
assert.equal(realpathSync(named.text), other, 'the folder named in the call wins');
const home = await runCommand({ command: pwd });
assert.equal(realpathSync(home.text), realpathSync(homedir()));
const gone = await runCommand({ command: pwd, cwd: join(dir, 'nope') });
assert.equal(gone.ok, false);
assert.match(gone.text, /does not exist, so the command was not run/);
assert.deepEqual(resolveCwd(''), { cwd: homedir() });
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('a relative path in the command finds its file in the tool\'s folder', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmd-')));
try {
mkdirSync(join(dir, 'tools'));
writeFileSync(join(dir, 'tools', 'foo.js'), 'console.log("from foo " + (process.env.STRATA_INPUT || ""));');
const r = await runCommand({ command: `${node} tools/foo.js`, cwd: dir, input: 'hello' });
assert.deepEqual([r.ok, r.text], [true, 'from foo hello']);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('a command over the limit is ended and says so; output over the cap says it was cut', async () => {
const slow = await runCommand({ command: `${node} -e "setTimeout(()=>{}, 30000)"`, timeoutSec: 1 });
assert.equal(slow.ok, false);
assert.match(slow.text, /stopped after 1 seconds/);
const big = await runCommand({ command: `${node} -e "process.stdout.write('x'.repeat(50000))"` });
assert.equal(big.ok, true);
assert.match(big.text, /\(The output was cut after 20,000 characters\.\)$/);
assert.ok(big.text.length < 20100);
});
test('PowerShell commands run on Windows (Get-ChildItem, $env:)', { skip: !WIN && 'PowerShell is checked on Windows' }, async () => {
const r = await runCommand({ command: 'Write-Output ("a" + $env:STRATA_INPUT)', input: 'b' });
assert.deepEqual([r.ok, r.text], [true, 'ab']);
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmd-')));
try {
writeFileSync(join(dir, 'note.txt'), 'hi');
const read = await runCommand({ command: 'Get-Content note.txt', cwd: dir });
assert.deepEqual([read.ok, read.text], [true, 'hi']);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('Settings keeps a command tool\'s folder, shell and time, validates them, and an edit that does not send them keeps them', () => {
let stored;
const dir = mkdtempSync(join(tmpdir(), 'strata-cmdset-'));
try {
const tools = createAgentTools({ read: () => stored, write: v => { stored = v; }, dataDir: dir });
tools.addCustom({ name: 'Build', about: 'Builds the project.', kind: 'command', command: 'npm run build', cwd: 'C:\\work\\app', shell: 'cmd', timeoutSec: 900 });
let tool = tools.view().custom[0];
assert.deepEqual([tool.cwd, tool.shell, tool.timeoutSec], ['C:\\work\\app', 'cmd', 900]);
tools.editCustom(tool.id, { name: 'Build', about: 'Builds the project, quickly.', kind: 'command', command: 'npm run build' }); // the form sends none of the three
tool = tools.view().custom[0];
assert.deepEqual([tool.about, tool.cwd, tool.shell, tool.timeoutSec], ['Builds the project, quickly.', 'C:\\work\\app', 'cmd', 900]);
assert.throws(() => tools.editCustom(tool.id, { name: 'Build', about: 'x', kind: 'command', command: 'npm run build', timeoutSec: 99999 }), /1 to 3600/);
assert.throws(() => tools.editCustom(tool.id, { name: 'Build', about: 'x', kind: 'command', command: 'npm run build', shell: 'fish' }), /Choose how the command runs/);
tools.addCustom({ name: 'Plain', about: 'Plain.', kind: 'command', command: 'echo hi' });
const plain = tools.view().custom[1];
assert.deepEqual([plain.cwd, plain.shell, plain.timeoutSec], ['', 'auto', 120]);
// the tool server's file carries them
const file = JSON.parse(require_read(tools.filePath));
assert.deepEqual(file.commands.build, { description: 'Builds the project, quickly.', command: 'npm run build', cwd: 'C:\\work\\app', shell: 'cmd', timeoutSec: 900 });
} finally { rmSync(dir, { recursive: true, force: true }); }
});
import { readFileSync } from 'node:fs';
const require_read = file => readFileSync(file, 'utf8');
test('the tool server over stdio: the call may name a folder, and the tool\'s own settings apply', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmdsrv-')));
const work = join(dir, 'work'); mkdirSync(work);
const file = join(dir, 'tools.json');
writeFileSync(file, JSON.stringify({ commands: {
where: { description: 'Prints its folder.', command: `${node} -e "console.log(process.cwd())"` },
pinned: { description: 'Runs in work.', command: `${node} -e "console.log(process.cwd())"`, cwd: work },
} }));
const child = spawn(process.execPath, [SERVER, 'commands', file], { stdio: ['pipe', 'pipe', 'inherit'] });
const lines = []; let buffer = '';
child.stdout.on('data', d => { buffer += d; let i; while ((i = buffer.indexOf('\n')) >= 0) { lines.push(JSON.parse(buffer.slice(0, i))); buffer = buffer.slice(i + 1); } });
const ask = async (id, method, params) => {
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
for (let n = 0; n < 200; n++) { const hit = lines.find(l => l.id === id); if (hit) return hit.result; await new Promise(r => setTimeout(r, 25)); }
throw new Error('no answer');
};
try {
const listed = await ask(1, 'tools/list', {});
assert.ok(listed.tools.every(t => t.inputSchema.properties.cwd?.type === 'string'), 'the optional folder is offered');
const pinned = await ask(2, 'tools/call', { name: 'pinned', arguments: {} });
assert.equal(realpathSync(pinned.content[0].text), work);
const named = await ask(3, 'tools/call', { name: 'where', arguments: { cwd: dir } });
assert.equal(realpathSync(named.content[0].text), dir);
const missing = await ask(4, 'tools/call', { name: 'where', arguments: { cwd: join(dir, 'nope') } });
assert.equal(missing.isError, true);
assert.match(missing.content[0].text, /does not exist/);
} finally { child.kill(); rmSync(dir, { recursive: true, force: true }); }
});
+2 -2
View File
@@ -45,11 +45,11 @@ test('withoutDraftLayer takes out --mtp, --spec-min-p and --batch-mtp and keeps
test('"Draft layer (MTP)" is a setting of the app, not frozen, and "MTP per request" is not frozen either', async () => {
assert.ok(NATIVE_FIELDS.some(f => f.id === 'mtp' && f.type === 'boolean'));
assert.deepEqual(APP_OWNED_FIELDS.sort(), ['batchMtp', 'mtp']);
assert.deepEqual(APP_OWNED_FIELDS.sort(), ['batchMtp', 'drafting', 'mtp']);
const f = fixture();
try {
await f.native.refresh(true);
for (const id of ['mtp', 'batchMtp']) assert.equal(f.native.state().fields.find(x => x.id === id).supported, true, `${id} must not be disabled`);
for (const id of ['mtp', 'batchMtp', 'drafting']) assert.equal(f.native.state().fields.find(x => x.id === id).supported, true, `${id} must not be disabled`);
assert.deepEqual(validateNativeSettings({ mtp: false }), { mtp: false });
assert.equal(checkRunConfig(JSON.stringify({ exe: 'e', tokenizer: 't', args: [], mtp: false })).issues.length, 0, 'the file check knows the key');
} finally { await f.native.close(); f.cleanup(); }
+123
View File
@@ -0,0 +1,123 @@
// The Drafting choice in the app (MTP / DFlash2 / Off / Auto): the Models screen row, the setting the host saves, what the host asks Control before
// a load, the download plan line, and the words in all eight languages. The table of which card gets which drafter lives in Python
// (strata_control/drafting.py, tests/terminal/test_drafting.py); the app never reads it.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { NATIVE_FIELDS, APP_OWNED_FIELDS, validateNativeSettings, withoutDraftLayer } from '../server/native-settings.mjs';
import { createControl } from '../server/control-runtime.mjs';
const read = rel => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8').replace(/\r\n/g, '\n');
test('Drafting is a setting of the model with four choices, saved by the app itself', () => {
const field = NATIVE_FIELDS.find(f => f.id === 'drafting');
assert.ok(field);
assert.deepEqual(field.choices, ['auto', 'mtp', 'dflash2', 'off']);
assert.equal(field.type, 'enum');
assert.match(field.hint, /Auto \(recommended\)/);
assert.match(field.hint, /Qwen3\.8-Flash-Next only/);
assert.ok(APP_OWNED_FIELDS.includes('drafting'), 'the engine\'s own source never lists it: the app applies it');
for (const ok of ['auto', 'mtp', 'dflash2', 'off']) assert.deepEqual(validateNativeSettings({ drafting: ok }), { drafting: ok });
assert.deepEqual(validateNativeSettings({ drafting: null }), { drafting: null }, 'cleared = Auto');
assert.throws(() => validateNativeSettings({ drafting: 'fastest' }), /Drafting: choose a supported option/);
});
test('Off leaves the draft layer out of every start, the same as "Draft layer (MTP)" off', () => {
assert.deepEqual(withoutDraftLayer(['--pack', 'p', '--mtp', '/m', '--spec', '4', '--spec-min-p', '0.5', '--batch-mtp']), ['--pack', 'p', '--spec', '4']);
const source = read('../server/native-control.mjs');
assert.match(source, /\(cfg\.mtp===false\|\|cfg\.drafting==='off'\)&&Array\.isArray\(cfg\.args\)\)cfg\.args=withoutDraftLayer\(cfg\.args\)/);
});
test('the Models screen offers the choice next to context, DFlash2 only for Qwen3.8-Flash-Next, and carries it to the plan and the load', () => {
const screen = read('../src/components/NativeModels.tsx');
assert.match(screen, /draftingOptions\(selectedSize\.sourceFamily \?\? selectedFamily\?\.family, native\?\.capabilities\?\.dflash2\)/);
assert.match(screen, /draftingWords: Record<string, string> = \{ auto: 'Auto \(recommended\)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' \}/);
const row = screen.slice(screen.indexOf('<div className="native-choice__row">'), screen.indexOf('{roleCards.length > 1 ? <GpuRoles'));
assert.ok(row.indexOf('Conversation context') < row.indexOf('tr("Drafting")') && row.indexOf('tr("Drafting")') < row.indexOf('Computer use'), 'in the same row, between context and computer use');
assert.match(row, /<Select ariaLabel=\{tr\("Drafting"\)\}/, 'the existing Select, no new component');
assert.match(screen, /setDrafting\(installed\.drafting \?\? 'auto'\)/, 'a model shows what it was saved with');
assert.match(screen, /const downloadValues = \{ \.\.\.values, \.\.\.\(draftingChoices \? \{ drafting \} : \{\}\)/);
assert.match(screen, /\.\.\.\(draftingChoices \? \{ values: \{ drafting \} \} : \{\}\)/);
assert.match(screen, /Choosing DFlash2 also downloads its drafter: about \{0\} GB, once\. If it does not fit in graphics memory, the model uses the MTP instead\./);
assert.match(screen, /loadedSelected\?\.metrics\?\.engine\?\.drafting_note/, 'what the engine said is shown');
});
test('the host asks Control before a load, and Control decides (the app never reads the table)', async () => {
const calls = [];
const control = createControl({ control: { dir: 'C:/c', python: 'py', cli: 'strata.py' }, run: async (args, options) => {
calls.push({ args, options });
return args[1] === 'plan'
? { code: 0, json: { ok: true, needed: true, gb: 1.2, line: 'Drafting: DFlash2' }, text: '', stderr: '' }
: { code: 0, json: { ok: true, event: 'result', pack: 'C:/d/dflash2/x.pack' }, text: '', stderr: '' };
} });
const plan = await control.draftingPlan({ mode: 'dflash2', family: 'gyro', dataDir: 'C:/d' });
assert.deepEqual(calls[0].args, ['drafting', 'plan', '--mode', 'dflash2', '--family', 'gyro', '--data-dir', 'C:/d', '--output', 'json']);
assert.equal(plan.needed, true);
const events = [];
await control.draftingFetch({ configPath: 'C:/w/cfg.json', mode: 'dflash2', onEvent: e => events.push(e) });
assert.deepEqual(calls[1].args, ['drafting', 'fetch', '--config', 'C:/w/cfg.json', '--mode', 'dflash2', '--if-needed', '--output', 'jsonl']);
assert.equal(calls[1].options.acquire, true, 'only this explicit step may reach the network');
assert.equal(calls[0].options.acquire, undefined, 'planning never does');
const failing = createControl({ control: { dir: 'C:/c', python: 'py', cli: 'strata.py' }, run: async () => ({ code: 5, json: { ok: false, error: { code: 'drafting_failed', message: 'The DFlash2 download did not finish.' } }, text: '', stderr: '' }) });
await assert.rejects(failing.draftingFetch({ configPath: 'x' }), /DFlash2 download did not finish/);
});
test('a load fetches the drafter first, never stops for it, and says what ran', () => {
const source = read('../server/native-control.mjs');
const prepare = source.slice(source.indexOf('async function prepareDrafter'), source.indexOf('function rememberSetting'));
assert.match(prepare, /rememberSetting\(configFile,'drafting',asked===null\|\|asked==='auto'\?null:asked\)/, 'the choice is kept for the next load');
assert.match(prepare, /cfg\.drafting==='auto'\|\|cfg\.drafting==='mtp'\|\|cfg\.drafting==='off'\)return/, 'Auto, MTP and Off never download');
assert.match(prepare, /control\.draftingFetch\(/);
assert.match(prepare, /catch\(error\)\{if\(job\.state==='running'\)\{job\.drafterNote=error\.message;/, 'a failed download leaves the MTP and the job says so');
assert.match(source, /await prepareDrafter\(job,args,cfg,configFile\)/);
assert.match(source, /async function noteDrafting\(job,instance,key\)/);
assert.match(source, /instance\.status='ready';instance\.phase='idle';await noteDrafting\(job,instance,key\)/);
assert.match(source, /choice!=='auto'&&typeof control\.draftingPlan==='function'/, 'the plan lists the drafter only when it is chosen and Control says it is needed');
assert.match(source, /drafting:cfg\.drafting\?\?'auto'/, 'the installed model reports its saved choice');
});
test('every sentence of the Drafting choice is in the catalog in all eight languages, with the same slots', () => {
const langs = ['en', 'zh', 'ja', 'de', 'es', 'ru', 'pt', 'ko'];
const catalogs = Object.fromEntries(langs.map(l => [l, JSON.parse(read(`../src/locales/${l}.json`))]));
const idOf = text => Object.entries(catalogs.en).find(([, v]) => v === text)?.[0];
const sentences = ['Drafting', 'Auto (recommended)', 'MTP', 'DFlash2',
"Drafting: MTP (DFlash2 didn't fit)", "Drafting: MTP (DFlash2 isn't available in this mode)", 'Drafting: MTP (DFlash2 is for Qwen3.8-Flash-Next only)',
"Drafting: MTP (the DFlash2 drafter isn't downloaded yet)",
'Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.',
'Downloading the DFlash2 drafter…', 'Packing the DFlash2 drafter for the engine…', 'The DFlash2 drafter is ready.',
'The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…', 'Loading the model…'];
const slots = t => (t.match(/\{\d+\}/g) ?? []).sort().join();
for (const text of sentences) {
const id = idOf(text);
assert.ok(id, `"${text.slice(0, 60)}" is in the English catalog`);
for (const l of langs) {
const t = catalogs[l][id];
assert.ok(typeof t === 'string' && t.trim(), `${id} in ${l}`);
assert.equal(slots(t), slots(text), `${id} in ${l} has the same slots`);
if (l !== 'en' && text.length > 25) assert.notEqual(t, text, `${id} is translated in ${l}`);
}
}
});
test('DFlash2 is offered only for Qwen3.8-Flash-Next AND only once the engine serves it; until then the setting is Auto / MTP / Off', async () => {
const { transform } = await import('esbuild');
const code = (await transform(read('../src/lib/drafting.ts'), { loader: 'ts', format: 'esm' })).code;
const { draftingChoices } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
const off = ['auto', 'mtp', 'off'], all = ['auto', 'mtp', 'dflash2', 'off'];
// the engine does not serve it (this release): never offered
for (const family of ['qwen', 'gyro', 'coder', 'swift', undefined]) {
assert.deepEqual(draftingChoices(family, false), off, `${family}, not served`);
assert.deepEqual(draftingChoices(family, undefined), off, `${family}, Control has not answered`);
assert.deepEqual(draftingChoices(family, null), off);
}
// the engine serves it: only the models it is for
assert.deepEqual(draftingChoices('qwen', true), all);
assert.deepEqual(draftingChoices('gyro', true), all);
for (const family of ['coder', 'swift', 'unsloth', 'nvfp4', undefined]) assert.deepEqual(draftingChoices(family, true), off, `${family}, served`);
// the app learns it from Control, once, and false until Control answers
const host = read('../server/native-control.mjs');
assert.match(host, /dflash2:dflash2Served===true/);
assert.match(host, /dflash2Served=p\.serves===true/);
assert.match(host, /function probeDrafting\(\)\{if\(!packaged\|\|dflash2Served!==null/);
});
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { engineProgressMark } from '../server/agent/engine-progress.mjs';
import { readFileSync } from 'node:fs';
test('the mark moves with the prompt read and with generation, and is null when the engine is idle or silent', () => {
const at = live => engineProgressMark({ metrics: { live } });
assert.equal(engineProgressMark(null), null);
assert.equal(engineProgressMark({ metrics: null }), null);
assert.equal(at({ state: 'idle' }), null);
assert.equal(at({ state: 'unloaded' }), null);
const a = at({ state: 'reading', prompt_read: 4096, prompt_total: 90000 });
const b = at({ state: 'reading', prompt_read: 8192, prompt_total: 90000 });
assert.notEqual(a, b);
assert.equal(a, at({ state: 'reading', prompt_read: 4096, prompt_total: 90000 }), 'no progress, same mark');
assert.notEqual(at({ state: 'generating', generated: 1 }), at({ state: 'generating', generated: 2 }));
assert.notEqual(at({ state: 'reading', slots: [{ state: 'reading', prompt_read: 1 }] }), at({ state: 'reading', slots: [{ state: 'reading', prompt_read: 2 }] }));
});
test('the host wires it in and the local profile lifts the 300 s stream-idle timer', () => {
assert.match(readFileSync(new URL('../server/index.mjs', import.meta.url), 'utf8'), /engineProgress: instanceId => engineProgressMark\(/);
assert.match(readFileSync(new URL('../server/agent/profile.mjs', import.meta.url), 'utf8'), /streamIdleTimeoutMs: 60 \* 60_000/);
});
+10 -4
View File
@@ -96,22 +96,28 @@ test('item 7: tool rows say what happened in plain words, with what it was about
}
});
test('item 8: starting a program that prints nothing is reported as started, plainly', () => {
test('item 8 / T9: starting a program that prints nothing is reported as started, never as opened', () => {
for (const command of ['Start-Process explorer.exe C:\\work', 'explorer.exe C:\\work', 'start notepad', 'Invoke-Item C:\\work\\a.pdf', 'ii .', 'xdg-open .', 'open .']) {
assert.match(outcomeText('pwsh', '(no output)', { command }), /^Started\./, command);
const said = outcomeText('pwsh', '(no output)', { command });
assert.match(said, /^The command finished without an error, so the program was started\./, command);
assert.match(said, /cannot confirm that a window or page actually opened/, command);
assert.ok(!/was opened|is open/i.test(said), command);
}
assert.match(outcomeText('pwsh', '(no output)', { command: 'New-Item x.txt | Out-Null' }), /finished without any error/, 'other silent commands keep their wording');
assert.equal(outcomeText('pwsh', 'hello', { command: 'echo hello' }), 'hello', 'a command that printed something shows it');
const p = new TurnProjector({ startedAt: 0 });
p.consume(ev('tool/call', { callId: 'e1', name: 'pwsh', arguments: JSON.stringify({ command: 'Start-Process explorer.exe C:\\work' }) }));
const res = p.consume(ev('tool/result', { message: { toolCallId: 'e1', content: [{ type: 'text', text: '(no output)' }] } }))[0].tool;
assert.match(res.result, /^Started\./); assert.equal(res.verb, 'Ran a command');
assert.match(res.result, /cannot confirm that a window or page actually opened/); assert.equal(res.verb, 'Ran a command');
});
test('item 7/10: our prompt has no internal terms and is short', () => {
assert.ok(AGENT_PROMPT.length < 1600, `${AGENT_PROMPT.length} chars`);
assert.doesNotMatch(AGENT_PROMPT, /DSH|DeepSeek|sandbox|isolated session|file policy|Harness/i);
assert.match(AGENT_PROMPT, /do not say you could not confirm/i);
// O6 / #3576: the prompt points at the present tool for local files and never tells the model to claim a window opened
assert.match(AGENT_PROMPT, /use the present tool/);
assert.match(AGENT_PROMPT, /Never say a window opened, a page rendered/);
assert.doesNotMatch(AGENT_PROMPT, /say plainly that it is open|do not say you could not confirm/i);
});
test('items 3, 7, 10: what the agent sends the model is small, plain, and carries Thinking Off', { skip }, async t => {
+36
View File
@@ -0,0 +1,36 @@
// T4 (tools and images review, 2026-10-22): the approval gate fails closed. When the permission mode of a session cannot be read, a call
// that is not provably read-only asks, as in Manual; it used to run without a card.
import test from 'node:test';
import assert from 'node:assert/strict';
import { apply, MANUAL_PRESET } from '../server/agent/plugins/strata-gate.mjs';
function gate(current) {
let handler;
apply({ on: (_e, h) => { handler = h; }, inject: () => {}, sessions: new Map(), permissionPresets: { current }, approval: { overrideOf: () => 'never' } });
return handler;
}
const session = { header: { origin: 'user' } };
const allow = async () => ({ kind: 'allow' });
const quiet = async fn => { const warn = console.warn; const lines = []; console.warn = (...a) => lines.push(a.join(' ')); try { return [await fn(), lines]; } finally { console.warn = warn; } };
test('a call that changes things asks when the preset cannot be resolved', async () => {
const handler = gate(() => { throw new Error('unknown session'); });
const [result, lines] = await quiet(() => handler({ name: 'write', agent: { session }, input: { file_path: 'x' } }, allow));
assert.equal(result.kind, 'ask');
assert.match(lines.join('\n'), /could not read the permission mode.*unknown session.*Manual/);
});
test('a provably read-only call still goes through, and a shell write asks', async () => {
const handler = gate(() => { throw new Error('gone'); });
const [read] = await quiet(() => handler({ name: 'read', agent: { session }, input: { file_path: 'x' } }, allow));
assert.equal(read.kind, 'allow');
const [shell] = await quiet(() => handler({ name: 'bash', agent: { session }, input: { command: 'rm -rf x' } }, allow));
assert.equal(shell.kind, 'ask');
});
test('with a readable preset nothing changes: Bypass runs, Manual asks', async () => {
const bypass = gate(() => 'strata-bypass');
assert.equal((await bypass({ name: 'write', agent: { session }, input: { file_path: 'x' } }, allow)).kind, 'allow');
const manual = gate(() => MANUAL_PRESET);
assert.equal((await manual({ name: 'write', agent: { session }, input: { file_path: 'x' } }, allow)).kind, 'ask');
});
Loaded 100 of 473 files, more files were not shown because too many files have changed in this diff. Show more