mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 22:59:00 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ca4383c7 | ||
|
|
2ecdc2e511 | ||
|
|
5353097c90 | ||
|
|
9011bd107a | ||
|
|
52b104b1ac | ||
|
|
00a2cfa079 | ||
|
|
6eaf32075c | ||
|
|
698a87d999 | ||
|
|
6c6267f33c | ||
|
|
cec75af6ae | ||
|
|
8ac2a8385e | ||
|
|
894dc24c6c | ||
|
|
c4dcde7145 | ||
|
|
67d5f44bf8 | ||
|
|
6a35eb6c5f | ||
|
|
12d0a4a051 | ||
|
|
c5e0e14823 | ||
|
|
9bb00346a2 | ||
|
|
672612ac1c | ||
|
|
f4e7685ccf | ||
|
|
267a2f4462 | ||
|
|
eec705cc98 | ||
|
|
b0d5f6e57c | ||
|
|
f2413d77e9 | ||
|
|
4f8770d13e | ||
|
|
984f52c827 | ||
|
|
aebdc298f9 | ||
|
|
fd5823ab83 | ||
|
|
d50da4e3a1 | ||
|
|
624a226d13 | ||
|
|
fa3252ad5d | ||
|
|
9797056e4d | ||
|
|
e2361d80d2 | ||
|
|
afd24ea6ae | ||
|
|
4ebba26767 | ||
|
|
598372c3fd | ||
|
|
2e477ecd40 | ||
|
|
0e121c7a13 | ||
|
|
415e618085 | ||
|
|
dfe29bce61 | ||
|
|
f51b80c222 | ||
|
|
17c585c402 | ||
|
|
41e804d93c | ||
|
|
524ff90e1a | ||
|
|
86834afedc | ||
|
|
fa77717a57 | ||
|
|
60d847ba33 | ||
|
|
a3f76dfd63 | ||
|
|
b4418e30df | ||
|
|
8a7102ec4d | ||
|
|
be26cb7995 | ||
|
|
736a4136e1 | ||
|
|
1d31e2a1e4 | ||
|
|
b8f9c63ae7 | ||
|
|
cdc3a26577 | ||
|
|
cdf8b98f54 | ||
|
|
5be4964c2b | ||
|
|
d914211e64 | ||
|
|
71c674dcdb | ||
|
|
0d38517888 | ||
|
|
e51e84e22a | ||
|
|
cffd240834 | ||
|
|
062fd4a95e | ||
|
|
f73ae5a62e | ||
|
|
e6e14f2714 | ||
|
|
a6cbc3621e | ||
|
|
aee5db5eb1 | ||
|
|
611c1701d3 | ||
|
|
5ec845f6bc | ||
|
|
a23b5f209c | ||
|
|
94413c7057 | ||
|
|
8fda835383 | ||
|
|
4278304914 | ||
|
|
75c57be831 | ||
|
|
2dbb263b02 | ||
|
|
955c709d96 | ||
|
|
70efffb5b6 | ||
|
|
027691b33d | ||
|
|
00ce92068a | ||
|
|
18022ceaf3 | ||
|
|
1904c0261b | ||
|
|
642e76948e | ||
|
|
1d0a8051d5 | ||
|
|
9f04da3afc | ||
|
|
134f24f199 | ||
|
|
6655733d5e | ||
|
|
f9f297bf90 | ||
|
|
ec5b2bdc4e | ||
|
|
a2aeb4ecb1 | ||
|
|
3feaa2aa3f | ||
|
|
8ea7fef827 | ||
|
|
36a94857a1 | ||
|
|
28ce98bf16 | ||
|
|
0be62ccad7 | ||
|
|
86ba33845f | ||
|
|
6f507fb210 | ||
|
|
1a7b4ae236 | ||
|
|
a9dd554e13 | ||
|
|
3724818db5 | ||
|
|
e9546cb564 | ||
|
|
8de8d90287 | ||
|
|
e5f536a6cb | ||
|
|
b6cc7e681b | ||
|
|
f2323d876d | ||
|
|
10af5bb1e4 | ||
|
|
237eb667d3 | ||
|
|
d6f18babe9 | ||
|
|
1747c8e538 | ||
|
|
f1cb66d09e | ||
|
|
4eedb86b4f | ||
|
|
e917cf5b37 | ||
|
|
c506238ce2 | ||
|
|
c35710e388 | ||
|
|
fb321d62cb | ||
|
|
f7d7072841 | ||
|
|
1c1005952a | ||
|
|
41856f6594 | ||
|
|
0beae0bca6 | ||
|
|
9469412db2 | ||
|
|
a345d9f8cf | ||
|
|
a82477069b | ||
|
|
81dd215de9 | ||
|
|
4b5f8d89f2 | ||
|
|
dc352b839b | ||
|
|
e838e2beca | ||
|
|
6053a43841 | ||
|
|
faaf82acd7 | ||
|
|
8fee63f4f0 | ||
|
|
7519b1c75c | ||
|
|
dbf3bb2e64 | ||
|
|
86bdfbd0da | ||
|
|
796dbb77b4 | ||
|
|
96a47b79d5 | ||
|
|
9c46ab821a | ||
|
|
12595032e7 | ||
|
|
a3d9552015 | ||
|
|
01220edb67 | ||
|
|
02b07b686a | ||
|
|
a70b006929 | ||
|
|
4cca3fac20 | ||
|
|
995ff61f3c | ||
|
|
f5fc88c599 | ||
|
|
428950b96c | ||
|
|
0553ba6c3f | ||
|
|
fd19f9d6bf | ||
|
|
b43b0bb518 | ||
|
|
d2411af9fd | ||
|
|
49e35e8bb7 | ||
|
|
c25724a222 | ||
|
|
d689e6080d | ||
|
|
cf4f8befe7 | ||
|
|
1cace5c864 | ||
|
|
7e9b0dcc25 | ||
|
|
0b48e23fa5 | ||
|
|
f6e4cd6899 | ||
|
|
c0f944e127 | ||
|
|
6aba354c16 | ||
|
|
71abb6c163 | ||
|
|
cf45533cf6 | ||
|
|
95994c45c5 | ||
|
|
ea081e3312 | ||
|
|
8cfe988383 | ||
|
|
e5c70bbaa0 | ||
|
|
c70ae83d4c | ||
|
|
f232cbfed6 | ||
|
|
08224f228f | ||
|
|
c8ead78419 | ||
|
|
55e1a53cfd | ||
|
|
8da42765dd | ||
|
|
75c9f2d81e | ||
|
|
2e61fef4d1 | ||
|
|
fb141be3e0 | ||
|
|
9051f31306 | ||
|
|
17bc654b6d | ||
|
|
084e654bcb | ||
|
|
7d2307deb0 | ||
|
|
226d73b45b | ||
|
|
52543768c7 | ||
|
|
3826885f4b | ||
|
|
e7b3f15240 | ||
|
|
7ddc17a38f | ||
|
|
d62ff4a608 | ||
|
|
488dd8459c | ||
|
|
b2d2cf22eb | ||
|
|
7b3e2eaa0b | ||
|
|
f5f205bee0 | ||
|
|
0edd96ccf6 | ||
|
|
eb6c0a23f0 | ||
|
|
c523890228 | ||
|
|
5c523d55f7 | ||
|
|
45dc5e3400 | ||
|
|
c42f4f2969 | ||
|
|
5c8dd32252 | ||
|
|
5964d113f8 | ||
|
|
fea2d02aaa | ||
|
|
81a7260194 | ||
|
|
3b1ab2848d | ||
|
|
80a70b86de | ||
|
|
9222b6a1d3 | ||
|
|
67a48427b8 | ||
|
|
d64ae1b0b1 | ||
|
|
27c0cabd81 | ||
|
|
2f5f7c1135 | ||
|
|
3bf804c9c8 | ||
|
|
0af0f9f125 | ||
|
|
ea67f6999a | ||
|
|
eb818c50ed | ||
|
|
092a62daaa | ||
|
|
1ed28f8863 | ||
|
|
38c7f7017d | ||
|
|
c924d3c9d7 | ||
|
|
6f8800aefe | ||
|
|
814716336a | ||
|
|
0530a1f7c2 | ||
|
|
f6db09bae2 | ||
|
|
217ddda0eb | ||
|
|
734ada7e1b | ||
|
|
a14e85d8e6 | ||
|
|
996b8a4044 | ||
|
|
164dd3bbef | ||
|
|
6577b69d5d | ||
|
|
dca4217501 | ||
|
|
cf4bef2f78 | ||
|
|
84b1e9815e | ||
|
|
c266cfb3cb | ||
|
|
a8301a2709 | ||
|
|
669023fc08 | ||
|
|
2f3f363c33 | ||
|
|
b440640929 | ||
|
|
155995b65c | ||
|
|
e890e30766 | ||
|
|
7b04360f93 | ||
|
|
31785cce47 | ||
|
|
e22a3c9ba9 | ||
|
|
b7196c23de | ||
|
|
fcb0c2d0ec | ||
|
|
e17efa53ce | ||
|
|
3f81245864 | ||
|
|
9471faa5f0 | ||
|
|
3b51719abc | ||
|
|
2ba95cf61f | ||
|
|
437a60676f | ||
|
|
f1b6a2144c | ||
|
|
c62d980112 | ||
|
|
9ee8b0cf79 | ||
|
|
051db74cdf | ||
|
|
49c157fae3 | ||
|
|
0c467e9d45 | ||
|
|
76ac5842ff | ||
|
|
5de8d26949 | ||
|
|
20fed551ba | ||
|
|
e4b31d6f69 | ||
|
|
b96f174775 | ||
|
|
f0010a607f | ||
|
|
ed0ed38e2d | ||
|
|
c4aec611b2 | ||
|
|
3ae6b3041b | ||
|
|
ebaeda85f1 | ||
|
|
815256d89b | ||
|
|
b14862351b | ||
|
|
f8f4e8a7df | ||
|
|
acf63bef73 | ||
|
|
098fd545b3 | ||
|
|
a7cd2de97a | ||
|
|
be58604387 | ||
|
|
5701fc65ce | ||
|
|
0351ecb11d | ||
|
|
d9042a16bd | ||
|
|
642f5120a7 | ||
|
|
98a139c3c7 | ||
|
|
828a2559a9 | ||
|
|
bf8285b8c9 | ||
|
|
a71fd34758 | ||
|
|
7d55581527 | ||
|
|
034f2fdebe | ||
|
|
c5b13accfd | ||
|
|
4791d6b676 | ||
|
|
8bf178febf | ||
|
|
1fa22cd29a | ||
|
|
e2858b9855 | ||
|
|
e6dbbc312a | ||
|
|
425bca7199 | ||
|
|
1298a3971c | ||
|
|
c40a03e6dc | ||
|
|
eebb870922 | ||
|
|
dd8201d20d | ||
|
|
a4c503d7ce | ||
|
|
23b3dfc2ca | ||
|
|
8be3f916f2 | ||
|
|
424b364a88 | ||
|
|
0788a91c64 | ||
|
|
ad98bf1d17 | ||
|
|
c67180edf1 | ||
|
|
49e82263ad | ||
|
|
736d7a840c | ||
|
|
a539e12c97 | ||
|
|
2a22daa4f0 | ||
|
|
27a5707de5 | ||
|
|
72d0bf667e | ||
|
|
e7ded666be | ||
|
|
3ae2123bce | ||
|
|
6d4874a491 | ||
|
|
b1ebc3d7ca | ||
|
|
5e52cf569f | ||
|
|
ff9ca03f3c | ||
|
|
5278ba0043 | ||
|
|
1ef68f83e7 | ||
|
|
aec327ee51 | ||
|
|
9fe2ad3f55 | ||
|
|
ba6966d83f | ||
|
|
4c953b424d | ||
|
|
6f83fe2c42 | ||
|
|
2ccf57ec46 | ||
|
|
89799596a6 | ||
|
|
48ec4a59ac | ||
|
|
e0d79b02f9 | ||
|
|
d45cd9d3cf | ||
|
|
5bc7f8cab3 | ||
|
|
b7d5ea2f82 | ||
|
|
d232721db6 | ||
|
|
5f49ec4599 | ||
|
|
54f81315bf | ||
|
|
6878105560 | ||
|
|
7f97c35aca | ||
|
|
651a56806a | ||
|
|
d276adf277 | ||
|
|
d8f58af1b5 | ||
|
|
dcba249d01 | ||
|
|
5f426c38cb | ||
|
|
128223891e | ||
|
|
17c9b92bc9 | ||
|
|
d1dec64362 | ||
|
|
2355265fdc | ||
|
|
1c2388876c | ||
|
|
e8cb1cba7f | ||
|
|
da70196350 | ||
|
|
88fcf2b443 | ||
|
|
12a3c0739e | ||
|
|
7df82763e3 | ||
|
|
e5b4021c6f | ||
|
|
d6a6c1c797 | ||
|
|
a5f54c9796 | ||
|
|
e38025769d | ||
|
|
3d8d1079bc | ||
|
|
6c8247af60 | ||
|
|
2713121725 | ||
|
|
c11e66aa8b | ||
|
|
9fcc101844 | ||
|
|
a7c50df002 | ||
|
|
a4c9336416 | ||
|
|
2189884a59 | ||
|
|
f7c645c065 | ||
|
|
4455c8bd1d | ||
|
|
d9220bd1a5 | ||
|
|
d251b2c0db | ||
|
|
5a52007005 | ||
|
|
6f703a6dbe | ||
|
|
befdd9d3e1 | ||
|
|
2d718defc0 | ||
|
|
9f78a77e01 | ||
|
|
01389205e9 | ||
|
|
a63b2d4dae | ||
|
|
8e13982710 | ||
|
|
ff5577ad70 | ||
|
|
d17c32d6a3 | ||
|
|
1c20a59675 | ||
|
|
cb725bb209 | ||
|
|
770292e41c | ||
|
|
090121c31b | ||
|
|
6b5ad4f27d | ||
|
|
c3e9a998ad | ||
|
|
a0dbad8078 | ||
|
|
453cc0d56f | ||
|
|
b73bd26a5c | ||
|
|
bbd0a2c673 | ||
|
|
a7eb99bba9 | ||
|
|
431803abb9 | ||
|
|
adf7ca9729 | ||
|
|
0b4e603cfd | ||
|
|
2ec3d2e381 | ||
|
|
a775b04695 | ||
|
|
6f99190f82 | ||
|
|
4292678b8f | ||
|
|
68a8cad3ed | ||
|
|
c23936be78 | ||
|
|
7e9531b3da | ||
|
|
512e298e6e |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)での数値です。ほかのカードの結果は
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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": {
|
||||
|
||||
Generated
+1
-1
@@ -3524,7 +3524,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
|
||||
|
||||
[[package]]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"serde",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -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);
|
||||
@@ -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`.
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.`) });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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.';
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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)}</>;
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.') };
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ``. 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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());}
|
||||
|
||||
@@ -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()) };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。"
|
||||
}
|
||||
@@ -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": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно."
|
||||
}
|
||||
@@ -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}."
|
||||
}
|
||||
@@ -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}."
|
||||
}
|
||||
@@ -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}."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "サーバー名は32文字以内で、英字、数字、ハイフン、アンダースコアを使ってください。",
|
||||
"tl02": "名前が使えないため、次のツールサーバーは除外されました: {0}。名前には、英字、数字、ハイフン、アンダースコアを32文字まで使えます。",
|
||||
"tl03": "プログラムまたはWebアドレスがないため、次のツールサーバーは除外されました: {0}。",
|
||||
"tl04": "ほぼ同じ名前の別のサーバーがあるため、次のツールサーバーは除外されました: {0}。"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "32자 이내의 간단한 서버 이름을 사용하세요. 영문자, 숫자, 하이픈, 밑줄을 쓸 수 있습니다.",
|
||||
"tl02": "이름을 사용할 수 없어 다음 도구 서버를 제외했습니다: {0}. 이름은 영문자, 숫자, 하이픈, 밑줄로 최대 32자까지 지을 수 있습니다.",
|
||||
"tl03": "프로그램이나 웹 주소가 없어 다음 도구 서버를 제외했습니다: {0}.",
|
||||
"tl04": "이름이 거의 같은 다른 서버가 있어 다음 도구 서버를 제외했습니다: {0}."
|
||||
}
|
||||
@@ -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}."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "Используйте простое имя сервера не длиннее 32 символов: буквы, цифры, дефисы или подчёркивания.",
|
||||
"tl02": "Эти серверы инструментов пропущены, потому что их имена недопустимы: {0}. Имя может содержать до 32 букв, цифр, дефисов или подчёркиваний.",
|
||||
"tl03": "Эти серверы инструментов пропущены, потому что у них нет программы или веб-адреса: {0}.",
|
||||
"tl04": "Эти серверы инструментов пропущены, потому что у другого почти такое же имя: {0}."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tl01": "请使用不超过 32 个字符的简单服务器名称:字母、数字、连字符或下划线。",
|
||||
"tl02": "以下工具服务器已被忽略,因为名称不被允许:{0}。名称最多可包含 32 个字母、数字、连字符或下划线。",
|
||||
"tl03": "以下工具服务器已被忽略,因为没有程序或网址:{0}。",
|
||||
"tl04": "以下工具服务器已被忽略,因为另一个服务器的名称几乎相同:{0}。"
|
||||
}
|
||||
@@ -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": "此更新在应用重启时被中断。之后的更新已完成,无需任何操作。"
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(' | '));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user