Compare commits

...
127 Commits
Author SHA1 Message Date
Niko1221 5c8dd32252 serve: max_tokens -1 and 0 mean no cap again, as in 0.2.2 (clients send -1 for that); documented 2026-10-23 07:57:09 +02:00
Niko1221 5964d113f8 release notes 0.2.3: hotfix heading, status/logs names, launch not setup, drop the unreleased-pack limit 2026-10-23 06:37:32 +02:00
Niko1221 fea2d02aaa docs: Strata 0.2.3 release notes 2026-10-23 06:36:54 +02:00
Niko1221 81a7260194 launch config never keeps --mtp beside "mtp": false; one line says where the pack and draft layer are read from (B10, C08 note) 2026-10-23 03:28:36 +02:00
Niko1221 3b1ab2848d Save and launch saves the preset only after the restart question is answered Restart; the question names the running model and port 2026-10-23 03:27:48 +02:00
Niko1221 80a70b86de Folder step: models Strata already keeps stay where they are (the text said they were moved) 2026-10-23 03:24:46 +02:00
Niko1221 9222b6a1d3 a failed start ends its own runtime host, checks the port first and quotes only this attempt's logs (X01) 2026-10-23 03:23:58 +02:00
Niko1221 67a48427b8 Merge f-023-int into f-023-tui2 2026-10-23 00:27:00 +02:00
Niko1221 d64ae1b0b1 tests: the smoke test reads the engine's own words from .reason, the message is plain (X01) 2026-10-23 00:26:59 +02:00
Niko1221 27c0cabd81 Merge f-023-tui1: TUI stress fixes (second launch reuse-or-ask with --restart, context change on the same port, status/logs find the launch, key kept, retune stops its own model, folder checks, Ctrl+C, --model-path wins) 2026-10-23 00:13:45 +02:00
Niko1221 2f5f7c1135 Failed engine test start gives a plain cause and next step; rollback never says the same version twice; max_tokens below 1 is a 400 (TUI stress X01, #18, #15)
X01: TestStartFailed has one text builder (TestStartFailed.plain): what did not work in plain words, that nothing was installed and the current engine is unchanged, and one next step (`strata update --force`, or a plain retry when the card was busy). The engine's own words stay in .reason, the state file and the JSON. The app's message matcher also accepts the new wording. Hints that a caller passes, such as the one for a busy launch, are used as they are, so the launch-holds-the-card line of f-023-tui1 composes with it.
#18: `components rollback` when an earlier rollback is still in force says the engine is already the previous one and what to run, instead of "0.1.45.3 -> 0.1.45.3".
#15 (K05): max_tokens, max_completion_tokens and max_output_tokens below 1 are a 400 naming the field (they used to mean "no cap"); an unknown model id is still answered by the loaded model, now documented in DETAILS.md.
2026-10-23 00:06:13 +02:00
Niko1221 3bf804c9c8 tests follow the folder check and the compact save summary 2026-10-23 00:05:00 +02:00
Niko1221 0af0f9f125 Installer: an older feed shows its real reason; a folder with accented letters unpacks (TUI stress #11 A01b/A05, #12 A04)
#11: the refusal of a feed older than the one already accepted now says both sequence numbers, the folder that holds the newer one (.updates) and one next step (use the newest release folder, or delete state.json to take the older one). install.ps1 and install.sh print the whole check output, not its first 6 lines, and no longer blame a missing signature when the check gave a reason.
#12: tar.exe got the path through the ANSI/OEM code page and failed on accented letters. The installer now runs Windows' own bsdtar (System32 first, so a GNU tar from Git for Windows cannot be picked up for a zip) with ASCII relative arguments from inside the data folder and renames the result, so no non-ASCII path ever crosses that boundary. Tested on C:\...\<U-umlaut>n<i-umlaut> test with a stub release folder; the old script fails there with "could not chdir".
2026-10-22 23:54:57 +02:00
Niko1221 ea67f6999a A missing shard is named with its file name in the CLI, the TUI and the app's plain issues (TUI stress #9, C05, #3478)
plan_import now returns missing_shards (the file names of the split set that are not in the folder). problem_words and plain_issues say "<name>-00002-of-00002.gguf is missing" instead of "some parts of the model are missing"; more than two are counted. The three call sites (models import, strata launch --model-path, the Model screen's own-files field) pass the list.
2026-10-22 23:47:37 +02:00
Niko1221 eb818c50ed Download size counts the draft layer and the picture reader; --plan no longer calls an installed engine missing; --drafting off writes no --mtp (TUI stress #6, B03, B10/E04)
#6 (A07/B03): a model that is not installed now counts the draft layer (5.2 GB) and the picture reader (0.9 GB) in the one place (modelstate.assess), so the Folder step, the confirm and --plan agree (Gyro-S 58.5 + 5.2 + 0.9 = 64.6 GB). Gyro joins the families that get the helpers. --mtp off and --drafting off drop the draft layer from the count and from what setup is told. setup.py's disk check counts the draft layer it is about to fetch.
B03: --plan keeps "engine_missing" for no engine at all; an installed engine that fails the check says so in plain words and adds engine_usable.
B10/E04: with --drafting off (or an installed config that says off) the written run config carries "mtp": false and no --mtp / --spec-min-p arguments.
2026-10-22 23:44:27 +02:00
Niko1221 092a62daaa --model-path and --data-dir win over an installed copy with the same model and context (C08) 2026-10-22 23:37:20 +02:00
Niko1221 1ed28f8863 the Folder step checks the path before going on, the Save summary shows the storage path, a denied folder is told in plain words (B05, B12) 2026-10-22 23:36:16 +02:00
Niko1221 38c7f7017d ctrl+c quits the launch screens like ctrl+q; only a running download or tune asks first (B13) 2026-10-22 23:36:15 +02:00
Niko1221 c924d3c9d7 An old Terminal that cannot hand over to a new Control is told to run the install command once more (TUI stress #4, H03)
The Terminals before 0.2.1.3 (0.2.0-beta, 0.2.1, 0.2.1.1, 0.2.1.2) install a newer Control and report "updated", but their launcher has no hand-over, so the old Control keeps running. Their updater test-starts the staged Control with --version (STRATA_NO_ACQUIRE=1, no STRATA_HANDOVER, from the .s staging folder) and shows the first 120 characters of the answer when that fails. Control now answers that start with one plain sentence and exit 7, so the old updater keeps the old Control and prints the sentence instead of "updated". Every updater that can hand over sets STRATA_HANDOVER and is not touched. Release note added.
2026-10-22 23:33:24 +02:00
Niko1221 6f8800aefe Version bump for 0.2.3: app 0.2.3, engine and Control 0.1.48
Cargo.toml and Cargo.lock, desktop and host package.json and lock, PRODUCT_VERSION, CMake project and Metal STRATA_VERSION, build_hip_linux PKGVER default, About comments, tests that pin the number, Docker image tags and docs (0.2.3-<flavor>), download names in the READMEs, AMD_HIP.md and UPDATES.md, AGENTS.md, the bug report placeholder. The engine moves too: it shares the CMake number and its source changed since 0.2.2 (DFlash2, verify and prefill paths). A new test checks every place agrees and that a 0.2.2 install (Control 0.1.47) is offered Control 0.1.48.
2026-10-22 23:33:15 +02:00
Niko1221 814716336a a second launch reuses the same choices, asks or needs --restart for other choices, and frees the port first (G04/E01) 2026-10-22 23:31:30 +02:00
Niko1221 0530a1f7c2 retune stops its own server first; engine update gives one next step when the launch holds the card (E07/H04/H06) 2026-10-22 23:22:57 +02:00
Niko1221 f6db09bae2 launch keeps the API key it made until --new-key is given (E05/G01) 2026-10-22 23:21:37 +02:00
Niko1221 217ddda0eb status and logs find the running launch when no address is given (F02) 2026-10-22 23:20:36 +02:00
Niko1221 a14e85d8e6 tests: the pinned DFlash2 drafter address is allowed in strata_control (only strata drafting fetch reaches it, behind acquisition.require) 2026-10-22 21:19:41 +02:00
Niko1221 164dd3bbef Merge f-023-fix3: drafting network code loads only when used, rollback tests wait for the restored start, Arc B-series runs with pictures on 2026-10-22 19:58:52 +02:00
Niko1221 6577b69d5d Intel Arc B-series (Battlemage, e.g. Arc Pro B70) runs like any Arc: pictures on by default with the CPU reader, one plain line that the card is new and how to report a problem
Owner decision 2026-10-22. setup_intel no longer switches pictures off for a B-series card; it prints the line instead (known risk: the first-picture xe reset on the B70, 2026-10-19). strata launch gets the same line as a warn-level note (Computer.new_card_note) and its picture default for an Intel engine now follows the CPU-reader rule at any VRAM size (it used the GPU reader for a 32 GB B70). Gyro adoption on Arc was already allowed in this tree (adopt.FAMILIES). Tests that pinned the opt-in are updated; docs/INTEL.md, packaging/README.md and docs/GYRO.md say B-series is supported and untested by us.
2026-10-22 19:39:27 +02:00
Niko1221 dca4217501 test_runtime: the rollback tests wait 30 s for the restored launch (5 s was shorter than a Windows engine start); the two failures and the teardown error were a timeout, the rollback itself works
Same failure on v0.2.2 (011c9e600): the failed new start is refused for its port as intended, the old config is relaunched, but it was still loading when the 5 s startup wait ended, so rolled_back was False and the left-over process held the temp folder at teardown.
2026-10-22 19:35:29 +02:00
Niko1221 84b1e9815e strata drafting: load the network code only when the command runs, so a started engine's command line does not import it (437a60676 regressed the start-path rule) 2026-10-22 19:30:23 +02:00
Niko1221 c266cfb3cb Merge f-023-int into f-023-gui 2026-10-22 18:59:33 +02:00
Niko1221 a8301a2709 Merge f-023-metalfix2: no expert-cache loan in the Metal CLI prompt path either 2026-10-22 18:42:02 +02:00
Niko1221 669023fc08 Merge f-023-strixpp: Strix Halo gets its 8192 prompt chunk back on Windows, and the HIP engine finds its own tuning table 2026-10-22 18:36:58 +02:00
Niko1221 b440640929 metal: no expert-cache loan in the CLI prompt path either (the 4K+ prompt of a plain strata run aborted in ExpertCache::fill_slot_queued) 2026-10-22 18:31:04 +02:00
Niko1221 155995b65c Merge f-023-drafting: the app hides DFlash2 until the engine serves it 2026-10-22 18:07:21 +02:00
Niko1221 e890e30766 The app offers DFlash2 only once the engine serves it: the Drafting setting is Auto / MTP / Off until then (Control reports it) 2026-10-22 18:07:03 +02:00
Niko1221 7b04360f93 hip prefill: the package tuning folder lookup split the exe path on '/' only (a backslash escape lost in the string): split on both 2026-10-22 18:05:47 +02:00
Niko1221 e22a3c9ba9 Merge f-023-drafting: strata launch --drafting auto|mtp|dflash2|off and the app's Drafting setting; suggestion table (Auto = MTP until a card wins everywhere); pinned drafter download and numpy packer; gated until the engine serves DFlash2 2026-10-22 17:43:34 +02:00
Niko1221 b7196c23de Drafting in the app: "Drafting: Auto (recommended) / MTP / DFlash2 / Off" beside context in the model's settings
- The Models screen gets one more column in the context row, the existing Select, with the four choices; DFlash2 is offered for Qwen3.8-Flash-Next
  (the qwen and gyro families) only. The choice is saved with the model (a setting the app owns, like Draft layer) and shown again next time.
- Before a load the host asks Control (strata drafting plan / fetch --if-needed): when the choice would use the DFlash2 drafter and its file is not
  here, it is downloaded pinned and checked and packed, with plain progress in the load. A drafter that cannot be had never stops the load: the MTP
  starts and the job says so. The download plan dialog lists the drafter's size when it is chosen.
- What ran is told in the engine's words under Details and below the row: "Drafting: MTP (DFlash2 didn't fit)" and the other three reasons, all in
  the eight languages. Off is the draft layer's own switch.
- The app never reads the suggestion table: Control decides (strata_control/drafting.py), so the Terminal and the app cannot disagree.
2026-10-22 17:41:10 +02:00
Niko1221 fcb0c2d0ec Merge f-023-int into f-023-gui 2026-10-22 17:34:22 +02:00
Niko1221andmedking82 e17efa53ce Updates: an interruption that a later finished update settled says so in the same red alert (#3578)
Logic and tests ported from #3578 by medking82. The alert stays red and in place; only its words change for the restart interruption of an update that a later update of the same component finished. Sentence-case text in all eight languages; the PR's screenshots and validation files are left out.

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

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

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

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

Co-authored-by: engharat <[email protected]>
2026-10-22 17:32:35 +02:00
Niko1221 437a60676f Drafting: strata launch --drafting auto|mtp|dflash2|off, one suggestion table, and the DFlash2 drafter fetched pinned and packed on the person's PC
- strata_control/drafting.py is the one place: the choice (saved in the model's run config as "drafting"), the table card class -> suggested
  drafter with the measured evidence (Auto picks DFlash2 only where it was faster on every tested prompt; today no class qualifies, so Auto is
  the MTP everywhere), the pinned drafter (incoai/Qwen3.8-Flash-Next-DFlash2 rev a22a1744, sizes and SHA-256 of the repository), what each choice
  puts on the engine arguments, and the plain "Drafting: MTP (DFlash2 didn't fit)" words read from the engine's own log.
- serve/server.py applies the choice at every start and reports "drafting" / "drafting_note" in /metrics.
- strata launch --drafting writes it to the launch config and presets, shows it in --plan, adds the drafter to the download plan when it would
  be used, fetches it before the start (a failure starts the MTP and says so), and prints the engine's fallback line.
- strata drafting plan|fetch (JSON/JSONL for the app). tools/dflash2_pack.py now reads safetensors with numpy alone (no torch on the person's
  PC), byte for byte the same file as before (checked against the earlier version), and ships in the Terminal bundle.
- DFlash2 is for Qwen3.8-Flash-Next (qwen, gyro) only. The engine in this release runs it only in its CLI run, not with --serve, so while
  drafting.ENGINE_SERVES_DFLASH2 is False the choice starts the MTP, says "DFlash2 isn't available in this mode yet" and downloads nothing.
2026-10-22 17:27:39 +02:00
Niko1221 f1b6a2144c Merge f-023-df2: DFlash2 drafter (opt-in --draft dflash2) merged onto the combined branch, builds on CUDA, HIP and Metal 2026-10-22 17:05:26 +02:00
Niko1221 c62d980112 hip prefill: with no STRATA_HIPBLASLT_TUNING in the environment the engine takes the table beside it (tuning/<arch>-hipblaslt-<version>.txt) itself, so a start that did not come through the launcher (hand-made config, CLI) does not read prompts at half speed on a Strix Halo; STRATA_HIPBLASLT_TABLE=off keeps plain hipBLAS 2026-10-22 17:04:58 +02:00
Niko1221 051db74cdf gfx1151 on Windows: a bare --prefill auto may reach 8192 again (the 6144 cap of #1630 stays for other cards); STRATA_GFX1151_CHUNK=0 keeps 6144 2026-10-22 16:55:16 +02:00
Niko1221 0c467e9d45 Merge f-023-agent: agent context pressure from measured tokens, summaries in pieces that fit, recovery from the context-window 400, preflight of oversized messages, quote-aware read-only shell detection, visible compaction, plain context errors, stale child marker after a force-kill
# Conflicts:
#	apps/host/src/screens/Chat.tsx
2026-10-22 16:52:15 +02:00
Niko1221 76ac5842ff Merge f-023-tune: Work profile CPU workers ceil(0.8 x physical cores), VRAM reserve capped at 1536 MiB on 16 GB cards (picture floor only for a GPU reader); #3493 conversation cache sized automatically after load; #3438 bench reports an engine that did not stop 2026-10-22 16:26:51 +02:00
Niko1221 5de8d26949 tests: setup no longer tips about the conversation cache (the engine sizes it, #3493) 2026-10-22 16:22:37 +02:00
Niko1221 e4b31d6f69 strata-context: drop the development log hook 2026-10-22 16:14:56 +02:00
Niko1221 b96f174775 Merge f-dflash2 (the DFlash2 drafter, opt-in) into f-023-int 2026-10-22 16:10:08 +02:00
Niko1221 f0010a607f serve: the conversation cache is sized from the free RAM when --conversation-cache-mib is not given (#3493): 40% of what is left above the parking floor, at most 8192, off under 1024, one log line; an explicit value (0 too) is kept; harness_reuse_probe.py; docs 2026-10-22 16:06:17 +02:00
Niko1221 ed0ed38e2d Merge f-023-tls: #3722 HTTPS uses the OS certificate store (truststore, certifi fallback) in setup, Control, serve and the fetch tools; the app host adds the system roots; a certificate failure says plainly what is intercepting and what to try 2026-10-22 15:43:33 +02:00
Niko1221 c4aec611b2 tls: downloads check the server against the operating system's certificates; a failed check gives a plain message (#3722)
Python's OpenSSL checks against certifi's roots and does not fetch a missing intermediate; Chrome and curl.exe use the Windows store
(schannel), which also holds the roots antivirus HTTPS scanning, a company proxy or a preview build adds. strata_control/tls.py
install() puts truststore (pinned in requirements.txt and the three runtime locks with hashes, CPython 3.12 in every archive) under
every HTTPS connection of setup, Control's transfer and component download, serve and the fetch tools, with certifi as the fallback.
A certificate failure is no longer the raw urlopen text and is not retried: it names the likely causes (antivirus web scanning, a
company or school proxy), says what to try, and never offers to skip the check.

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

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

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

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

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

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

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

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

Verified on gfx1151: json_extract at fixed windows (--spec 4
--spec-min-p 0 --suffix-draft 0) gives an identical output line before
and after.
2026-10-21 23:42:55 +02:00
Niko1221 9fe2ad3f55 wip: gfx1151 takes mode 8 gate/up (LDS, fused SwiGLU + q8_1) and the CUDA-layout down for IQ4_NL 2026-10-21 19:55:22 +02:00
Niko1221 ba6966d83f verify: the DFlash2 tap kernel only where the DFlash2 sources are built (Metal and SYCL keep the per-row dump path) 2026-10-21 19:52:11 +02:00
Niko1221 4c953b424d wip (experiment): expert layouts compiled for every HIP arch 2026-10-21 19:46:43 +02:00
Niko1221 6f83fe2c42 dflash2: drop the cache-touch kernel (no gain), wait-commit measurement switch 2026-10-21 19:46:26 +02:00
Niko1221 2ccf57ec46 spec: faster start-up adaptation of the expert tier in speculative runs (swap every 2 verify rounds for the first 200, then the normal interval); default on sm_86 only, STRATA_SPEC_ADAPT_EVERY / STRATA_SPEC_ADAPT_WARM set it elsewhere, an explicit --adapt-every is kept 2026-10-21 19:45:52 +02:00
Niko1221 89799596a6 dflash2: fused conv+residual+norm+quant, q/k norm+rope, K/V ring write and attention-output quantization; stage skip switches and small benches 2026-10-21 16:34:27 +02:00
Niko1221 48ec4a59ac spec: cost-aware window: the first round of a width is replaced by the guess from the other widths, a running mean for the first samples 2026-10-21 16:10:56 +02:00
Niko1221 e0d79b02f9 spec: with STRATA_SPEC_COST a round far above the known cost of its width (the first use captures a graph) counts as a tenth of a sample 2026-10-21 16:04:10 +02:00
Niko1221 d45cd9d3cf spec: STRATA_SPEC_COST_TABLE seeds the row costs (a per-card table), the CLI prints the learned table at the end 2026-10-21 15:35:07 +02:00
Niko1221 5bc7f8cab3 dflash2: tiled Q8 GEMM, fused quantizers, head-major K/V ring with a prefetching attention, forked context update, PLE read-ahead, benches 2026-10-21 15:31:29 +02:00
Niko1221 b7d5ea2f82 native_expert_bench: BENCH_CAP sizes the launch for more possible groups than real ones (what the empty block rows of a verify window cost); bench only 2026-10-21 15:08:13 +02:00
Niko1221 d232721db6 spec: cost-aware chain rule keeps the best width seen and looks one or two drafts ahead (the row cost is not convex); unmeasured widths are probed a few times 2026-10-21 15:03:51 +02:00
Niko1221 5f49ec4599 spec: STRATA_SPEC_COST keeps an MTP draft only while the next row pays at the measured row cost (step-by-step chain rule in MtpDrafter, DraftPolicy::keep_draft) 2026-10-21 14:46:56 +02:00
Niko1221 6878105560 spec: STRATA_SPEC_COST, a cost-aware MTP window (DraftPolicy::best_rows: the window with the best expected tokens per ms from the chain's probabilities, a measured probability-to-acceptance table and the measured row costs); the trace now logs the probabilities the window was chosen from (A/B arm, off by default) 2026-10-21 14:07:25 +02:00
Niko1221 7f97c35aca DFlash2 drafter for Qwen3.8-Flash-Next: Q8_0 pack tool, GPU forward (conv, K/V ring, windowed attention, top-16 selector), permanent verify/prefill taps, --draft dflash2 --draft-model (opt-in, MTP stays the fallback) 2026-10-21 14:04:15 +02:00
Niko1221 651a56806a generate: STRATA_SPEC_TRACE, a per-round trace of the window rows, accepted drafts, round ms and MTP draft probabilities for offline window-length studies (bench only, off by default) 2026-10-21 13:46:06 +02:00
Niko1221 d276adf277 verify: STRATA_ROUTE_STATS (distinct and new-per-row routed experts per layer per window width) and a per-width GPU stage profile line for STRATA_SPEC_T_CYCLE runs (bench only, off by default) 2026-10-21 13:28:02 +02:00
Niko1221 d8f58af1b5 generate: STRATA_SPEC_T_CYCLE, a bench-only cycle of the verify window size with a per-width cost line (off by default) 2026-10-21 13:25:20 +02:00
Niko1221 dcba249d01 verify: STRATA_TAP_FILE, an env-gated dump of the hidden states at chosen layers per verified row (DFlash2 acceptance experiment; off by default, no new graph nodes without it) 2026-10-21 13:25:19 +02:00
Niko1221 5f426c38cb tools/dflash2: q8test, the drafter with Q8_0 matrices keeps its acceptance 2026-10-21 13:24:21 +02:00
Niko1221 128223891e tools/dflash2: the torch port of the DFlash2 drafter and the scripts of the acceptance experiment (research tooling) 2026-10-21 13:24:21 +02:00
Niko1221 17c9b92bc9 generate: STRATA_SPEC_T_CYCLE, a bench-only cycle of the verify window size with a per-width cost line (off by default) 2026-10-21 13:24:21 +02:00
Niko1221 d1dec64362 verify: STRATA_TAP_FILE, an env-gated dump of the hidden states at chosen layers per verified row (DFlash2 acceptance experiment; off by default, no new graph nodes without it) 2026-10-21 13:24:21 +02:00
291 changed files with 14776 additions and 638 deletions

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ body:
attributes:
label: Strata version
description: "In the app: Settings > About. In the Terminal: `strata --version`. From source: the About tab of the web page, or `engine` in `GET /v1/status`, or the git commit."
placeholder: "Strata 0.2.2 · engine 0.1.47"
placeholder: "Strata 0.2.3 · engine 0.1.48"
validations:
required: true
- type: input
+1 -1
View File
@@ -13,7 +13,7 @@ on:
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag whose engine packages go into the images (for example v0.2.2)'
description: 'Release tag whose engine packages go into the images (for example v0.2.3)'
required: true
type: string
flavors:
+51
View File
@@ -0,0 +1,51 @@
name: Metal build
# Compile and link the Metal engine on a macOS runner (no GPU needed: the kernels are compiled at run time). The shared host files
# (src/core/verify.cpp, src/prefill, src/program/generate.cpp) call kernel entry points that exist only for CUDA / HIP until Metal gets a
# stub or an implementation; a missing one is a link error here, not on a user's Mac (owner round 9, G6).
on:
pull_request:
paths:
- 'src/**'
- 'include/**'
- 'metal/**'
- 'third_party/**'
- 'CMakeLists.txt'
- '.github/workflows/metal-build.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
metal-link:
runs-on: macos-14
timeout-minutes: 45
env:
# setup.py's LLAMA_CPP_COMMIT and metal/setup_mac.py's metal-cpp pin
LLAMA_CPP_COMMIT: 3cf03257f219afbe7334045ff7c6a06ac68c627d
METAL_CPP_URL: https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip
METAL_CPP_SHA256: 4df3c078b9aadcb516212e9cb03004cbc5ce9a3e9c068fa3144d021db585a3a4
steps:
- uses: actions/checkout@v4
- name: Tools
run: brew install ninja
- name: metal-cpp
run: |
curl -fsSL "$METAL_CPP_URL" -o metal-cpp.zip
echo "$METAL_CPP_SHA256 metal-cpp.zip" | shasum -a 256 -c -
unzip -q metal-cpp.zip -d _mcpp
- name: llama.cpp (ggml) at the pinned commit
run: |
mkdir llama.cpp && cd llama.cpp
git init -q
git remote add origin https://github.com/ggml-org/llama.cpp.git
git fetch -q --depth 1 origin "$LLAMA_CPP_COMMIT"
git checkout -q FETCH_HEAD
- name: Configure
run: |
cmake -S metal -B build-metal -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DMETAL_CPP_DIR="$PWD/_mcpp/metal-cpp" -DSTRATA_GGML_DIR="$PWD/llama.cpp"
- name: Build and link the engine
run: cmake --build build-metal --target strata
+1 -1
View File
@@ -2,7 +2,7 @@
Strata runs the Qwen3.8-Flash-Next mixture-of-experts model (and its Coder, Swift 1.5, Unsloth, Gyro and NVFP4
variants, and Qwen3.6-35B-A3B) on a normal PC: one NVIDIA or AMD graphics card plus system RAM on Windows or Linux,
or an Apple Silicon Mac. Version 0.2.2, engine 0.1.47. It has three parts:
or an Apple Silicon Mac. Version 0.2.3, engine 0.1.48. It has three parts:
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
not want a terminal. The installer has the engine inside.
+30 -3
View File
@@ -8,7 +8,7 @@
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
# and is wrong about its own performance, which is the worst of both outcomes.
cmake_minimum_required(VERSION 3.24)
project(strata VERSION 0.1.47 LANGUAGES CXX)
project(strata VERSION 0.1.48 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})
@@ -1081,7 +1105,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 +1476,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)
+2 -1
View File
@@ -56,7 +56,8 @@ RUN set -e; \
ver="${ENGINE_VERSION:-$(sed -n 's/^[[:space:]]*project([^)]*VERSION[[:space:]]*\([0-9][0-9.]*\).*/\1/p' CMakeLists.txt | head -1)}"; \
cudafull=$(nvcc --version | sed -n 's/.*release \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1); cudamajor=${cudafull%%.*}; \
if [ "$cudamajor" = 13 ]; then mindriver=580.65; else mindriver=575.51; fi; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true}" > /stage/BUILD.json; \
vision_archs=""; [ -f /stage/strata-vision ] && vision_archs=", \"vision_archs\": \"$CUDA_ARCHITECTURES\""; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true${vision_archs}}" > /stage/BUILD.json; \
python3 packaging/collect_libs.py --binary build/strata --search /usr/local/cuda/lib64 --out /stage --also 'libcublasLt.so*'; \
archs=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do archs="$archs --arch sm_$a"; done; \
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
+11 -11
View File
@@ -33,7 +33,7 @@ Bilder und arbeitet mit deinen Apps und Coding-Agents zusammen. Deine Chats und
- **Kostenlos.** MIT-Lizenz, kein Konto, keine Cloud.
- **Privat.** Keine Telemetrie. Was Strata herunterlädt, wann es das Internet nutzt und wie es Downloads prüft, steht in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.2 · Engine 0.1.47
Version 0.2.3 · Engine 0.1.48
## Was du brauchst
@@ -73,16 +73,16 @@ unsicher, welche du brauchst? Lies den Absatz unter der Tabelle.
| System | Deine Grafikkarte | Datei zum Herunterladen |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Wenn du unsicher bist:** Bei NVIDIA nimmst du `cuda13`, wenn dein Treiber Version 580 oder neuer ist, sonst `cuda12`.
`cuda12` ist für ältere Karten und ältere Treiber. Die Dateien `hip` und `amd` sind für AMD-Karten. Für andere AMD-Karten
+11 -11
View File
@@ -33,7 +33,7 @@ con tus aplicaciones y tus agentes de programación. Tus chats y tus archivos se
- **Gratis.** Licencia MIT, sin cuenta, sin nube.
- **Privado.** Sin telemetría. Lo que Strata descarga, cuándo usa internet y cómo comprueba las descargas está en [docs/SAFETY.md](docs/SAFETY.md).
Versión 0.2.2 · motor 0.1.47
Versión 0.2.3 · motor 0.1.48
## Lo que necesitas
@@ -73,16 +73,16 @@ sabes cuál elegir? Mira el párrafo que hay debajo de la tabla.
| Sistema | Tu tarjeta gráfica | Archivo que descargar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Si no estás seguro:** con NVIDIA, elige `cuda13` si la versión de tu controlador es la 580 o posterior, y `cuda12` en caso
contrario. `cuda12` es para tarjetas y controladores más antiguos. Los archivos `hip` y `amd` son para tarjetas AMD. Las
+11 -11
View File
@@ -33,7 +33,7 @@ fonctionne avec vos applications et vos agents de code. Vos conversations et vos
- **Gratuit.** Licence MIT, pas de compte, pas de cloud.
- **Privé.** Aucune télémétrie. Ce que Strata télécharge, quand il utilise internet et comment il vérifie les téléchargements est expliqué dans [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.2 · moteur 0.1.47
Version 0.2.3 · moteur 0.1.48
## Ce qu'il vous faut
@@ -73,16 +73,16 @@ Vous ne savez pas lequel prendre ? Lisez le paragraphe sous le tableau.
| Système | Votre carte graphique | Fichier à télécharger |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Si vous hésitez :** pour NVIDIA, prenez `cuda13` si la version de votre pilote est 580 ou plus récente, sinon `cuda12`.
`cuda12` convient aux cartes plus anciennes et aux pilotes plus anciens. Les fichiers `hip` et `amd` sont pour les cartes
+11 -11
View File
@@ -33,7 +33,7 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
- **無料です。** MIT ライセンス。アカウントもクラウドも不要です。
- **プライバシーを守ります。** テレメトリはありません。Strata が何をダウンロードするか、いつインターネットを使うか、ダウンロードをどう検証するかは [docs/SAFETY.md](docs/SAFETY.md) に書いてあります。
バージョン 0.2.2 · エンジン 0.1.47
バージョン 0.2.3 · エンジン 0.1.48
## 必要な環境
@@ -72,16 +72,16 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
| OS | グラフィックスカード | ダウンロードするファイル |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**迷ったときは:** NVIDIA の場合、ドライバーがバージョン 580 以降なら `cuda13`、それより古ければ `cuda12` を選びます。`cuda12` は古いカードと古いドライバー向けです。
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000)には、まだ用意されたアプリがありません。
+11 -11
View File
@@ -33,7 +33,7 @@ agents. Your chats and files stay on your PC.
- **Free.** MIT license, no account, no cloud.
- **Private.** No telemetry. What Strata downloads, when it uses the internet and how it checks downloads is in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.2 · engine 0.1.47
Version 0.2.3 · engine 0.1.48
## What you need
@@ -73,16 +73,16 @@ line under the table.
| System | Your graphics card | File to download |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**If you are unsure:** for NVIDIA, take `cuda13` if your driver is version 580 or newer, otherwise `cuda12`. `cuda12` is
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. RX 6000 cards have no installer of
+11 -11
View File
@@ -33,7 +33,7 @@ agentes de programação. Suas conversas e arquivos ficam no seu PC.
- **Gratuito.** Licença MIT, sem conta, sem nuvem.
- **Privado.** Sem telemetria. O que o Strata baixa, quando usa a internet e como confere os downloads está em [docs/SAFETY.md](docs/SAFETY.md).
Versão 0.2.2 · mecanismo 0.1.47
Versão 0.2.3 · mecanismo 0.1.48
## O que você precisa
@@ -73,16 +73,16 @@ logo abaixo da tabela.
| Sistema | Sua placa de vídeo | Arquivo para baixar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Se você estiver em dúvida:** para NVIDIA, use `cuda13` se o seu driver for a versão 580 ou mais nova, e `cuda12` nos outros casos. O `cuda12`
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
+11 -11
View File
@@ -33,7 +33,7 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
- **免费。** MIT 许可证,不用注册账号,不走云端。
- **隐私。** 没有遥测。Strata 下载什么、什么时候联网、如何校验下载内容,都写在 [docs/SAFETY.md](docs/SAFETY.md)。
版本 0.2.2 · 引擎 0.1.47
版本 0.2.3 · 引擎 0.1.48
## 硬件要求
@@ -72,16 +72,16 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
| 系统 | 你的显卡 | 要下载的文件 |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**不确定时:** NVIDIA 显卡的驱动版本在 580 及以上就选 `cuda13`,否则选 `cuda12`。`cuda12` 适用于较旧的显卡和驱动。
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000)目前还没有现成的应用,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.2",
"version": "0.2.3",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+1 -1
View File
@@ -3524,7 +3524,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.2"
version = "0.2.3"
dependencies = [
"gtk",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.2"
version = "0.2.3"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.2",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.2",
"version": "0.2.3",
"dependencies": {
"html-to-image": "^1.11.13",
"katex": "^0.19.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata",
"private": true,
"version": "0.2.2",
"version": "0.2.3",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
/**
* The context fixes against a REAL engine (not the scripted worker): Gyro-S at 64K on this PC, the agent bridge and the real Harness.
*
* gpu + heavy locks held first (python phaseA-tests\lock.py acquire gpu "023-agent: context e2e", then heavy); one engine; nothing of the
* installed app is used or touched: its model files are read, its Harness is copied (STRATA_HARNESS_DIR), data goes to a scratch folder.
*
* node scripts/context-e2e.mjs (needs STRATA_HARNESS_DIR; see the E2E_* names below)
*
* It starts serve/server.py as ITS OWN child (foreground: this script waits for it and stops it by PID at the end or on a memory alarm),
* runs four scenarios and prints one table. Exit code 0 when every expectation held.
*
* A a message that can never fit (about 60K tokens in a 64K window) is refused in seconds, before the engine reads it
* B a chat of dense log messages grows past the window several times over: automatic compaction runs, nothing is refused
* C /compact at a high fill: summarises, says the figures; and a second /compact is answered in plain words
* D the needle: a fact from the first message is asked about at the end (reported, not asserted: it is the summary's quality)
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { countTokens } from '../server/agent/context-fit.mjs';
const REPO = join(import.meta.dirname, '..', '..', '..');
const env = (name, fallback) => process.env[name] ?? fallback;
const DOCS = env('E2E_DOCS', 'C:\\Users\\AI-Server\\Documents\\Strata');
const ENGINE = env('E2E_ENGINE', 'C:\\Users\\AI-Server\\Desktop\\Strata\\GitHub\\wt-023-int\\build-cuda\\strata.exe');
const PROFILE = env('E2E_EXPERT_PROFILE', 'C:\\Users\\AI-Server\\AppData\\Local\\Programs\\Strata\\control\\data\\expert-profile.bin');
const PYTHON = env('E2E_PYTHON', 'python');
const PORT = Number(env('E2E_PORT', '18590'));
const MIN_FREE_GB = Number(env('E2E_MIN_FREE_GB', '7'));
const GGUF = join(DOCS, 'models', 'gyro-Gyro-S', 'Qwen3.8-Flash-Next-Gyro-S-TQ1_0.gguf');
const CUDA_BIN = env('E2E_CUDA_BIN', 'C:\\Program Files\\NVIDIA GPU Computing Toolkit\\CUDA\\v13.0\\bin');
const WINDOW = 65536;
/** A model server that is already running (the scripted worker, to try this script without a GPU): nothing is started or stopped. */
const EXISTING = process.env.E2E_BASE_URL ?? '';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const freeGb = () => {
const out = spawnSync('powershell', ['-NoProfile', '-Command', '[math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1MB,1)'], { encoding: 'utf8' }).stdout.trim();
return Number(out);
};
const rows = [];
const note = (id, ok, detail) => { rows.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${id} ${detail}`); };
if (!process.env.STRATA_HARNESS_DIR) { console.error('STRATA_HARNESS_DIR is not set'); process.exit(2); }
if (!EXISTING) for (const f of [ENGINE, GGUF]) if (!existsSync(f)) { console.error(`missing: ${f}`); process.exit(2); }
if (!EXISTING && freeGb() < 20) { console.error(`only ${freeGb()} GB of RAM are free; wait for the other streams (lock.py status) and run again`); process.exit(2); }
const scratch = mkdtempSync(join(tmpdir(), 'strata-ctx-e2e-'));
const configFile = join(scratch, 'server.json');
const logFile = join(scratch, 'server.log');
const readyFile = join(scratch, 'ready.json');
writeFileSync(configFile, JSON.stringify({
exe: ENGINE,
args: ['--pack', join(DOCS, 'packs', 'gyro-gyro-s'), '--native', GGUF, '--ple-gguf', GGUF, ...(existsSync(PROFILE) ? ['--expert-profile', PROFILE] : []),
'--prefill', 'auto', '--spec-min-p', '0.5', '--mtp', join(DOCS, 'mtp', 'rt'), '--max-context', String(WINDOW), '--kv', 'int8', '--pool-workers', '5',
'--expert-cache', 'auto', '--kv-resident', '32768', '--spec', '4', '--vram-reserve-mib', '1536'],
cwd: REPO, tokenizer: join(DOCS, 'packs', 'gyro-gyro-s', 'tokenizer'), model_name: 'qwen3.8-flash-next-gyro-gyro-s', log: join(scratch, 'engine.log'), lib_dirs: [], port: PORT, gpu: 0,
}, null, 2));
let server = null, bridge = null, alarm = null;
const stopServer = () => {
if (server?.pid) spawnSync('taskkill', ['/T', '/F', '/PID', String(server.pid)], { stdio: 'ignore' });
server = null;
};
const finish = async (code) => {
clearInterval(alarm);
try { await bridge?.close(); } catch { /* going away */ }
stopServer();
await sleep(1500);
console.log(`\nscratch folder: ${scratch}`);
process.exit(code);
};
process.on('SIGINT', () => finish(130));
if (!EXISTING) server = spawn(PYTHON, ['serve/server.py', '--engine', 'strata', '--config', configFile, '--port', String(PORT), '--no-api-key', '--ready-file', readyFile], {
cwd: REPO, env: { ...process.env, PATH: `${CUDA_BIN};${process.env.PATH}`, STRATA_DATA_DIR: join(scratch, 'data') }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
const sink = [];
if (server) { server.stdout.on('data', d => sink.push(String(d))); server.stderr.on('data', d => sink.push(String(d))); server.on('exit', code => { sink.push(`[server exited ${code}]`); server = null; }); }
// Memory alarm: this run must never be what freezes the PC. Under the floor the engine is stopped (by PID) and the run ends.
if (!EXISTING) alarm = setInterval(() => { const free = freeGb(); if (free < MIN_FREE_GB) { console.error(`ALARM: ${free} GB of RAM free, stopping the engine`); finish(3); } }, 3000);
alarm?.unref?.();
const base = EXISTING || `http://127.0.0.1:${PORT}`;
let ready = false;
for (let i = 0; i < 120 && !ready; i++) {
if (!EXISTING && !server) break;
try { ready = (await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* still loading */ }
if (!ready) await sleep(2000);
}
writeFileSync(logFile, sink.join(''));
if (!ready) { console.error('the engine did not become ready:\n' + sink.join('').slice(-1500)); await finish(4); }
const model = (await (await fetch(`${base}/v1/models`)).json()).data[0].id;
console.log(`engine ready: ${model}, free RAM ${freeGb()} GB`);
// ---------------------------------------------------------------------------------------------------------------- the pieces
const line = i => `2026-10-22 13:${String(Math.floor(i / 60) % 60).padStart(2, '0')}:${String(i % 60).padStart(2, '0')} INFO svc-${i % 17} req=${(i * 2654435761 >>> 0).toString(16)} status=${200 + (i % 7) * 3} ms=${(i * 37) % 900} path=/api/v1/items/${(i * 7919) % 100000}`;
const logText = (chars, from = 0, needle = '') => {
const out = [];
let n = 0, i = from;
while (n < chars) { const l = line(i++); out.push(l); n += l.length + 1; }
if (needle) out.splice(Math.floor(out.length / 2), 0, needle);
return out.join('\n');
};
const count = text => countTokens({ baseUrl: `${base}/v1`, model, text });
const turnsOf = async (message) => {
const frames = [];
const t0 = Date.now();
await bridge.runTurn({ chatId: 'e2e', message, workspacePath: scratch, mode: 'bypass', thinking: 'Off', onFrame: f => frames.push(f) });
return { frames, seconds: (Date.now() - t0) / 1000, last: frames.at(-1) };
};
const textOf = frames => frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('').trim();
const noteOf = frames => frames.find(f => f.type === 'delta' && f.noteReason === 'compacted')?.text?.trim() ?? null;
bridge = createAgentBridge({
dataDir: join(scratch, 'agent'), env: process.env, startTimeoutMs: 120_000, log: l => console.log(' bridge:', l),
localInstances: () => [{ id: 'e2e', label: 'Gyro-S', baseUrl: `${base}/v1`, modelId: model, context: WINDOW, slots: 1, key: '' }], connections: () => [],
});
mkdirSync(join(scratch, 'agent'), { recursive: true });
// ---------------------------------------------------------------------------------------------------------------- A
{
// Grow a log until the engine itself counts about 60K tokens.
let chars = 150_000, tokens = await count(logText(chars));
while (tokens && tokens < 58_000 && chars < 400_000) { chars = Math.round(chars * (60_000 / tokens)); tokens = await count(logText(chars)); }
const t = await turnsOf(logText(chars) + '\n\nWhat is the most common status?');
const refused = t.last.type === 'error' && t.last.code === 'CONTEXT_MESSAGE_TOO_LARGE';
note('A too large is refused before it is read', refused && t.seconds < 15, `${chars} characters = ${tokens} tokens by the engine; answered in ${t.seconds.toFixed(1)} s; frame ${t.last.type}/${t.last.code ?? '-'}: ${String(t.last.message ?? '').slice(0, 110)}`);
}
// ---------------------------------------------------------------------------------------------------------------- B
const needle = 'NOTE FROM OPS: the vault code is COBALT-27. Do not lose it.';
const timeline = [];
let notes = 0, refusedAny = false, peak = 0;
{
for (let k = 0; k < 5; k++) {
const body = logText(34_000, k * 1000, k === 0 ? needle : '');
const t = await turnsOf(`${body}\n\nReply with the single word OK.`);
const note = noteOf(t.frames);
if (note) notes++;
if (t.last.type === 'error') refusedAny = true;
const used = t.last.contextTokens ?? 0;
peak = Math.max(peak, ...t.frames.filter(f => f.type === 'context').map(f => f.contextTokens), used);
timeline.push(`msg${k + 1}: ${t.last.type}${t.last.code ? '/' + t.last.code : ''} ${t.seconds.toFixed(0)} s context ${used} (${Math.round(100 * used / WINDOW)}%)${note ? ' [' + note.slice(0, 80) + ']' : ''}`);
console.log(' ' + timeline.at(-1));
if (t.last.type === 'error') break;
}
note('B five dense messages (about 14K tokens each) in a 64K window: no error', !refusedAny, `peak context ${peak} of ${WINDOW}; ${notes} compaction note(s) in the chat`);
note('B compaction ran and was announced', notes >= 1, timeline.join(' | '));
}
// ---------------------------------------------------------------------------------------------------------------- C
{
const run = async (line) => { const frames = []; await bridge.compactCommand({ chatId: 'e2e', message: line, workspacePath: scratch, mode: 'bypass', choice: null, onFrame: f => frames.push(f) }); return frames; };
await turnsOf(`${logText(30_000, 90_000)}\n\nReply with the single word OK.`);
const t0 = Date.now();
const first = await run('/compact');
const said = first.find(f => f.type === 'delta');
const ok = said?.key === 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.' && said.values[0] > said.values[1];
note('C /compact summarises and states the figures', ok, `${((Date.now() - t0) / 1000).toFixed(0)} s: ${said?.text}`);
const second = await run('/compact');
const again = second.find(f => f.type === 'delta')?.text ?? '';
note('C a second /compact is answered in plain words', /summarized|nothing|could not/i.test(again) && !/\{|400|invalid_request/.test(again), again);
}
// ---------------------------------------------------------------------------------------------------------------- D
{
const t = await turnsOf('What was the vault code in the very first message? Answer with the code only.');
const answer = textOf(t.frames);
console.log(`D needle after compaction: "${answer.slice(0, 80)}" (${t.last.type})`);
rows.push({ id: 'D the needle', ok: /COBALT-27/.test(answer), detail: `reported only: answer "${answer.slice(0, 80)}"` });
}
console.log('\n' + rows.map(r => `${r.ok ? 'PASS' : r.id.startsWith('D') ? 'INFO' : 'FAIL'} ${r.id}${r.id.startsWith('D') ? ` ${r.detail}` : ''}`).join('\n'));
const failed = rows.filter(r => !r.ok && !r.id.startsWith('D')).length;
writeFileSync(join(scratch, 'result.json'), JSON.stringify(rows, null, 2));
await finish(failed ? 1 : 0);
+12
View File
@@ -14,6 +14,7 @@ and talks to its Gateway. Nothing here downloads or builds anything.
| `children.mjs` | pure projection of one helper agent's events into its row and its approval card |
| `preset.mjs` | the agent preset (which tools the chat has) |
| `plugins/strata-gate.mjs` | the Manual-mode approval gate (a Harness plugin, loaded by path) |
| `plugins/strata-context.mjs`, `context-budget.mjs`, `context-fit.mjs` | measured context pressure, summaries that fit the window, the check before a message goes out |
| `agent-bridge.mjs` | the chat-to-session map, turns, approvals, Stop, status |
## Frames sent on `/api/chat/stream` for an agent turn
@@ -132,3 +133,14 @@ A turn that keeps failing the same way is stopped by the app, never left to the
- **Compaction:** after a guard stop (and after a turn the model server refused because the context was full) the bridge asks the Harness for `/compact` on that session, so the next message has room. Best effort and logged.
- **Never silent:** a turn that ends (`completed` or `max-tokens`) with no answer text gets "I ran out of room in this chat..." when the last request used 85% of the window or more, otherwise "I stopped without writing an answer..." (not when tool cards were shown, not after Stop, not after an error, which says why by itself). Goal runs have their own ending lines and are not guarded.
- Tests: `tests/agent-turn-guard.test.mjs` (counting, always; end to end with the real Harness, the scripted model's `searchloop` and `bloat` scripts and `searchMock: 'limited'` on the bridge, which runs the real search code on a pretend web that refuses, no network).
## Context: measured pressure, summaries that fit, the check before a message goes out (2026-10-22)
Found by the owner's hands-on test of the installed 0.2.1 (Gyro-S, 64K): a 118 KB log paste was 66,500 tokens to the engine and about 29,500 to the Harness (4 characters a token; logs and code are about 2); the engine refused it with a 400, the refusal stayed in the history, and then neither the next message nor `/compact` could go through, because the summary request carried the same history and was refused for its size too. `/compact` itself was never a command of the app: it went to the model as a question and the model answered "nothing to compact".
- **`plugins/strata-context.mjs`** (inserted by the profile, like the gate) wraps `tokenMeter.measure` and `llm.stream` in place, once each. Pressure = the last prompt size the model server reported for an answer, plus what changed since priced at the density that measurement showed (`context-budget.mjs`: `pressureNow`, `densityOf`); never lower than the Harness's own price. Node prices are scaled the same way, so the retained tail is in real tokens. A summary request (`purpose: 'compaction'`) that would not fit the window is asked for in pieces: a message bigger than a piece is cut for the request only, each piece builds on the summary before it, a piece the server refuses is halved and tried again. `/strata-context` (a Harness command, like `/strata-gate`) answers `{tokens, window}` for a session so the host can tell the person true figures.
- **`context-fit.mjs`**: before a message is sent the model server counts it (`POST /v1/messages/count_tokens`, its own tokenizer). `decideFit`: send; summarise first and then send (the message fits an otherwise empty chat); or never fits. The last one is not sent: the error frame has `code: 'CONTEXT_MESSAGE_TOO_LARGE'` and the page offers "Attach as a file" (`oversize.mjs`, `/api/chat/oversize`: the text is saved in the chat's folder, the assistant reads it in parts) or "Send a shortened preview". An engine that cannot count lets the message through; the Harness's own compact-and-retry is the net.
- **`/compact`** is handled by the host (`compactCommand`): the Harness's `/compact`, then a plain line with the measured figures ("The chat was 90% full and is now 38% full"), or "nothing older to summarize" when the newest message is what fills the chat.
- **Visible:** `compaction/start` shows "Summarizing earlier messages..." in the status line; `compaction/end` adds one plain line to the reply. The projector also sends a `context` frame after every model request, so the meter follows the running turn.
- **A refusal that is left** (`CONTEXT_WINDOW_EXCEEDED`) is a plain sentence with the engine's text under Details and the buttons "Summarize and continue" and "Start a new chat". "Try sending the message again" is for other errors only.
- Tests: `tests/agent-context.test.mjs` (arithmetic; the real Harness against the scripted worker with `window`, `charsPerToken` and `noCount`), `tests/oversize.test.mjs`, `tests/context-ui.test.mjs`. The run against a real engine is `scripts/context-e2e.mjs`.
+136 -7
View File
@@ -22,7 +22,7 @@ import { randomUUID } from 'node:crypto';
import { GatewayClient } from './gateway-client.mjs';
import { HarnessProcess, resolveHarness, redact, stopStaleAgent } from './harness-runtime.mjs';
import { StatusReporter } from './status.mjs';
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf } from './profile.mjs';
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudget, compactionHeadroom } from './profile.mjs';
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
import { ChildProjector } from './children.mjs';
import { nearestBudget } from './llm-shape.mjs';
@@ -30,6 +30,7 @@ import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
import { turnEngineSpeed } from './engine-speed.mjs';
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
import { BASE_TOKENS, COUNT_ABOVE_CHARS, FIT_TEXT, countTokens, decideFit, fill, grouped } from './context-fit.mjs';
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, stepSummary, planAsPrompt, editedSteps, planView, researchPrompt, researchWritePrompt, RESEARCH_NUDGE, parseSources } from './plan.mjs';
/**
@@ -111,11 +112,12 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
* @param {string|null} [options.componentsDir] where installed components live
* @param {() => Array} options.localInstances ready local instances (see profile.mjs)
* @param {() => Array} options.connections saved API connections WITH their keys (host-internal)
* @param {() => (string|null)} [options.defaultLocalId] the local instance the chat runs on: its tool servers are the agent's (T5)
* @param {NodeJS.ProcessEnv} [options.env]
* @param {string} [options.nodePath] the Node runtime that runs the Harness
* @param {(line:string)=>void} [options.log]
*/
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, defaultLocalId = () => null, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, engineProgress = () => null, engineGraceMs = 30 * 60_000, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
const root = join(dataDir, 'agent');
const home = join(root, 'home');
const sessionsFile = join(root, 'sessions.json');
@@ -171,7 +173,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** The agent's profile now, plus where its shaped routes really lead (read by the forwarding endpoint). */
let shapeTargets = {};
function buildProfileNow() {
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock });
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock, defaultLocalId: defaultLocalId() });
shapeTargets = profile.shapeTargets ?? {};
return profile;
}
@@ -193,6 +195,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
harness: { version: h.version },
runtime: rt?.hp.running ? { pid: rt.hp.pid, generation: rt.generation } : null,
routes: profile.routes.map(({ route, kind, modelId, name, contextWindow }) => ({ route, kind, modelId, name, contextWindow })),
// tool servers that could not be wired (a name the rule does not allow, no program or address): the chat says so
skippedServers: profile.mcpSkipped ?? [],
activeTurns: activeTurns(),
pendingApprovals: pending.size,
};
@@ -406,7 +410,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const appliedMode = planTask ? 'plan' : modeOf(mode);
await applyMode(runtime, sessionId, appliedMode);
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval });
// Does the message fit the model's window? Asked before anything is sent, with the model server's own count (context-fit.mjs). A message
// that fits once the older messages are a summary is sent after a summary; one that can never fit is not sent, and the person is told
// plainly and offered the two ways out. Whatever cannot be counted goes through: the Harness recovers from a refusal by itself.
if (!goal && route.kind === 'local') {
const fit = await fitCheck({ runtime, route, sessionId, fresh, history, message }).catch(() => null);
if (fit?.action === 'too-large') {
reporter.clear();
const values = [grouped(fit.tokens), grouped(fit.window)];
const text = fill(FIT_TEXT.tooLarge, values);
emit({ type: 'error', message: text, key: FIT_TEXT.tooLarge, values, code: 'CONTEXT_MESSAGE_TOO_LARGE', tokens: fit.tokens, window: fit.window });
return { error: text };
}
if (fit?.action === 'summarize') {
reporter.set({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
const done = await compactChat(runtime, sessionId);
reporter.clear();
if (done.ok) emit(summarizedNote(done.before, done.after, fit.window));
}
}
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval, window: route.contextWindow });
if (!goal) guard = new TurnGuard({ plan: !!planTask, contextMax: route.kind === 'local' ? route.contextWindow : 0 }); // a saved connection's window is often only assumed: never judged by a guess
projector.onStatus = update => { if (!turn?.parentOpen && turn?.children.size && [...turn.children.values()].some(h => !h.done) && update.phase === 'waiting') return; reporter.set(update); };
const turn = {
@@ -415,7 +439,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
abortFromRuntime: reason => finishTurn(reason), finish: failure => finishTurn(failure),
// Helper agents (subagents) the Harness started for this chat, and the state of the run around them.
children: new Map(), subagentCalls: new Map(), parentOpen: false, stopping: false, settleTimer: null, mode: appliedMode,
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), watchdog: null,
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), heard: Date.now(), engineMark: null, watchdog: null,
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
};
c.turn = turn;
@@ -428,6 +452,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
if (turn.finished || turn.stopping) return;
const toolRunning = [...projector.tools.values()].some(x => x.card?.state === 'running' || x.card?.state === 'proposed') || pending.size > 0 && pendingFor(chatId).length > 0;
const limit = toolRunning ? stallToolMs : stallMs;
// A local engine reading a long prompt sends the Harness nothing for minutes (#3623). Its /metrics progress counts as activity,
// for at most engineGraceMs after the Harness last spoke: progress for another chat cannot keep a lost turn alive forever.
if (turn.route?.kind === 'local' && Date.now() - turn.heard < engineGraceMs) {
let mark = null;
try { mark = engineProgress(turn.route.instanceId); } catch { /* no progress known: the Harness alone decides */ }
if (mark != null && mark !== turn.engineMark) { turn.engineMark = mark; turn.lastActivity = Date.now(); return; }
}
if (Date.now() - turn.lastActivity < limit) return;
turn.stalledFor = Math.round((Date.now() - turn.lastActivity) / 1000);
turn.stopping = true;
@@ -501,6 +532,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
function handle(item) {
if (turn.finished) return;
turn.lastActivity = Date.now();
turn.heard = turn.lastActivity;
if (item.type === 'event') {
const e = item.event;
if (e.seq <= turn.lastSeq) return;
@@ -538,7 +570,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
emitRow(child, true);
const fold = item => {
if (turn.finished) return;
turn.lastActivity = Date.now();
turn.lastActivity = turn.heard = Date.now();
// A helper that finishes before its throttled "working" row went out would show queued -> completed and never "working": send the held row first.
if (item.type === 'event' && item.event.type === 'turn/end' && child.rowPending) emitRow(child, true);
const { frames, row } = cp.consume(item);
@@ -951,6 +983,16 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
// -- /plan -----------------------------------------------------------------------------------------------
/** A short answer in the chat that the page translates (the sentence is in the interface catalog, "values" fill its slots). */
function sayKey(options, template, values = []) {
const { onFrame } = options;
onFrame({ type: 'queued', requestId: 'compact-' + randomUUID() });
onFrame({ type: 'start', model: '', contextMax: 0, demo: false, remoteModel: false, agent: true, mode: modeOf(options.mode), skills: [] });
onFrame({ type: 'delta', text: fill(template, values), key: template, values });
onFrame({ type: 'done', finishReason: 'stop', tokensIn: null, tokensOut: null, usageSource: 'unavailable', durationMs: 0 });
return { compact: true };
}
/** A short answer in the chat (no model): the same frames a turn sends. */
function sayOnce(options, reply) {
const { onFrame } = options;
@@ -1150,6 +1192,93 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
* Stop one chat's turn. Resolves only once the turn has ended (or the wait times out), because the
* Harness acknowledges a cancel as admitted, not as done.
*/
/** What the chat holds now and the model's window, as the context plugin measures them (null when it cannot be read). */
async function contextOf(runtime, sessionId) {
try {
const r = await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/strata-context', submittedAttachments: [] });
if (r?.result?.kind !== 'success') return null;
const v = JSON.parse(String(r.result.text));
return Number.isFinite(v.tokens) ? { tokens: Math.round(v.tokens), window: Number(v.window) > 0 ? Math.round(v.window) : 0 } : null;
} catch { return null; }
}
/** The decision of context-fit.mjs for the message about to be sent, or null when the model server cannot count it. */
async function fitCheck({ runtime, route, sessionId, fresh, history, message }) {
const inst = localInstances().find(i => i.id === route.instanceId);
if (!inst || !(route.contextWindow > 0)) return null;
const carried = fresh && history.length ? withEarlierConversation(history, message) : message;
if (carried.length < COUNT_ABOVE_CHARS) return null;
const count = text => countTokens({ baseUrl: inst.baseUrl, key: inst.key, model: inst.modelId, text });
let tokens = await count(carried);
if (tokens == null) return null;
const now = fresh ? null : await contextOf(runtime, sessionId);
const window = now?.window || route.contextWindow;
const pressure = now?.tokens ?? 0;
// Where the Harness starts summarising by itself on this window (profile.mjs: the reply keeps its room, then the headroom).
const reply = localOutputBudget(window);
const threshold = Math.floor(Math.min(window * 0.8, window - reply - compactionHeadroom(window, reply)));
let verdict = decideFit({ window, pressure, messageTokens: tokens, threshold });
// The earlier conversation carried into a fresh session is what pushed it over: send the message alone instead.
if (verdict.action === 'too-large' && carried !== message) {
const alone = await count(message);
if (alone != null) { tokens = alone; verdict = decideFit({ window, pressure, messageTokens: alone, threshold }); }
}
return { ...verdict, tokens, window, pressure };
}
/**
* Summarise the older part of a chat with the Harness's /compact and say what happened. Never throws.
* ok:true carries the figures before and after; ok:false says why (nothing to summarise, busy, failed).
*/
async function compactChat(runtime, sessionId) {
const before = await contextOf(runtime, sessionId);
let result;
try { result = (await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/compact', submittedAttachments: [] }))?.result; }
catch (e) { return { ok: false, reason: /not.found|no such/i.test(String(e.message)) ? 'nothing' : 'failed', before, text: String(e.message ?? e) }; }
const text = String(result?.text ?? '');
log('agent · compact: ' + (result?.kind ?? 'none') + ' ' + text.slice(0, 140));
if (result?.kind === 'success' && /^Compacted/i.test(text)) return { ok: true, before, after: await contextOf(runtime, sessionId) };
if (result?.kind === 'success') return { ok: false, reason: 'nothing', before, text };
if (/active compaction|not idle/i.test(text)) return { ok: false, reason: 'busy', before, text };
// The Harness cannot make a summary smaller than what is left when the chat holds little beyond its first prompt: that is nothing to summarise, not a failure.
const little = before && before.window > 0 && before.tokens - BASE_TOKENS < before.window * 0.25;
return { ok: false, reason: little && /useful summary|not smaller/i.test(text) ? 'nothing' : 'failed', before, text };
}
/** The line in the chat that explains why the context meter fell: how full it was and how full it is. */
function summarizedNote(before, after, window) {
const w = after?.window || before?.window || window || 0;
const pct = v => Math.max(0, Math.min(100, Math.round(100 * v.tokens / w)));
const known = w > 0 && before && after;
const template = known ? FIT_TEXT.summarized : FIT_TEXT.summarizedShort;
const values = known ? [pct(before), pct(after)] : [];
return { type: 'delta', own: true, text: '\n\n' + fill(template, values), key: template, values, noteReason: 'compacted' };
}
/** "/compact": summarise the older messages of this chat now, and say in plain words what happened. */
async function compactCommand(options) {
const { chatId, choice, signal } = options;
const c = chat(chatId);
const say = (template, values = []) => sayKey(options, template, values);
if (c.turn || c.starting) return say(FIT_TEXT.compactBusy);
const known = sessionMap[chatId]?.sessionId;
if (!known) return say(FIT_TEXT.noChatYet);
let runtime;
try { ({ rt: runtime } = await ensureRuntime(choice, signal)); } catch { return say(FIT_TEXT.compactFailed); }
const done = await compactChat(runtime, known);
if (done.ok) {
const note = summarizedNote(done.before, done.after, 0);
return say(note.key, note.values);
}
if (done.reason === 'busy') return say(FIT_TEXT.compactBusy);
if (done.reason === 'nothing') {
const w = done.before?.window || 0;
const pct = w > 0 && done.before ? Math.round(100 * done.before.tokens / w) : 0;
return say(pct >= 60 ? FIT_TEXT.nothingFull : FIT_TEXT.nothingToSummarize, pct >= 60 ? [pct] : []);
}
return say(FIT_TEXT.compactFailed);
}
/** Summarise the older part of a session with the Harness's own /compact. Best effort: the next message compacts again by itself when it must. */
async function compactSession(runtime, sessionId) {
try {
@@ -1204,7 +1333,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
return { compactCommand, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
}
/** A goal that stopped by itself, in words. */
+162
View File
@@ -0,0 +1,162 @@
/**
* Token arithmetic for the agent's context, with no I/O (the Harness plugin `plugins/strata-context.mjs` and the host both use it).
*
* Why it exists. The Harness prices a conversation with a fixed 4 characters per token. Code, logs and JSON are about twice as dense
* (2.09 characters per token in the 2026-10-22 hands-on test), so its pressure read 31,794 tokens while the engine had been sent 59,048,
* and compaction waited until the window was already past full. The engine reports the real prompt size with every answer; this module
* turns that report into the number compaction decides on, and splits a summarising request that would not fit the window.
*/
/** The Harness's own price of a text: characters over this, plus a fixed cost per block and per message. */
export const CHARS_PER_TOKEN = 4;
const BLOCK_OVERHEAD = 4;
const MESSAGE_OVERHEAD = 4;
/** The most a real token can be worth in the Harness's units (one character per token is the worst a tokenizer does). */
export const MAX_DENSITY = 6;
/** Room kept free in a window for the engine's own framing and the chat template (the engine keeps 64 tokens of slack itself). */
export const WINDOW_SLACK = 1024;
/** The Harness's price (4 characters a token) of one message, the same arithmetic as its token meter. */
export function estimateMessage(message) {
const content = Array.isArray(message?.content) ? message.content : [];
if (message?.role === 'system') {
if (!content.length) return 0;
let chars = 0;
for (const block of content) chars += block?.type === 'text' ? String(block.text ?? '').length : JSON.stringify(block ?? {}).length;
return Math.ceil(chars / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD;
}
let tokens = MESSAGE_OVERHEAD;
for (const block of content) {
if (block?.type === 'text' || block?.type === 'reasoning') tokens += Math.ceil(String(block.text ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
else if (block?.type === 'tool-call') tokens += Math.ceil(String(block.name ?? '').length / CHARS_PER_TOKEN) + Math.ceil(String(block.arguments ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
else tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block ?? {}).length / CHARS_PER_TOKEN);
}
return tokens;
}
/** The Harness's price of the tool list of a request header. */
export function estimateTools(header) {
const tools = header?.tools;
if (!Array.isArray(tools) || !tools.length) return 0;
return Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
}
/**
* How many real tokens one of the Harness's tokens is worth, from one measured request: the engine said the prompt was `real` tokens,
* the Harness priced the same prompt at `estimated`. Never below 1 (a conversation of plain prose is priced about right, and a lower
* number would only delay compaction) and never above MAX_DENSITY (a wild sample must not make every step compact).
*/
export function densityOf(real, estimated) {
if (!(real > 0) || !(estimated > 0)) return 1;
return Math.min(MAX_DENSITY, Math.max(1, real / estimated));
}
/**
* The prompt size compaction decides on. `sample` is the last measured request: `real` tokens for a prompt the Harness priced at
* `estimated`. `now` is the Harness's price of the conversation now. What was added or removed since the sample is scaled by the sample's
* density; the measured part is exact. Without a sample, or when the measurement is lower than the Harness's own price, the Harness's
* price stands (it is never an underestimate that makes this smaller).
*/
export function pressureNow({ sample = null, now }) {
if (!sample || !(sample.real > 0) || !(sample.estimated > 0)) return Math.max(0, now);
const density = densityOf(sample.real, sample.estimated);
return Math.max(Math.round(now), Math.round(sample.real + density * (now - sample.estimated)));
}
/** The usage a finished model request reported, as the prompt side only: input plus the cache's reads and writes (output is not in the prompt). */
export function promptTokensOf(usage) {
if (!usage || typeof usage !== 'object') return 0;
const n = value => (Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0);
return n(usage.inputTokens) + n(usage.cacheReadTokens) + n(usage.cacheWriteTokens);
}
/** The usage of a settled assistant event (a `usage` on the message, or the last usage chunk of its stream). */
export function usageOfEvent(event) {
const data = event?.data;
if (!data) return null;
if (data.usage && typeof data.usage === 'object') return data.usage;
const stream = Array.isArray(data.stream) ? data.stream : [];
for (let i = stream.length - 1; i >= 0; i--) if (stream[i]?.type === 'chunk' && stream[i].chunk?.type === 'usage') return stream[i].chunk.usage ?? null;
return null;
}
/**
* A summarising request is: the conversation's system prompt and tools, the messages to condense, then the instruction. This is how many
* real tokens the messages may take in a window so that all of it, the summary and a safety margin fit.
*/
export function summaryBudget({ window, summaryTokens, toolsTokens = 0, systemTokens = 0, instructionTokens = 900, slack = WINDOW_SLACK }) {
return Math.floor(window - summaryTokens - toolsTokens - systemTokens - instructionTokens - slack);
}
/** Text blocks of a message, longest first: the ones worth shortening when a single message is bigger than the budget. */
function textBlocks(message) {
return (Array.isArray(message?.content) ? message.content : []).filter(b => (b?.type === 'text' || b?.type === 'reasoning') && typeof b.text === 'string');
}
/**
* One message cut down to `maxTokens` (in the Harness's price times `density`): the start and the end of its longest text are kept and
* the middle is replaced by a line saying how much was left out. Only the copy sent to the summariser is cut; the conversation keeps
* the whole message until the summary replaces it.
*/
export function clipMessage(message, maxTokens, density = 1) {
if (estimateMessage(message) * density <= maxTokens) return message;
const blocks = textBlocks(message);
if (!blocks.length) return message;
const others = estimateMessage({ ...message, content: (message.content ?? []).filter(b => !blocks.includes(b)) }) * density;
const room = Math.max(256, maxTokens - others);
const share = room / blocks.reduce((n, b) => n + b.text.length, 0) / density * CHARS_PER_TOKEN; // characters kept per character
const content = (message.content ?? []).map((block) => {
if (!blocks.includes(block)) return block;
const keep = Math.max(200, Math.floor(block.text.length * Math.min(1, share) - 120));
if (block.text.length <= keep) return block;
const head = Math.ceil(keep * 0.6), tail = keep - head;
const left = block.text.length - head - tail;
return { ...block, text: `${block.text.slice(0, head)}\n\n[… ${left.toLocaleString('en-US')} characters from the middle are left out of this summary request …]\n\n${block.text.slice(block.text.length - tail)}` };
});
return { ...message, content };
}
/** Whether cutting the list in front of index `i` would separate a tool call from its result. */
function balancedBefore(messages, i) {
let open = 0;
for (let k = 0; k < i; k++) {
const m = messages[k];
if (m?.role === 'assistant') open += (Array.isArray(m.content) ? m.content : []).filter(b => b?.type === 'tool-call').length;
else if (m?.role === 'tool') open -= 1;
}
return open <= 0;
}
/**
* Split messages into runs that each fit `budget` real tokens (price `density`), cutting between a tool call's result and the next
* message when it can. A message bigger than the budget by itself is clipped first. Order is kept.
*/
export function splitMessages(messages, budget, density = 1) {
const room = Math.max(512, budget);
const sized = messages.map(m => clipMessage(m, Math.floor(room * 0.8), density));
const cost = sized.map(m => estimateMessage(m) * density);
const runs = [];
let start = 0, used = 0;
for (let i = 0; i < sized.length; i++) {
if (i > start && used + cost[i] > room) {
// Cut as late as possible, but never between a tool call and its result when a clean place exists inside the run.
let cut = i;
while (cut > start + 1 && !balancedBefore(sized, cut)) cut--;
if (!balancedBefore(sized, cut)) cut = i;
runs.push(sized.slice(start, cut));
start = cut;
used = cost.slice(start, i).reduce((n, x) => n + x, 0);
}
used += cost[i];
}
if (start < sized.length) runs.push(sized.slice(start));
return runs;
}
/** Whether a failure is the model server saying the prompt does not fit its window. */
export function isContextOverflow(error) {
if (!error) return false;
if (error.code === 'CONTEXT_WINDOW_EXCEEDED') return true;
const text = [error.message, error.cause?.message, error.failure?.message, error.failure?.code].filter(Boolean).join(' ');
return /request exceeds the context window|leaves no room to answer|context[_ ]length|context window|too many tokens|maximum context/i.test(text);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Does a message fit the model's window? Asked before it is sent, with the model server's own count, so a message the server would refuse
* is never read for half a minute first (2026-10-22: a 103 KB paste was 70,141 tokens in a 65,536-token window, and the 400 came back after
* the whole prompt had been read). No I/O except the one count request; everything else is arithmetic the tests call directly.
*
* Three answers: send it; summarise the chat first and then send it (the message fits once the older messages are a summary); or it can
* never fit, and the person is offered the two ways out that do (the text as a file the assistant reads in parts, or a shortened preview).
*/
/** What a new chat already holds before the person's first word: the system prompt and the tool list. Measured 10,510 by the Harness's price. */
export const BASE_TOKENS = 12000;
/** The least room a reply needs after the prompt: an eighth of the window, at most this. */
export const REPLY_FLOOR = 4096;
/** A message shorter than this many characters cannot matter to a window of 8K or more, so it is not even counted. */
export const COUNT_ABOVE_CHARS = 3000;
export const replyRoom = window => Math.max(512, Math.min(REPLY_FLOOR, Math.floor(window / 8)));
/**
* @param {{window:number, pressure?:number, base?:number, messageTokens:number, threshold?:number}} input
* `pressure` is what the chat holds now (measured), `base` what an empty chat holds, `messageTokens` the new message, `threshold` where the
* Harness starts summarising by itself. The Harness looks at that only before its NEXT step, so a big message that arrives now would otherwise
* be sent on top of a chat already near the threshold (2026-10-22: 54K of 64K held by the time it was looked at): summarise first.
* @returns {{action:'send'|'summarize'|'too-large', room:number, needs:number}}
*/
export function decideFit({ window, pressure = 0, base: given = BASE_TOKENS, messageTokens, threshold = 0 }) {
const reply = replyRoom(window);
const base = Math.min(given, Math.floor(window * 0.6)); // on a small window the first prompt cannot be what the window is made of
// A chat holds at least what an empty one does (the system prompt and the tools), so a new chat is not counted as empty.
const needs = Math.max(pressure, base) + messageTokens + reply;
if (!(window > 0) || !(messageTokens >= 0)) return { action: 'send', room: Infinity, needs: 0 };
if (needs <= window) {
const worthIt = pressure > base + window * 0.1; // older messages worth summarising: more than a tenth of the window beyond the first prompt
if (threshold > 0 && worthIt && pressure + messageTokens > threshold) return { action: 'summarize', room: window - needs, needs, why: 'threshold' };
return { action: 'send', room: window - needs, needs };
}
// Not as it stands. Does the message fit in an otherwise empty chat (only the system prompt and the tools before it)?
if (base + messageTokens + reply <= window) return { action: 'summarize', room: window - (base + messageTokens + reply), needs };
return { action: 'too-large', room: window - (base + messageTokens + reply), needs };
}
/**
* The model server's count of a text (its tokenizer and chat template), through its Anthropic-style count endpoint.
* Returns the number, or null when it cannot be had (an old engine, a server that is busy, a timeout): then nothing is blocked.
*/
export async function countTokens({ baseUrl, key = '', model = '', text, fetchImpl = fetch, timeoutMs = 8000 }) {
try {
const root = String(baseUrl ?? '').replace(/\/v1\/?$/, '').replace(/\/$/, '');
const res = await fetchImpl(`${root}/v1/messages/count_tokens`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}`, 'x-api-key': key } : {}) },
body: JSON.stringify({ model, messages: [{ role: 'user', content: String(text) }] }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
const n = Number((await res.json())?.input_tokens);
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
} catch { return null; }
}
/** The plain sentences (also in the interface catalog in all eight languages). `{0}` slots are filled by the page. */
export const FIT_TEXT = Object.freeze({
tooLarge: 'This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.',
tooLargeNext: 'Attach it as a file for the assistant to read in parts, or send a shortened preview.',
full: 'This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.',
fullNext: 'Summarize it now and continue, or start a new chat.',
summarizing: 'Summarizing earlier messages…',
summarized: 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.',
summarizedWas: 'Older messages were summarized to make room. The chat was {0}% full.',
summarizedShort: 'Older messages were summarized to make room.',
nothingFull: 'This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.',
nothingToSummarize: 'There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.',
noChatYet: 'There is nothing to summarize yet. This chat has not started a conversation with the assistant.',
compactFailed: 'The older messages could not be summarized right now. Try again in a moment, or start a new chat.',
compactBusy: 'This chat is still working. Stop it or wait for it to finish, then try again.',
});
export const fill = (template, values = []) => String(template).replace(/\{(\d+)\}/g, (_, i) => String(values[Number(i)] ?? ''));
/** A number with thousands separators, the way the page writes it. */
export const grouped = n => Math.round(n).toLocaleString('en-US');
/**
* The two ways to send a message that is too large.
* file the whole text goes in a file in the chat's folder; the message names it and shows how it starts
* preview the start and the end of the text, cut to what the window can hold, with the number of characters left out
* @param {{text:string, mode:'file'|'preview', tokens:number, window:number, pressure?:number, base?:number, path?:string}} input
* @returns {{message:string, kept:number}}
*/
export function oversizedReplacement({ text, mode, tokens, window, pressure = 0, base = BASE_TOKENS, path = '' }) {
const t = String(text);
const charsPerToken = tokens > 0 ? Math.max(0.8, t.length / tokens) : 3;
if (mode === 'file') {
const start = t.slice(0, 1200).trimEnd();
return { kept: start.length, message: `My message was too long to send in the chat, so I saved it as a file: ${path}\n\nIt starts like this:\n\n${start}\n\n[the rest is in the file]\n\nPlease read the file in parts (it is long) and then do what my message asks.` };
}
const room = Math.max(1000, Math.floor((window - Math.max(pressure, base) - replyRoom(window)) * 0.7));
const keep = Math.max(500, Math.floor(room * charsPerToken * 0.9));
const head = Math.ceil(keep * 0.7), tail = keep - head;
const left = Math.max(0, t.length - head - tail);
if (!left) return { kept: t.length, message: t };
return { kept: head + tail, message: `${t.slice(0, head)}\n\n[… ${grouped(left)} characters from the middle are left out because the message was too long to send …]\n\n${t.slice(t.length - tail)}` };
}
@@ -0,0 +1,103 @@
/**
* Runs the command of a command tool the person wrote (Settings -> Tools). Split from custom-tools-mcp.mjs so it can be tested.
*
* T7 (tools and images review, 2026-10-22). It used to run every command with `shell: true` (cmd.exe on Windows, so `Get-Content x` failed with
* "not recognized"), in whatever folder the host happened to run in (so `python tools/foo.py` could not find its file), and ended it after two
* minutes with 20,000 characters of output, silently cut.
*
* shell 'auto' (default): PowerShell for a Windows command that reads as PowerShell (Get-Content, Start-Process, $env:X, $(...)), cmd.exe for
* any other Windows command (so every command that worked keeps working), /bin/sh elsewhere. 'cmd', 'powershell', 'bash' and 'sh' name one.
* cwd the folder to run in: the tool's own, or the one the assistant names in the call (it knows the chat's folder); else the person's home.
* timeoutSec the time allowed, 1 to 3600 (default 120). A command that runs over is ended together with the programs it started.
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
export const DEFAULT_TIMEOUT_SEC = 120;
export const MAX_TIMEOUT_SEC = 3600;
export const OUT_MAX = 20000;
export const SHELLS = ['auto', 'cmd', 'powershell', 'bash', 'sh'];
/** A Windows command that is PowerShell and would fail in cmd.exe. */
const POWERSHELL_LOOKS = /(^|[\s;|(&])[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b|\$\w|\$\(|\$env:|@\(|\|\s*(?:%|\?)\s*\{/;
export const looksLikePowerShell = command => POWERSHELL_LOOKS.test(String(command ?? ''));
let powershellFound;
/** pwsh when it is installed (PowerShell 7), else the Windows PowerShell every Windows has. */
function powershellExe(platform) {
if (powershellFound) return powershellFound;
if (platform !== 'win32') return (powershellFound = 'pwsh');
try { if (spawnSync('where', ['pwsh'], { windowsHide: true, timeout: 5000 }).status === 0) return (powershellFound = 'pwsh'); } catch { /* none */ }
return (powershellFound = 'powershell.exe');
}
/**
* -> { file, args, shell } for spawn: which program runs `command`.
* @param {string} command
* @param {{shell?: string, platform?: string}} [options]
*/
export function planShell(command, { shell = 'auto', platform = process.platform } = {}) {
const windows = platform === 'win32';
let choice = SHELLS.includes(shell) ? shell : 'auto';
if (choice === 'auto') choice = windows ? (looksLikePowerShell(command) ? 'powershell' : 'cmd') : 'sh';
if (choice === 'powershell') return { file: powershellExe(platform), args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], shell: false, name: 'PowerShell' };
if (choice === 'bash') return { file: 'bash', args: ['-c', command], shell: false, name: 'bash' };
if (choice === 'sh') return windows ? { file: 'sh', args: ['-c', command], shell: false, name: 'sh' } : { file: command, args: [], shell: true, name: 'sh' };
return { file: command, args: [], shell: true, name: windows ? 'cmd.exe' : 'sh' }; // cmd: spawn's shell:true is cmd.exe on Windows
}
/** The folder a command runs in: -> { cwd } or { error } with a plain sentence. */
export function resolveCwd(...candidates) {
for (const candidate of candidates) {
const text = typeof candidate === 'string' ? candidate.trim() : '';
if (!text) continue;
const expanded = text.replace(/^~(?=$|[\\/])/, homedir());
try {
if (!existsSync(expanded) || !statSync(expanded).isDirectory()) return { error: `The folder “${text}” does not exist, so the command was not run.` };
} catch { return { error: `The folder “${text}” could not be opened, so the command was not run.` }; }
return { cwd: expanded };
}
return { cwd: homedir() };
}
export const clampTimeoutSec = value => {
const n = Number(value);
return Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), MAX_TIMEOUT_SEC) : DEFAULT_TIMEOUT_SEC;
};
function endTree(child) {
try {
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10000 });
else child.kill('SIGKILL');
} catch { /* gone */ }
}
/**
* @param {{command: string, input?: string, cwd?: string, callCwd?: string, shell?: string, timeoutSec?: number, platform?: string}} job
* @returns {Promise<{ok: boolean, text: string}>}
*/
export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto', timeoutSec, platform = process.platform }) {
return new Promise(resolve => {
const where = resolveCwd(callCwd, cwd);
if (where.error) return resolve({ ok: false, text: where.error });
const plan = planShell(command, { shell, platform });
const limit = clampTimeoutSec(timeoutSec);
let child;
try {
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) { return resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); }
let text = '', cut = false, timedOut = false;
const take = chunk => { if (text.length < OUT_MAX + 1) text += chunk.toString(); else cut = true; };
child.stdout.on('data', take); child.stderr.on('data', take);
const timer = setTimeout(() => { timedOut = true; endTree(child); }, limit * 1000);
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); });
child.on('close', code => {
clearTimeout(timer);
if (text.length > OUT_MAX) { cut = true; text = text.slice(0, OUT_MAX); }
const out = text.trim() + (cut ? `\n\n(The output was cut after ${OUT_MAX.toLocaleString('en-US')} characters.)` : '');
if (timedOut) return resolve({ ok: false, text: `The command was stopped after ${limit} seconds.${out ? '\n' + out : ''}` });
resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
});
});
}
+9 -23
View File
@@ -8,49 +8,35 @@
* It speaks the Model Context Protocol over stdio (newline-delimited JSON-RPC), the transport the Harness's MCP client uses for a program
* it starts. The file is re-read on every request, so a change in Settings is seen without restarting anything. The assistant's text for
* a command tool reaches the command in the STRATA_INPUT environment variable (never spliced into the command line, so it cannot add
* commands of its own). A command gets 2 minutes and 20,000 characters of output.
* commands of its own). A command runs in the shell that suits it (PowerShell for a Windows command that reads as PowerShell, cmd.exe for
* the rest, sh elsewhere), in its own folder or the one the assistant names, gets 2 minutes unless its tool allows more (up to an hour) and
* 20,000 characters of output (custom-command-run.mjs).
*/
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { runCommand } from './custom-command-run.mjs';
const [mode, file] = process.argv.slice(2);
if (!['instructions', 'commands'].includes(mode) || !file) { console.error('usage: custom-tools-mcp.mjs instructions|commands <tools.json>'); process.exit(2); }
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8'))[mode] ?? {}; } catch { return {}; } };
const OUT_MAX = 20000;
const TIME_MS = 120000;
const schema = mode === 'commands'
? { type: 'object', properties: { input: { type: 'string', description: 'Text to give the command (optional).' } }, additionalProperties: false }
? { type: 'object', properties: {
input: { type: 'string', description: 'Text to give the command (optional).' },
cwd: { type: 'string', description: 'The folder to run the command in (optional). Give the working folder of this chat when the command uses relative paths.' },
}, additionalProperties: false }
: { type: 'object', properties: {}, additionalProperties: false };
function listTools() {
return Object.entries(load()).map(([name, t]) => ({ name, description: String(t.description ?? ''), inputSchema: schema, annotations: mode === 'instructions' ? { readOnlyHint: true } : {} }));
}
function runCommand(command, input) {
return new Promise(resolve => {
const child = spawn(command, { shell: true, windowsHide: true, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
let text = '';
const take = chunk => { if (text.length < OUT_MAX) text += chunk.toString(); };
child.stdout.on('data', take); child.stderr.on('data', take);
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, TIME_MS);
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start: ${e.message}` }); });
child.on('close', (code, signal) => {
clearTimeout(timer);
const out = text.slice(0, OUT_MAX).trim();
if (signal) resolve({ ok: false, text: `The command was stopped after ${TIME_MS / 1000} seconds.${out ? '\n' + out : ''}` });
else resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
});
});
}
async function call(name, args) {
const t = load()[name];
if (!t) return { isError: true, content: [{ type: 'text', text: 'That tool is no longer available.' }] };
if (mode === 'instructions') return { content: [{ type: 'text', text: String(t.text ?? '') }] };
const r = await runCommand(String(t.command ?? ''), args?.input);
const r = await runCommand({ command: String(t.command ?? ''), input: args?.input, callCwd: args?.cwd, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec });
return { isError: !r.ok, content: [{ type: 'text', text: r.text }] };
}
@@ -0,0 +1,11 @@
/**
* How far a local engine has got, as a short text that changes whenever it makes progress. The agent's stall watchdog
* compares it with the last one it saw: while a long prompt is read the Harness hears nothing for minutes, but the
* engine's /metrics (polled every 2 s) shows `live.prompt_read` moving (#3623). Null when the engine is idle or says nothing.
*/
export function engineProgressMark(instance) {
const live = instance?.metrics?.live;
if (!live || live.state === 'idle' || live.state === 'unloaded') return null;
const slots = Array.isArray(live.slots) ? live.slots.map(s => `${s?.state}:${s?.prompt_read ?? ''}:${s?.generated ?? ''}`).join(',') : '';
return `${live.state}|${live.prompt_read ?? ''}|${live.generated ?? ''}|${slots}`;
}
+36 -6
View File
@@ -9,6 +9,8 @@
import { toolUpdate, TokenCount, fmtTokens } from './status.mjs';
import { askFor } from './ask.mjs';
import { FIT_TEXT, fill } from './context-fit.mjs';
import { isContextOverflow } from './context-budget.mjs';
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
const WRITE = new Set(['write', 'edit']);
@@ -157,6 +159,9 @@ export function withPlain(raw) {
return plain ? `${plain}\n\nDetails: ${raw.replace(/^Error:\s*/, '')}` : raw;
}
/** What a launch that printed nothing proves, and no more. */
export const LAUNCHED_TEXT = 'The command finished without an error, so the program was started. Strata cannot confirm that a window or page actually opened.';
/** A command whose whole point is to open something: it has no output to show when it worked. */
const LAUNCH = /(^|[\s;|&(])(start-process|start|invoke-item|ii|explorer(?:\.exe)?|xdg-open|open|notepad(?:\.exe)?|code)(?=[\s;|&)]|$)/i;
@@ -172,9 +177,10 @@ export function outcomeText(tool, text, args = {}) {
text = text.replace(/\r?\n?\[stderr\]\r?\n(?:Cannot create type\. Only core types are supported in this language mode\.[\s\S]*?CannotCreateTypeConstrainedLanguage\s*)+$/, '');
if (!text.trim()) text = '(no output)';
}
// Starting a program or opening a folder prints nothing when it worked. The model used to read "finished, nothing to show" as "cannot tell
// whether it opened" and told the person so; a launch that returned without an error did start, and the result says that plainly.
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return 'Started. The program, folder or file was opened.';
// Starting a program or opening a folder prints nothing when it worked, and also when it did not (a browser given a file:// address, a
// program that closed at once). T9 / #3576: the card used to say "was opened", and the model repeated it ("I previewed it in a browser to
// confirm it renders"). It says what is known: the command returned without an error.
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return LAUNCHED_TEXT;
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim())) return 'The command finished without any error. It printed nothing, so there is nothing more to show.';
return text;
}
@@ -196,8 +202,10 @@ export class TurnProjector {
* no question ever reaches the person: the card goes straight from running to denied, without a moment where it
* offers Approve and Deny buttons that would do nothing.
*/
constructor({ startedAt = Date.now(), policy = 'ask' } = {}) {
constructor({ startedAt = Date.now(), policy = 'ask', window = 0 } = {}) {
this.startedAt = startedAt;
this.window = Number(window) || 0; // the model's window, for the figures in the line that says the chat was summarised
this.summarizing = false;
this.policy = policy;
this.byPolicy = new Set(); // callIds the approval policy rejected without asking
this.tools = new Map(); // callId -> { card, calledAt }
@@ -303,6 +311,7 @@ export class TurnProjector {
this.tokensIn += prompt;
this.tokensOut += Number(c.usage.outputTokens) || 0;
this.contextTokens = prompt + (Number(c.usage.outputTokens) || 0);
out.push({ type: 'context', contextTokens: this.contextTokens, contextMax: this.window || undefined });
}
}
return out;
@@ -371,6 +380,22 @@ export class TurnProjector {
if (frame) out.push(frame);
break;
}
case 'compaction/start':
// The Harness is summarising the older messages to make room: say so while it does, and again (once) when it is done.
this.summarizing = true;
this.#status({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
break;
case 'compaction/end':
if (!this.summarizing) break;
this.summarizing = false;
this.#afterTool();
if (!d.error) {
const before = this.window > 0 && this.contextTokens ? Math.min(100, Math.round(100 * this.contextTokens / this.window)) : null;
const text = before === null ? FIT_TEXT.summarizedShort : FIT_TEXT.summarizedWas;
const values = before === null ? [] : [before];
out.push({ type: 'delta', own: true, text: '\n\n' + fill(text, values), key: text, values, noteReason: 'compacted' });
}
break;
case 'turn/start':
// The parent woke up again (a helper finished and reported back): the message goes on, as a new paragraph.
this.ended = null;
@@ -420,7 +445,7 @@ export class TurnProjector {
case 'aborted':
return [{ type: 'done', ...base, ...usage, finishReason: 'aborted', cancelled: true }];
case 'error':
return [{ type: 'error', message: friendlyFailure(reason.error), code: reason.error?.code ?? 'UNKNOWN' }];
return [{ type: 'error', message: friendlyFailure(reason.error), code: isContextFailure(reason.error) ? 'CONTEXT_WINDOW_EXCEEDED' : reason.error?.code ?? 'UNKNOWN', ...(isContextFailure(reason.error) ? { key: FIT_TEXT.full, details: String(reason.error?.message ?? '') } : {}) }];
case 'blocked':
return [{ type: 'error', message: 'The agent stopped because this turn was blocked by policy.', code: 'BLOCKED' }];
default:
@@ -429,10 +454,15 @@ export class TurnProjector {
}
}
/** Whether a failure is the model server saying the conversation does not fit its window (its code, or the engine's own words). */
export function isContextFailure(failure) {
return isContextOverflow(failure) || /context window|too long for|exceeds the context/i.test(String(failure?.message ?? ''));
}
/** A model-route failure in words a user can act on. */
export function friendlyFailure(failure) {
const message = String(failure?.message ?? 'The model request failed.');
if (/context window|too long|exceeds/i.test(message)) return `The conversation no longer fits the model's context window. ${clip(message, 200)}`;
if (isContextFailure(failure)) return FIT_TEXT.full;
if (/ECONNREFUSED|fetch failed|connect/i.test(message)) return `The model is not reachable. ${clip(message, 200)}`;
if (/401|403|credential|api key/i.test(message)) return 'The model rejected the credentials. Check the connection in Settings.';
return clip(message, 400);
+1 -1
View File
@@ -33,7 +33,7 @@ export function agentEnvironment(base = process.env, extra = {}) {
}
// DSH_PERMISSION_MODE is the composition default for a session no chat mode has touched (a child agent, a
// seeded session): the strictest sandbox, which matches the Manual preset, never the Harness's wider default.
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', ...extra };
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', NODE_USE_SYSTEM_CA: '1', ...extra };
}
/**
+1 -1
View File
@@ -117,7 +117,7 @@ export function stepObjective(plan, index) {
`You are on step ${index + 1} of ${n}: ${s.text}${s.detail ? ` (${s.detail})` : ''}.`,
done ? `Already done: ${done}.` : '',
plan.briefing ? String(plan.briefing) : '',
isViewStep(s.text) ? 'This step is about seeing the result in a browser. Never wait for a program to close. If a person is at this computer, start the browser so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say it is open. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
isViewStep(s.text) ? 'This step is about seeing the result. To show a local image, SVG, HTML page or PDF, use the present tool (a browser cannot open file:// addresses). Never wait for a program to close. If a person is at this computer and you start a browser, start it so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say only that you started it, not that the page rendered. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
'Do only this step, check that it worked, and then finish the goal.',
index + 1 < n ? 'The next steps are handled separately: do not start them.' : '',
].filter(Boolean).join(' ');
@@ -0,0 +1,159 @@
/**
* Strata's context plugin for the Harness (plain ESM, loaded by absolute path from the profile, like strata-gate and strata-models).
*
* What it fixes (2026-10-22 hands-on test, Gyro-S at 64K):
* 1. Compaction decided from the Harness's own price of the conversation, 4 characters a token. Code, logs and JSON are about twice as
* dense, so a chat the engine had read as 59,048 tokens was priced at 31,794 and compaction started far too late. Here the token meter
* is calibrated by what the model server reports for each answer (its real prompt size): the measured size is exact, and what was
* added since is priced with the density that measurement showed. Automatic compaction and the manual /compact both measure through
* the token meter, so both read the same number as the context meter in the app.
* 2. The summarising request itself could be bigger than the window ("/compact" sent a 66,507-token prompt to a 65,536 window). The
* summary is now asked for in pieces that fit: a message bigger than a piece is cut for the request (the conversation keeps it until
* the summary replaces it), the pieces are summarised one after the other, each building on the summary before it, and a piece the
* server still refuses is halved and tried again.
*
* How. It wraps `tokenMeter.measure` and `llm.stream` in place, once each (the same way strata-models wraps the subagent tools). Only a
* request the Harness marks `purpose: 'compaction'` is touched in `llm.stream`. Nothing in the Harness is rewritten: a Harness update
* that renames either leaves compaction as it was, and the test that runs the real Harness fails.
*/
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK } from '../context-budget.mjs';
export const name = 'strata-context';
export const inject = ['tokenMeter', 'llm'];
/** Real prompt measurements per session: { real, estimated } for the last answer the model server reported. */
const samples = new WeakMap();
const bySessionId = new Map();
const wrapped = new WeakSet();
/** Densities that made a summary request fail on this server, per session id (the next request starts from a more careful one). */
const careful = new Map();
export function apply(ctx) {
const meter = ctx.tokenMeter;
const llm = ctx.llm;
const log = (level, text) => { try { ctx.logger?.[level]?.(`strata-context: ${text}`); } catch { /* logging never blocks */ } };
// 1. The measured size of the prompt, from the model server's own report for each answer.
if (meter && typeof meter.measure === 'function' && !wrapped.has(meter)) {
wrapped.add(meter);
const original = meter.measure;
meter.measure = function measure(session, requestHeader) {
const m = original.call(this, session, requestHeader);
const sample = samples.get(session);
if (!sample) return m;
const now = m.surfaceTokens + estimateTools(requestHeader ?? session.requestHeader?.());
const total = Math.max(m.totalTokens, pressureNow({ sample, now }));
if (total <= m.totalTokens) return m;
const density = densityOf(sample.real, sample.estimated);
return { ...m, totalTokens: total, nodes: m.nodes.map(n => ({ ...n, tokens: Math.ceil(n.tokens * density) })) };
};
ctx.on('session/event', (session, event) => {
if (event.type !== 'assistant/message') return;
const real = promptTokensOf(usageOfEvent(event));
if (!(real > 0)) return;
try {
// The request that was just answered saw the conversation without this answer: its price is the surface before the answer's node.
const m = original.call(meter, session);
const heuristic = m.nodes.reduce((n, x) => n + (x.seq === event.seq ? 0 : x.heuristicTokens), 0);
const estimated = heuristic + estimateTools(session.requestHeader?.());
if (!(estimated > 0)) return;
const sample = { real, estimated };
samples.set(session, sample);
if (session.id) bySessionId.set(String(session.id), sample);
} catch (e) { log('warn', `could not record a measurement: ${e.message}`); }
});
}
// 2. A summary request that fits the window.
if (llm && typeof llm.stream === 'function' && !wrapped.has(llm)) {
wrapped.add(llm);
const original = llm.stream;
llm.stream = function stream(options) {
if (options?.purpose !== 'compaction') return original.call(this, options);
return summaryStream.call(this, options, original);
};
}
// The host asks, with /strata-context, how much of the window the chat holds right now: the same measured number compaction decides on
// (JSON in the command's text: { tokens, window }). Before a message goes out, and after /compact, so the person is told true figures.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
definitionId: 'strata-context',
name: 'strata-context',
description: 'Reports how many tokens the conversation holds now and the size of the model window',
handler: async (invocation) => {
try {
const session = invocation.agent.session;
const tokens = meter.measure(session).totalTokens;
const config = session.requestHeader?.()?.config;
let window = 0;
if (config) window = (await llm.resolveModelInfo(config.provider, config.model))?.context?.contextWindow ?? 0;
return { kind: 'success', text: JSON.stringify({ tokens, window }) };
} catch (e) { return { kind: 'error', text: `strata-context: ${e.message}` }; }
},
});
});
/** A finished stream: its chunks, the text it wrote and why it ended. */
async function drain(iterable) {
let text = '', finish = null;
const chunks = [];
for await (const chunk of iterable) {
chunks.push(chunk);
if (chunk.type === 'text-delta') text += chunk.text ?? '';
else if (chunk.type === 'finish') finish = chunk.reason;
}
return { text, finish, chunks };
}
const refusedForSize = finish => finish?.kind === 'error' && isContextOverflow(finish.failure ?? finish);
async function* summaryStream(options, original) {
let window = 0;
try { window = (await llm.resolveModelInfo(options.provider, options.model, options.signal))?.context?.contextWindow ?? 0; } catch { window = 0; }
const messages = options.messages ?? [];
const key = String(options.sessionId ?? '');
const summaryTokens = options.maxTokens ?? 4096;
if (!(window > 0) || messages.length < 2) { yield* original.call(this, options); return; }
const own = bySessionId.get(key);
let density = Math.max(careful.get(key) ?? 1, own ? densityOf(own.real, own.estimated) : 1);
const price = list => list.reduce((n, m) => n + estimateMessage(m), 0) * density;
const limit = window - summaryTokens - WINDOW_SLACK;
// The whole request fits by this price: ask as the Harness would (the model server's cache of the conversation is reused).
if (price(messages) + estimateTools({ tools: options.tools }) * density <= limit) {
const first = await drain(original.call(this, options));
if (!refusedForSize(first.finish)) { for (const chunk of first.chunks) yield chunk; return; }
density = Math.min(MAX_DENSITY, density * 1.6);
careful.set(key, density);
log('warn', 'the summary request did not fit the window; asking in pieces');
}
// In pieces. The instruction is the last message; the system prompt, when there is one, the first.
const instruction = messages[messages.length - 1];
const system = messages[0]?.role === 'system' ? messages[0] : null;
const body = messages.slice(system ? 1 : 0, -1);
let tools = options.tools;
if (tools && estimateTools({ tools }) * density > window / 3) tools = undefined; // a long tool list only describes tools: the summary does not need it
const fixed = (system ? estimateMessage(system) : 0) * density + estimateTools({ tools }) * density + estimateMessage(instruction) * density;
let budget = summaryBudget({ window, summaryTokens: summaryTokens * 2, systemTokens: fixed, instructionTokens: 0 });
for (let attempt = 0; ; attempt++) {
const runs = splitMessages(body, Math.max(1024, budget), density);
log('info', `summarising ${body.length} messages in ${runs.length} piece(s) for a ${window}-token window (density ${density.toFixed(2)}, about ${Math.round(budget)} tokens a piece)`);
let failed = null, summary = '';
for (let i = 0; i < runs.length && !failed; i++) {
options.signal?.throwIfAborted?.();
const carried = summary ? [{ role: 'user', content: [{ type: 'text', text: `<compacted-summary>\n${summary}\n</compacted-summary>` }] }] : [];
const { tools: _drop, ...rest } = options;
const piece = { ...rest, ...(tools ? { tools } : {}), messages: [...(system ? [system] : []), ...carried, ...runs[i], instruction] };
const { text, finish, chunks } = await drain(original.call(this, piece));
if (refusedForSize(finish)) { failed = finish; break; }
// The last piece's answer is the summary the Harness reads; a piece that ended in another error ends the request the same way.
if (i === runs.length - 1 || finish?.kind === 'error' || finish?.kind === 'aborted') { for (const chunk of chunks) yield chunk; return; }
summary = text;
}
if (!failed || attempt >= 3) throw Object.assign(new Error(failed?.failure?.message ?? 'the summary request does not fit the window'), { code: 'CONTEXT_WINDOW_EXCEEDED' });
density = Math.min(MAX_DENSITY, density * 1.6);
careful.set(key, density);
budget = Math.max(1024, budget / 2);
log('warn', `a piece did not fit; asking again with pieces of about ${Math.round(budget)} tokens`);
}
}
}
+38 -3
View File
@@ -68,8 +68,37 @@ const SAFE_VERSION = /^(node|python|python3|py|npm|pnpm|git|dotnet|cargo|rustc|g
/** Anything that can chain, redirect, expand or start something else is not read-only. */
const UNSAFE_SYNTAX = /[;&<>`${}()]|\r|\n|--%|\bout-file\b|\bset-content\b|\badd-content\b|\binvoke-|\biex\b|\bstart-|\bnew-|\bremove-|\bcopy-|\bmove-|\brename-|\bclear-|\bset-|\bpush-|\bexpand-|\bsave-|\binstall-|\bcurl\b|\bwget\b|\bsudo\b|\bsh\b|\bbash\b|\bpwsh\b|\bpowershell\b|\bcmd\b|-exec\b|-delete\b|-execdir\b/i;
/**
* The command with every quoted text replaced by a plain stand-in, or null when it cannot be read safely. What is inside quotes is data
* (a path like "C:\Users\x\Q&A", a pattern like 'a|b'), so an & ; | ( ) < > or { in it must not decide anything. Two things still can:
* inside double quotes a $ or a backtick expands (a variable, a sub-command), and an unterminated quote means the text was not understood.
* Either one gives null, and the command asks as before.
* @param {string} text
* @returns {string|null}
*/
export function maskQuotes(text) {
let out = '';
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c !== '"' && c !== "'") { out += c; continue; }
let end = text.indexOf(c, i + 1);
// PowerShell writes a quote inside a quoted text by doubling it ('it''s'): the text goes on past the pair.
while (end >= 0 && text[end + 1] === c) end = text.indexOf(c, end + 2);
if (end < 0) return null;
if (c === '"' && /[$`]/.test(text.slice(i + 1, end))) return null;
// A quoted text is a whole word. One glued to other characters (--out"put"=f, "a"b) or one that starts with a dash could hide an option from the
// word checks below (git diff "--output=f" writes a file), so those are not understood either.
const before = text[i - 1], after = text[end + 1];
if ((before !== undefined && !/[\s|]/.test(before)) || (after !== undefined && !/[\s|]/.test(after)) || text[i + 1] === '-') return null;
out += '"_"';
i = end;
}
return out;
}
/**
* Whether a shell command only reads. Conservative by design: one unrecognised word and the answer is no.
* Quoted text is data and is looked at after it has been set aside (maskQuotes): Get-ChildItem "C:\Users\x\Q&A" only lists.
* @param {string} command
* @returns {boolean}
*/
@@ -77,8 +106,9 @@ export function isReadOnlyShell(command) {
const text = String(command ?? '').trim();
if (!text || text.length > 600) return false;
if (SAFE_VERSION.test(text)) return true;
if (UNSAFE_SYNTAX.test(text)) return false;
const segments = text.split('|').map(s => s.trim());
const masked = maskQuotes(text);
if (masked === null || UNSAFE_SYNTAX.test(masked)) return false;
const segments = masked.split('|').map(s => s.trim());
return segments.every((segment) => {
if (!segment) return false;
const words = segment.split(/\s+/);
@@ -219,8 +249,13 @@ export function apply(ctx, config = {}) {
ctx.on('tools/pre-execute', async (exec, next) => {
const agent = exec.agent;
if (!agent || exec.name === 'run_code') return next();
// Fail closed (T4): a session whose preset cannot be read (a reset in progress, a helper whose chat is gone) is treated as Manual, so
// anything this file cannot prove is read-only still asks. It used to run unasked.
let preset;
try { preset = effectivePreset(agent.session); } catch { return next(); }
try { preset = effectivePreset(agent.session); } catch (e) {
preset = MANUAL_PRESET;
try { console.warn(`[strata-gate] could not read the permission mode for this call (${e?.message ?? e}); treating it as Manual`); } catch { /* logging must never decide a permission */ }
}
// A helper that was set to ask while the chat was in Manual must stop asking the moment the chat leaves Manual,
// so a mode change applies to the very next tool call of a running goal and its helpers.
if (preset !== MANUAL_PRESET && agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) === 'ask') {
+2 -2
View File
@@ -42,8 +42,8 @@ export const AGENT_PROMPT = [
'You are a helpful assistant that can also work on this computer through your tools. You are running as {{model}}.',
'Answer questions directly and briefly; use a tool only when the task needs one. The person reads your replies in a chat: use plain words, not tool names or technical jargon.',
'Look before you change: read a file before you edit or overwrite it, and prefer a small edit to a rewrite. Search with the glob and grep tools and read files with the read tool; use the shell for running programs.',
'When you start a program or open a folder or file for the person, the command returning without an error means it worked: say plainly that it is open. Do not say you could not confirm it. If a command fails, say what went wrong in one sentence and what you will try next.',
'To open a page or a file for the person (a browser, an app, a folder), start it so the command returns at once and never wait for the program to close: on Windows use Start-Process, on macOS open, on Linux xdg-open. Then say plainly that it is open.',
'A command that starts a program, folder or page only tells you that it started. Never say a window opened, a page rendered or that you checked how it looks unless a tool confirmed it. If a command fails, say what went wrong in one sentence and what you will try next.',
'To show the person an image, SVG, HTML page or PDF from their folders, use the present tool: the app shows it next to the chat. A browser cannot open file:// addresses. If you do start a program, start it so the command returns at once and never wait for it to close: Start-Process on Windows, open on macOS, xdg-open on Linux.',
'When the person asks you to ask a subagent, a helper or another model ("ask a subagent", "ask model X"), use the subagent tool and name the model in its model field. Never look for a model with shell commands, and never wait for a helper with sleep: end your turn; its answer arrives as a new message.',
'Tokens prefixed with @ are files or folders the person pointed at. A failed or refused action changed nothing; say so, and do not claim work you did not do.',
].join('\n\n');
+42 -6
View File
@@ -20,6 +20,7 @@
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { standardPreset } from './preset.mjs';
import { validMcpName } from '../mcp-names.mjs';
import { BUDGETS, isPrivateHost } from './llm-shape.mjs';
import { DDG_PROVIDER_ID } from '../web-search-ddg.mjs';
@@ -55,6 +56,8 @@ export const modeOf = value => (Object.hasOwn(MODE_PRESETS, value) ? value : DEF
/** The approval gate plugin (plain ESM, loaded by absolute path). */
export const GATE_PLUGIN = fileURLToPath(new URL('./plugins/strata-gate.mjs', import.meta.url));
/** The app's web search (DuckDuckGo, or the person's own Exa / Perplexity key), registered with the Harness's web seam. */
/** Compaction that measures the real prompt and summarises in pieces that fit the window (plugins/strata-context.mjs). */
export const CONTEXT_PLUGIN = fileURLToPath(new URL('./plugins/strata-context.mjs', import.meta.url));
export const MODELS_PLUGIN = fileURLToPath(new URL('./plugins/strata-models.mjs', import.meta.url));
export const SEARCH_PLUGIN = fileURLToPath(new URL('./plugins/strata-search.mjs', import.meta.url));
/** The app's own tool server for the tools a person writes (instructions, commands). */
@@ -71,11 +74,17 @@ const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'b
* `mcp__<server>__<tool>`. A command or URL goes into the patch as text; a secret (an env value or a header) never does: the row
* carries `!!js process.env.<NAME>` and the value travels in the agent process's environment (returned in `env`).
*/
export function mcpRows(servers, env) {
export function mcpRows(servers, env, skipped = []) {
const rows = [];
const ids = new Set();
let index = 0;
for (const [name, server] of Object.entries(servers ?? {})) {
if (!server || server.disabled === true || !/^[A-Za-z0-9_-]{1,32}$/.test(name)) continue;
if (!server || server.disabled === true) continue; // switched off on purpose: not an error
// A server that cannot be wired is REPORTED (T6): the model never sees its tools, and a silent drop looked like "the tool does not work".
if (!validMcpName(name)) { skipped.push({ name, why: 'name' }); continue; }
if (!server.command && !server.url) { skipped.push({ name, why: 'address' }); continue; }
if (ids.has(`mcp-${slug(name)}`)) { skipped.push({ name, why: 'duplicate' }); continue; }
ids.add(`mcp-${slug(name)}`);
const token = (kind, key, value) => {
const variable = `STRATA_MCP_${index}_${kind}_${slug(key).replace(/-/g, '_').toUpperCase()}`;
env[variable] = String(value);
@@ -91,6 +100,24 @@ export function mcpRows(servers, env) {
return rows;
}
/**
* What the chat says when a tool server was left out (one notice per reason). The sentences are interface copy: they are in every language's
* catalog (src/locales/tools-<lang>.json), and the {0} is the list of names exactly as the person wrote them.
*/
const SKIPPED_TEXT = {
name: 'These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.',
address: 'These tool servers were left out because they have no program or web address: {0}.',
duplicate: 'These tool servers were left out because another one has almost the same name: {0}.',
};
export function mcpSkippedNotices(skipped) {
const out = [];
for (const why of Object.keys(SKIPPED_TEXT)) {
const names = (skipped ?? []).filter(s => s.why === why).map(s => String(s.name).slice(0, 40));
if (names.length) out.push({ code: `mcp-skipped-${why}-${names.join(',')}`.slice(0, 160), message: SKIPPED_TEXT[why].replace('{0}', names.join(', ')) });
}
return out;
}
const slug = value => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'x';
/** Identity of one route's wire configuration, secret included by hash only. */
@@ -166,7 +193,7 @@ export function outputBudget(context, maxOutput = null) {
/** What a local model, or an OpenAI-style server on this network, takes for the thinking level (see the comment where a local route is built). */
const THINKING_COMPAT = { supportsStore: false, thinkingFormat: 'chat-template', chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' }, reasoning_effort: { $var: 'thinking.effort', omitWhenOff: true } } };
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null }) {
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null, defaultLocalId = null }) {
const providers = {};
const env = {};
const routes = [];
@@ -202,6 +229,9 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
api: 'openai-completions',
baseURL: inst.baseUrl,
apiKeyEnv: keyEnv,
// A long prompt read sends only SSE comments, which pi-ai's OpenAI client drops, so its 300 s stream-idle timer would cut the
// request five minutes in (#3623). 60 minutes is a backstop: serve ends a hung engine and the bridge's watchdog ends a lost turn.
streamIdleTimeoutMs: 60 * 60_000,
// The Strata server reads the thinking level from `chat_template_kwargs` (enable_thinking, reasoning_effort) or from a top-level
// reasoning_effort; a top-level `enable_thinking` (pi-ai's `qwen` format) is ignored, so the model went on thinking with the menu on Off.
compat: THINKING_COMPAT,
@@ -257,9 +287,13 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
for (const m of conn.models) routes.push({ route, fp, kind: 'api', connectionId: conn.id, connectionName: conn.name, modelId: m.id, name: m.name || m.id, contextWindow: m.contextMax ?? ASSUMED_API_CONTEXT, ...(shaped ? { shaped } : {}) });
});
const defaultRoute = routes.find(r => r.kind === 'local') ?? null;
// The model the chat really runs on is the default instance, not whichever one was loaded first (T5: with two models loaded, the tool servers of the
// first one were wired to a chat on the second).
const defaultInstance = local.find(i => i.id === defaultLocalId) ?? local[0] ?? null;
const defaultRoute = routes.find(r => r.kind === 'local' && r.instanceId === defaultInstance?.id) ?? routes.find(r => r.kind === 'local') ?? null;
// One Harness, one set of tool servers: the default local model's.
const mcp = mcpRows({ ...(local[0]?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv);
const mcpSkipped = [];
const mcp = mcpRows({ ...(defaultInstance?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv, mcpSkipped);
Object.assign(env, mcpEnv);
// The person's own instruction and command tools come from the app's tool server; a command tool asks in Manual like any connected tool.
const own = (kind, on) => on && tools.toolFile ? [{ id: `mcp-strata-${kind}`, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: `strata-${kind}`, transport: 'stdio', command: process.execPath, args: [CUSTOM_TOOLS_SCRIPT, kind, tools.toolFile] } }] : [];
@@ -286,6 +320,8 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
// Manual asks before a change runs, so the person sees one Approve/Deny card and nothing has happened yet.
// Its web question names the search service the person chose (a keyed one only when its key is saved), and the person's switch skips the question.
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN, config: { web: { provider: SEARCH_NAMES[keyed ?? 'builtin'], skipAsk: tools.webWithoutAsking === true } } }] }] : []),
// Compaction decides from what the model server measured, and its summary requests always fit the window (plugins/strata-context.mjs).
{ insert: [{ id: 'strata-context', name: CONTEXT_PLUGIN, config: {} }] },
// Helper agents on a model the person names: every model the app can reach is allowed, and the plugin makes the choice forgiving (plugins/strata-models.mjs).
...(subagentRoutes.length ? [{ id: 'subagent-model-selection-settings', config: { enabled: true, allowedModels: subagentRoutes.map(r => ({ provider: r.provider, model: r.model })) } }] : []),
...(subagentRoutes.length ? [{ insert: [{ id: 'strata-models', name: MODELS_PLUGIN, config: { routes: subagentRoutes } }] }] : []),
@@ -309,7 +345,7 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
// What the agent is offered (tool groups, web search, tool servers, the person's own tools) decides whether a running agent must be
// restarted for the next message; the routes are compared separately (a changed context size alone does not restart it).
const toolsSignature = createHash('sha256').update(JSON.stringify([mcp, ownRows, webRows, entries.find(e => e.id === 'preset-standard'), env.STRATA_SEARCH_KEY ?? '', mcpEnv])).digest('hex').slice(0, 16);
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets };
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets, mcpSkipped };
}
/** Find the route for a chat's model choice. */
+14 -2
View File
@@ -13,6 +13,7 @@
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { GROUP_IDS, TOOL_GROUPS } from './tools-catalog.mjs';
import { SHELLS, clampTimeoutSec } from './custom-command-run.mjs';
export const SEARCH_PROVIDERS = ['builtin', 'exa', 'perplexity', 'deepseek'];
const KINDS = ['instructions', 'command', 'server'];
@@ -38,6 +39,8 @@ function normalize(raw) {
id: String(t.id), name: String(t.name), about: clean(t.about, 300), kind: t.kind, enabled: t.enabled !== false,
instructions: clean(t.instructions, 20000), command: clean(t.command, 2000), address: clean(t.address, 2000),
args: (Array.isArray(t.args) ? t.args : []).slice(0, 50).map(a => clean(a, 1000)),
// a command tool's folder, shell and time allowed (custom-command-run.mjs); '' / 'auto' / 120 when not set
cwd: clean(t.cwd, 1000).trim(), shell: SHELLS.includes(t.shell) ? t.shell : 'auto', timeoutSec: clampTimeoutSec(t.timeoutSec),
})),
};
}
@@ -49,7 +52,7 @@ function validateCustom(body, existing) {
if (!about) throw new Error('Say what the tool does, in a sentence. The assistant reads this to decide when to use it.');
const kind = body?.kind;
if (!KINDS.includes(kind)) throw new Error('Choose how the tool works.');
const out = { name, about, kind, instructions: '', command: '', address: '', args: [] };
const out = { name, about, kind, instructions: '', command: '', address: '', args: [], cwd: '', shell: 'auto', timeoutSec: clampTimeoutSec(undefined) };
if (kind === 'instructions') {
out.instructions = clean(body.instructions, 20000).trim();
if (!out.instructions) throw new Error('Write the instructions the assistant should follow when it uses this tool.');
@@ -57,6 +60,14 @@ function validateCustom(body, existing) {
out.command = clean(body.command, 2000).trim();
if (!out.command) throw new Error('Enter the command to run.');
if (/[\r\n]/.test(out.command)) throw new Error('Put the command on one line.');
// The edit form of Settings does not send these three: an edit keeps what the tool had.
const before = existing.find(t => t.id === body.id && t.kind === 'command');
out.cwd = body.cwd !== undefined ? clean(body.cwd, 1000).trim() : before?.cwd ?? '';
if (/[\r\n]/.test(out.cwd)) throw new Error('Put the folder on one line.');
if (body.shell !== undefined && !SHELLS.includes(body.shell)) throw new Error('Choose how the command runs: automatic, cmd, PowerShell, bash or sh.');
out.shell = body.shell !== undefined ? body.shell : before?.shell ?? 'auto';
if (body.timeoutSec !== undefined && !(Number(body.timeoutSec) >= 1 && Number(body.timeoutSec) <= 3600)) throw new Error('Give the time allowed in seconds, from 1 to 3600.');
out.timeoutSec = body.timeoutSec !== undefined ? clampTimeoutSec(body.timeoutSec) : before?.timeoutSec ?? clampTimeoutSec(undefined);
} else {
out.address = clean(body.address, 2000).trim();
if (!out.address) throw new Error('Enter the tool server’s web address, or the program that starts it.');
@@ -91,7 +102,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
for (const t of s.custom.filter(t => t.enabled)) {
const key = t.id;
if (t.kind === 'instructions') instructions[key] = { description: t.about, text: t.instructions };
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command };
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec };
}
mkdirSync(join(dataDir, 'agent'), { recursive: true });
const tmp = `${filePath}.tmp`;
@@ -115,6 +126,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
id: t.id, name: t.name, about: t.about, kind: t.kind, enabled: t.enabled,
summary: t.kind === 'instructions' ? t.instructions.slice(0, 80) : t.kind === 'command' ? t.command : t.address,
instructions: t.instructions, command: t.command, address: t.address, args: t.args,
...(t.kind === 'command' ? { cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec } : {}),
})),
};
}
+17 -1
View File
@@ -67,13 +67,29 @@ export function nvidiaDriverVersion() {
/** Where the builds are published; the wrong-make sentence points here. */
export const RELEASES_URL = 'https://github.com/Niko1221/Strata/releases';
/** The PCI vendor ids (`10de`, `1002`, `8086`) of the Windows display adapters; empty off Windows or when the registry cannot be read. */
export function windowsDisplayVendors({ platform = process.platform, run = execFileSync } = {}) {
const found = new Set();
if (platform !== 'win32') return found;
try {
const out = run('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}', '/s', '/v', 'MatchingDeviceId'], { encoding: 'utf8', timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
for (const match of String(out).matchAll(/VEN_([0-9A-F]{4})/gi)) found.add(match[1].toLowerCase());
} catch { /* no registry access: the tools decide */ }
return found;
}
/**
* The engine family this PC's graphics card needs ('cuda', 'hip', 'sycl'), or null when it cannot be told. The same order as Control
* (NVIDIA first, then AMD, then Intel); STRATA_ENGINE_BACKEND wins. Reads only the driver and (Linux) the PCI vendor of the display adapters.
*/
export function gpuBackendHere(env = process.env) {
export function gpuBackendHere(env = process.env, { vendors = windowsDisplayVendors() } = {}) {
const forced = String(env.STRATA_ENGINE_BACKEND ?? '').trim().toLowerCase();
if (forced) return ['cuda', 'hip', 'sycl'].includes(forced) ? forced : null;
// Windows: the display adapters are the machine's own statement of which cards it has; a leftover nvidia-smi from a card that was
// taken out must not outvote them (#3688). Same order as Control: NVIDIA, then AMD, then Intel.
if (vendors.size) {
for (const [vendor, backend] of [['10de', 'cuda'], ['1002', 'hip'], ['8086', 'sycl']]) if (vendors.has(vendor)) return backend;
}
if (nvidiaDriverVersion()) return 'cuda';
try {
const base = '/sys/class/drm';
+16
View File
@@ -244,6 +244,22 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.ok) return result.json;
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
},
/**
* The DFlash2 drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter }. Control decides
* (the table of strata_control/drafting.py); the app never reads it. Nothing is downloaded.
*/
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The drafter could not be checked.');
},
/** Fetch and pack the drafter when the choice would use it and it is not here (`strata drafting fetch --if-needed`); `onEvent` gets {step, done, total}. */
async draftingFetch({ configPath, mode = null, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--if-needed', '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
if (result.json?.ok) return result.json;
throw Object.assign(new ControlError(result.json?.error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The DFlash2 drafter could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: 'drafting_failed' });
},
async saveProfile(name, configPath) {
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
+22
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node
import { engineProgressMark } from './agent/engine-progress.mjs';
import './system-ca.mjs'; // #3722: the operating system's certificates, before any HTTPS request
import { createCloudflare } from './cloudflare.mjs';
import { createConnectorInstaller } from './cloudflared-install.mjs';
import { findRunningConnector } from './cloudflared-find.mjs';
@@ -15,6 +17,7 @@ import { materializeSkills, parseSkillFile, skillSlug, writeCreatedSkill } from
import { systemFonts } from './fonts.mjs';
import { tuningDir } from './platform-paths.mjs';
import { createAgentBridge, plainTurnFailure } from './agent/agent-bridge.mjs';
import { mcpSkippedNotices } from './agent/profile.mjs';
import { createAgentTools } from './agent/tools-settings.mjs';
import { withPlainStatus } from './agent/status.mjs';
import { findBrowser, findPlaywrightMcp } from './agent/browser-probe.mjs';
@@ -55,6 +58,7 @@ import { RequestQueue } from './request-queue.mjs';
import { selectSkills, skillInstructions } from './skill-selection.mjs';
import { createModelProviders } from './model-providers.mjs';
import { createForkTimeskip } from './timeskip-fork.mjs';
import { prepareOversized } from './oversize.mjs';
import { createChatFolders, chatRootOf, openFile, resolveChatFile, previewFile, rawFile, createAttachments, withAttachments } from './chat-files.mjs';
import { createLlmProxy, samplingFromExpert } from './agent/llm-shape.mjs';
import { checkUpdates, checkComponentUpdates } from './updates.mjs';
@@ -174,12 +178,15 @@ const agent = createAgentBridge({
// The person's Response settings (thinking budget, Expert sampling) reach the agent's model through this host's own forwarding endpoint.
proxyBase: `http://127.0.0.1:${PORT}/api/internal/agent-llm`,
expertSampling: () => samplingFromExpert(store.get().expert),
// How far a local engine has got (the 2 s /metrics poll), for the agent's stall watchdog: it moves while a long prompt is read.
engineProgress: instanceId => engineProgressMark(native.readyInstances().find(i => i.id === instanceId)),
onPlan: (chatId, event, plan) => hub.send('plan', { chatId, event, plan }),
onQueue: (chatId, queue) => hub.send('queue', { chatId, queue }),
dataDir: store.DATA_DIR,
componentsDir: componentsDirectory(),
// The installer ships the Harness next to the host (<install>/host/server -> <install>/harness).
bundledDir: resolve(HERE, '..', '..', 'harness'),
defaultLocalId: () => native.defaultInstanceId(),
localInstances: () => native.readyInstances().map(i => ({
id: i.id, label: i.label, baseUrl: `http://127.0.0.1:${i.port}/v1`, modelId: i.apiModelId,
context: i.metrics?.engine?.max_context ?? i.context ?? undefined, key: i.key, vision: i.vision,
@@ -601,8 +608,11 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
const goalLine = /^\/goal(?:\s+([\s\S]*))?$/i.exec(message.trim());
const planLine = /^\/plan(?:\s+([\s\S]*))?$/i.exec(message.trim());
const researchLine = /^\/research(?:\s+([\s\S]*))?$/i.exec(message.trim());
// "/compact" summarises the older messages of this chat now (it used to go to the model as text, which answered it as a question).
const compactLine = /^\/compact\s*$/i.test(message.trim());
const turnOptions = { chatId, message, workspacePath: cwd, choice, images, mode: typeof body.mode === 'string' ? body.mode : null, thinking: typeof body.thinking === 'string' ? body.thinking : null, thinkingBudget: Number.isFinite(body.thinkingBudget) ? body.thinkingBudget : 0, history: Array.isArray(body.history) ? body.history : [], onFrame: write, signal: controller.signal };
const result = typeof body.planId === 'string' && body.message === '/plan start' ? await agent.planGoal({ ...turnOptions, planId: body.planId })
: compactLine ? await agent.compactCommand(turnOptions)
: researchLine ? await agent.researchCommand({ ...turnOptions, line: researchLine[1] ?? '' })
: planLine ? await agent.planCommand({ ...turnOptions, line: planLine[1] ?? '' })
: goalLine ? await agent.goalCommand({ ...turnOptions, line: goalLine[1] ?? '' }) : await agent.runTurn(turnOptions);
@@ -1717,6 +1727,16 @@ async function api(req, res, path, method, url) {
try { const f = rawFile(chatRoot(url.searchParams.get('sessionId') ?? ''), url.searchParams.get('path')); res.writeHead(200, { 'content-type': f.mime, 'content-length': f.bytes.length, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(f.bytes); return undefined; }
catch (e) { return error(res, e.code === 'outside' ? 403 : 400, e.message); }
}
/* A message too large for the model's window, made small enough to send: its text as a file in the chat's folder, or a shortened preview (oversize.mjs). */
if (path === '/api/chat/oversize' && method === 'POST') {
const body = await readBody(req, 12 * 1024 * 1024);
const text = String(body.text ?? '');
if (!text.trim()) return error(res, 400, 'text is required');
let instance = null;
try { instance = native.resolveInstance(body.nativeModelId ?? native.state().defaultId); } catch { /* no local model: the length alone decides */ }
try { return json(res, 200, await prepareOversized({ mode: body.mode === 'file' ? 'file' : 'preview', text, root: chatRoot(String(body.sessionId ?? '')), instance })); }
catch (e) { return error(res, 400, 'The message could not be prepared. Try a shorter message.'); }
}
/* "Attach as context": the file is named in the next message of the chat, so the assistant reads it again. */
if (path === '/api/chat/attachments' && method === 'GET') return json(res, 200, { files: chatAttachments.list(url.searchParams.get('sessionId') ?? '') });
if (path === '/api/chat/attachments' && method === 'POST') {
@@ -1934,6 +1954,8 @@ async function api(req, res, path, method, url) {
if (chatId && !agentSkip) {
const capability = agent.status();
if (capability.state !== 'unavailable') {
const skipped = mcpSkippedNotices(capability.skippedServers);
for (const notice of skipped) res.write(`${JSON.stringify({ type: 'notice', ...notice })}\n`);
await runAgentTurn({ res, controller, chatId, body, message, workspaceId, images });
res.end();
return undefined;
+10
View File
@@ -0,0 +1,10 @@
// The one rule for the name of a tool server (MCP), shared by the Settings editor (src/lib/mcp-names.ts keeps a copy; tests/mcp-rows.test.mjs
// checks they agree), the save check (native-settings.mjs) and the agent profile (agent/profile.mjs).
//
// The model sees every tool as `mcp__<server>__<tool>`, and an OpenAI-style API takes at most 64 characters for that name, so the server's
// part is limited to 32 (T6, tools and images review 2026-10-22). Before, the editor and the save check accepted 64 and the profile quietly
// dropped everything over 32 (or with a dot): the model never saw the tool and nothing said why.
export const MCP_NAME_MAX = 32;
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
export const validMcpName = name => typeof name === 'string' && MCP_NAME_RE.test(name);
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
+30
View File
@@ -0,0 +1,30 @@
/**
* Which files of a model are missing, in words a person can act on (#3478). Control's model list says which required parts of the run
* config are absent (`missing`: part + file name), and the installer's status says which GGUF files and shards it could not find
* (`missing_files`, full paths). Together they give the names; the app shows them instead of only "some files are missing".
*/
import { basename } from 'node:path';
const PART_WORDS = { engine: 'the engine program', 'tokenizer/vocab.json': 'the tokenizer', 'tokenizer/merges.txt': 'the tokenizer', 'tokenizer/token_type.json': 'the tokenizer' };
/** Short readable names, no duplicates, most useful first. */
export function missingNames(controlParts, installerFiles) {
const names = [];
const add = text => { const t = String(text ?? '').trim(); if (t && !names.includes(t)) names.push(t); };
for (const file of Array.isArray(installerFiles) ? installerFiles : []) add(basename(String(file).split(String.fromCharCode(92)).join('/')));
for (const item of Array.isArray(controlParts) ? controlParts : []) {
add(PART_WORDS[item?.part] ?? item?.file ?? item?.part);
}
return names;
}
/** The error a load or a tune raises for a model that is not whole. Names are in the sentence (at most four) and all of them in `details`. */
export function missingFilesError(model, names) {
const list = names ?? [];
const shown = list.slice(0, 4).join(', ') + (list.length > 4 ? ` and ${list.length - 4} more` : '');
const error = new Error(list.length
? `Some model files are missing: ${shown}. Use Download / repair to restore them.`
: 'Some model files are missing. Use Download / repair to restore them.');
error.details = [list.length ? `Missing for ${model}: ${list.join(', ')}` : `Missing for ${model}: Strata could not tell which files`];
return error;
}
+38 -10
View File
@@ -1,4 +1,5 @@
/** Native Strata control plane. All user actions are explicit; opening the UI only reads state. */
import {missingFilesError,missingNames} from './missing-files.mjs';
import { spawn } from 'node:child_process';
import {chooseParallel,applyParallel,withoutParallel,FALLBACK_NOTE} from './native-parallel.mjs';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
@@ -54,7 +55,10 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const emit=()=>{const value=state();writeFileSync(join(directory,'native-jobs.json'),JSON.stringify(value.jobs),{mode:0o600});changed(value);};
const append=(instanceId,line)=>{logs.push({at:new Date().toISOString(),instanceId,line:String(line).slice(0,2000)});if(logs.length>400)logs.shift();};
function diskSpace(path){try{const s=statfsSync(path);return {free:Number(s.bavail)*Number(s.bsize),total:Number(s.blocks)*Number(s.bsize)};}catch{return null;}}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false}};}
/* Does the engine in this release serve DFlash2? Control knows (strata_control/drafting.py); asked once, false until it answers. The Models screen offers the choice only when true. */
let dflash2Served=null;
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;emit();}).catch(()=>{});}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true}};}
let sourceCache={path:'',time:0,text:''};
function sourceText(){try{const path=join(packaged?control.control.dir:preferences.repo,'serve/server.py'),time=statSync(path).mtimeMs;if(sourceCache.path===path&&sourceCache.time===time)return sourceCache.text;sourceCache={path,time,text:readFileSync(path,'utf8')};return sourceCache.text;}catch{return '';}}
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
@@ -73,7 +77,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,drafting:cfg.drafting??'auto',/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
/* The settings file Strata Control and the installer read (control-runtime.mjs sets STRATA_SETTINGS_FILE to it): the app's data folder is written there. */
function controlSettingsFile(){return process.env.STRATA_SETTINGS_FILE||join(dirname(componentsDirectory()),'app-settings.json');}
@@ -106,8 +110,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
packagedInstall=list.install_dir;
const engine=await control.engine();
const installer=await mcpCall('strata_status',{hardware:true}).catch(error=>({error:error.message}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),images:'off',port:undefined,last_used:null}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0,missingFiles:m.missing_files}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),missing:missingNames(m.missing,disk[m.id]?.missingFiles),images:'off',port:undefined,last_used:null}));
const cores=os.cpus().length;
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
@@ -125,6 +129,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
/* A read that is not forced never makes the window wait for the (slow) status of Control: it gets the last state at once, and a stale one
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
if(refreshing)return !force&&snapshot.available?state():refreshing;if(!force&&Date.now()-lastRead<45000)return state();
probeDrafting();
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=false;}}const markImages=c=>c&&Array.isArray(c.families)?{...c,families:c.families.map(f=>({...f,imagesCapable:!!f.images,images:!!f.images&&visionReader,sizes:(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:!!s.images&&visionReader}))}))}:c;snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
}
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
@@ -139,7 +144,10 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
function settings(model){const {config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true};}
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);return tool('strata_install',installArgs(args,false));}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);const result=await tool('strata_install',installArgs(args,false));
/* A chosen drafter that is not on this computer yet is part of the download: its size is listed beside the model's (Control decides; nothing is fetched here). */
const choice=args.drafting;if(packaged&&typeof choice==='string'&&choice!=='auto'&&typeof control.draftingPlan==='function'){try{const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:snapshot.installed?.data_folder??null});if(d.needed)return {...result,drafting:{needed:true,gb:d.gb,line:d.line}};}catch{/* the model plan still stands */}}
return result;}
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
@@ -176,7 +184,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const note=engineStep?.summaryNote();
const finalResult=engineStep?job.result:result;
if(note)return {...finalResult,summary:note};
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision});}
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision,...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
return finalResult;
}
}
@@ -218,7 +226,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(mine.length){cfg.args=mergeUserArgs(cfg.args??[],mine);cfg[USER_KEY]=mine;}else delete cfg[USER_KEY];
if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
/* The model's own choices win over what setup wrote into the arguments: "Draft layer (MTP)" off leaves --mtp out of every start, and a picture reading that is off leaves --vision out (setup writes both again whenever it runs; the saved settings are applied here, on every load). */
if(cfg.mtp===false&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if(!cfg.vision&&Array.isArray(cfg.args))cfg.args=cfg.args.filter(a=>a!=='--vision');
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
@@ -229,12 +237,32 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(parallelChoice){const applied=applyParallel(cfg,parallelChoice);for(const k of Object.keys(cfg))if(!(k in applied.cfg))delete cfg[k];Object.assign(cfg,applied.cfg);parallelNote=applied.note;}
return {cfg,resources,visionPreferences,parallelChoice,parallelNote};
}
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
Never stops the load: a drafter that cannot be had leaves the MTP running, and the job says so. The choice itself is remembered in the model's settings file. */
async function prepareDrafter(job,args,cfg,configFile){
const asked=args.values?.drafting;
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
if(!packaged||typeof control.draftingFetch!=='function'||!cfg.drafting||cfg.drafting==='auto'||cfg.drafting==='mtp'||cfg.drafting==='off')return;
const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});const probe=join(work,'drafting-'+randomUUID()+'.json');
try{
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
job.engineAbort??=new AbortController();
await control.draftingFetch({configPath:probe,mode:cfg.drafting,signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
if(job.state==='running'){job.summary='Loading the model…';job.details=undefined;emit();}
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary='The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
finally{try{rmSync(probe,{force:true});}catch{/* best effort */}}
}
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
async function loadEngine(job,args){
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready)throw new Error('Some model files are missing. Use Download / repair to restore them.');
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
if(externalRunning().length)throw new Error('A Strata server is already running outside this UI. Stop it before loading here, or connect its API in Settings → Models.');
if(args.multiple&&instances.size>=8)throw new Error('Unload an instance before adding another. Up to eight native instances can run here.');
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote}=buildConfig(args,config);
let parallelNote=chosenNote;
await prepareDrafter(job,args,cfg,configFile);if(job.state!=='running')return;
const fingerprint=createHash('sha256').update(JSON.stringify({...cfg,api_key:undefined,port:undefined})).digest('hex');
const placement=Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu];const matching=[...instances.values()].find(i=>i.model===args.model&&JSON.stringify(i.gpus)===JSON.stringify(placement));
if(matching?.status==='ready'&&matching.fingerprint===fingerprint&&(args.multiple||instances.size===1))return {summary:`${matching.label} is already ready.`,instanceId:matching.id};
@@ -289,7 +317,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
if(instance.status==='error')throw new Error(instance.error);
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
await new Promise(resolve=>setTimeout(resolve,1200));
}
if(job.state!=='running'){await unload(id);return;}
@@ -482,5 +510,5 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
// Remove nonserializable process references from public jobs.
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),defaultInstanceId:()=>defaultId,close};
}
+9 -4
View File
@@ -28,12 +28,16 @@ export function defaultReserves(config,hardware,baselineReserve=0){
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
const placed=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)),gpuMemory=placed.map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
const headless=placed.length>0&&placed.every(computeOnly); // no display on the card (the desktop is on an integrated GPU): no desktop to keep room for
const floor=headless?(config.vision?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=headless?floor:Math.max(config.vision?2048:floor,baselineReserve);
/* Only a picture reader that runs on the GPU needs VRAM of its own: the CPU reader (vision.gpu false) took the same 2048 floor and cost a 12 GB card a quarter of its expert cache (0.2.3 tuning, notes-023-tune.md). */
const pictures=config.vision?(typeof config.vision==='object'?Boolean(config.vision.gpu):true):false;
const floor=headless?(pictures?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=headless?floor:Math.max(pictures?2048:floor,baselineReserve);
/* A card that shares the PC's memory (Strix Halo, other APUs) has no memory of its own to keep a fifth of: that fifth is RAM, and "Leave 20% for my work" already keeps it
* through the RAM budget. A fifth of the shared pool here held back 16 GB of expert cache for nothing (#3510); the display floor stays. */
const shared=placed.length>0&&placed.some(g=>g.uma===true||g.unified===true);
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety,performance:safety};
/* "work" keeps a fifth of the card, but on a card of 16 GB or less no more than 1536 MiB: a fifth of 12 GB (2457) cost 20% of the speed against 1536, which already leaves 1.1 GiB of VRAM free after the load (RTX 5070, Gyro-S 64K, 5 pairs; notes-023-tune.md). */
const fifth=gpuMemory.length?Math.floor(Math.min(...gpuMemory)*1024*.2):0,cap=gpuMemory.length&&Math.min(...gpuMemory)<=16?1536:Infinity;
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.min(fifth,cap)):safety,performance:safety};
}
/** The reserve the person asked for (null: nothing) and the default for each profile. A 0.2.0-beta file has the key and the same number in "args" with no note that the
@@ -63,7 +67,8 @@ export function resourceSettings(config,hardware,profile,model={},values={}){
const cores=Number(hardware?.cpu?.cores)||0;
if(profile==='work'&&cores>1){
const at=args.indexOf('--pool-workers'),previous=at>=0?Number(args[at+1]):0;
const workers=Math.max(1,Math.floor(cores*.8));remove('--pool-workers');
/* 80% of the physical cores rounded UP, never more than the cores less the host thread (the engine's own count): floor(0.8 x 6) = 4 ran 8-10% slower than the engine's 5 on a 6-core Ryzen (notes-023-tune.md); 12 logical threads gave 9 in 0.2.1. */
const workers=Math.max(1,Math.min(cores-1,Math.ceil(cores*.8)));remove('--pool-workers');
args.push('--pool-workers',String(previous>0?Math.min(previous,workers):workers));
}
// Preserve calibrated worker counts and the installation's safety/vision reserve.
+6 -2
View File
@@ -1,3 +1,4 @@
import { validMcpName, MCP_NAME_HELP } from './mcp-names.mjs';
/** Editable native keys. Unknown config keys and program paths stay intact. */
export const NATIVE_FIELDS=[
{id:'reasoning_budget_tokens',label:'Thinking token budget',group:'Responses',type:'number',min:0,max:1048576,hint:'Leave blank for the model default.'},
@@ -8,6 +9,7 @@ export const NATIVE_FIELDS=[
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
{id:'mtp',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
{id:'drafting',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster. Auto (recommended) lets Strata pick from what it measured on cards like yours; today that is the MTP. DFlash2 is for Qwen3.8-Flash-Next only and downloads a 1.2 GB drafter once. If DFlash2 cannot run, the MTP is used and Details says why. Off turns the draft layer off.'},
{id:'batchMtp',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
{id:'engine_silence_s',label:'Engine stall timeout (seconds)',group:'Recovery',type:'number',min:10,max:3600},
@@ -24,7 +26,7 @@ export const NATIVE_FIELDS=[
{id:'mcp_max_result_chars',label:'Maximum tool result characters',group:'Tools',type:'number',min:100,max:1000000},
];
/** Settings the app itself applies when it starts a model (they are not read from the engine's source, so the engine's text never says it supports them). */
export const APP_OWNED_FIELDS=['mtp','batchMtp'];
export const APP_OWNED_FIELDS=['mtp','batchMtp','drafting'];
/** The engine arguments without the draft layer: --mtp <folder or count>, --spec-min-p <p> (its confidence threshold) and --batch-mtp. --spec stays: prompt lookup still uses its windows. Same rule as strata_control/draft_layer.py. */
export function withoutDraftLayer(args){
const out=[];const list=(args??[]).map(String);
@@ -56,7 +58,9 @@ export function publicMcpServers(servers){return Object.fromEntries(Object.entri
export function validateMcpServers(servers,previous={}){
if(!servers||typeof servers!=='object'||Array.isArray(servers)||Object.keys(servers).length>30)throw new Error('MCP servers must be an object with at most 30 entries.');
const out={};for(const [name,s] of Object.entries(servers)){
if(!/^[a-z0-9_-]{1,64}$/i.test(name)||!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
if(!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
// a name that was saved before the 32-character rule stays saveable (unrelated settings must not be blocked); the agent reports it as left out
if(!validMcpName(name)&&!Object.hasOwn(previous??{},name))throw new Error(MCP_NAME_HELP);
if(!!s.command===!!s.url)throw new Error(`${name}: choose a command or HTTP URL.`);
for(const key of Object.keys(s))if(!['command','args','env','cwd','url','headers','disabled'].includes(key))throw new Error(`${name}: unsupported property ${key}.`);
if(s.command&&(typeof s.command!=='string'||s.command.length>1000||/[\n\r\0]/.test(s.command)))throw new Error(`${name}: choose a valid program.`);
+30
View File
@@ -0,0 +1,30 @@
/**
* A message too large for the model's window, made small enough to send (agent/context-fit.mjs decides that it is too large).
* The person picks how, on the error card:
* file the whole text is saved in the chat's folder and the message names the file; the assistant reads it in parts
* preview the start and the end of the text, cut to what the window can hold
* Nothing is sent from here: the page sends the returned message as the person's next message.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { countTokens, oversizedReplacement } from './agent/context-fit.mjs';
const stamp = d => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
/**
* @param {{mode:'file'|'preview', text:string, root:string|null, instance?:{port:number, key?:string, apiModelId?:string, context?:number, metrics?:any}|null, now?:Date, fetchImpl?:typeof fetch}} input
* @returns {Promise<{message:string, mode:string, tokens:number|null, kept:number, path?:string}>}
*/
export async function prepareOversized({ mode, text, root, instance = null, now = new Date(), fetchImpl = fetch }) {
const window = instance?.metrics?.engine?.max_context ?? instance?.context ?? 32768;
const tokens = instance ? await countTokens({ baseUrl: `http://127.0.0.1:${instance.port}/v1`, key: instance.key ?? '', model: instance.apiModelId ?? '', text, fetchImpl }) : null;
const counted = tokens ?? Math.ceil(text.length / 3); // not countable: three characters a token is on the safe side for code and logs
if (mode === 'file') {
if (!root) throw new Error('This chat has no folder yet. Choose one first.');
mkdirSync(root, { recursive: true });
const path = join(root, `pasted-message-${stamp(now)}.txt`);
writeFileSync(path, text, { encoding: 'utf8', flag: 'wx' });
return { mode, tokens, path, ...oversizedReplacement({ text, mode, tokens: counted, window, path }) };
}
return { mode: 'preview', tokens, ...oversizedReplacement({ text, mode: 'preview', tokens: counted, window }) };
}
+14 -3
View File
@@ -88,10 +88,21 @@ export let MODELS_DIR = MODELS_PICK.directory;
/** Native setup writes weights to data_dir/models. Keep its data root one level up. */
export function nativeDataDirectory(input) {
if(!input||process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY))return input;
if(!input||!existsSync(input))return input;
if(MODELS_DIR_NOTICE)return input;
const root=realpathSync(DEFAULT_MODELS_DIRECTORY);
if(resolve(input)!==resolve(DEFAULT_MODELS_DIRECTORY)&&realpathSync(input)!==root)return input;
const root=realpathSync(input),legacy=realpathSync(join(HOME,'models'));
// Retained legacy libraries are model folders too, not native data roots.
// A named data root satisfies the installer's guard; its alias leaves weights in place.
if(root===legacy){
const data=join(HOME,'Strata-data'),nativeModels=join(data,'models');
mkdirSync(data,{recursive:true});
// Unlike a fresh default, a retained library must not fall back to an empty folder if linking fails.
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:relative(data,root),nativeModels,process.platform==='win32'?'junction':'dir');
const a=statSync(nativeModels),b=statSync(root);
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The model folder conflicts with another library. Choose a different folder.');
return data;
}
if(process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY)||root!==realpathSync(DEFAULT_MODELS_DIRECTORY))return input;
const data=dirname(root);
// `models` and `Models` are one folder on Windows and macOS; where they are not (Linux) a link makes them one, and where no link can be made (exFAT, a share) models go in `models`.
alignModels(data,root);
+24
View File
@@ -0,0 +1,24 @@
// The host's own HTTPS downloads (engine releases from GitHub, web search, a cloud model's API) check the server against the operating
// system's certificates as well as Node's bundled list (#3722). Node carries its own list of roots; Chrome and curl.exe use the Windows
// store, which also holds the roots an antivirus's HTTPS scanning, a company proxy or an OS preview build adds. Nothing is turned off:
// the system roots are ADDED to the bundled ones, and a connection that still fails keeps failing.
//
// Node 24.5 and newer: tls.getCACertificates('system') and tls.setDefaultCACertificates. An older Node (the app bundles 24.x) does nothing here;
// the agent process gets NODE_USE_SYSTEM_CA=1 (harness-runtime.mjs) for the same reason.
import tlsModule from 'node:tls';
/** @returns {'system'|'bundled'} what the process trusts after the call */
export function useSystemCertificates(tls = tlsModule, env = process.env) {
if (env.STRATA_NO_SYSTEM_CA === '1') return 'bundled'; // a way back for a PC where reading the store misbehaves; not a way to skip checks
try {
if (typeof tls.getCACertificates !== 'function' || typeof tls.setDefaultCACertificates !== 'function') return 'bundled';
const system = tls.getCACertificates('system');
if (!Array.isArray(system) || !system.length) return 'bundled';
tls.setDefaultCACertificates([...new Set([...tls.getCACertificates('bundled'), ...system])]);
return 'system';
} catch {
return 'bundled';
}
}
export const trust = useSystemCertificates();
+1 -1
View File
@@ -35,7 +35,7 @@ export function plainUpdateError(error, subject = 'engine') {
} else if (/another update is already running/i.test(raw)) {
message = 'An update is already running.';
next = `Wait a moment, then press ${button} in Settings.`;
} else if (/test start|did not answer a one-token|did not become ready/i.test(raw)) {
} else if (/test start|did not start correctly|did not answer a one-token|did not become ready/i.test(raw)) {
message = `The new ${subject} didn't start correctly, so nothing was changed. ${keep}`;
} else if (looksOffline(error) || looksOffline(raw)) {
message = 'No internet connection.';
+1
View File
@@ -67,6 +67,7 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
else if (engine.status === 'install') Object.assign(engineView, { status: 'available', message: `This PC needs an engine to run models. Press Update engine to download it (${mib(engine.download_bytes)}).` });
else if (engine.status === 'current' || engine.status === 'failed-before' || engine.status === 'skipped') Object.assign(engineView, { status: 'current', message: 'The engine is up to date.', details: engine.note });
else if (engine.status === 'wrong-make') Object.assign(engineView, { status: 'unavailable', message: engine.note ?? 'Nothing was changed: the update was for another make of graphics card.', details: (engine.reasons ?? []).join('\n') || undefined });
else if (engine.status === 'not-in-feed') Object.assign(engineView, { status: 'current', message: 'No engine update is listed right now.' });
else Object.assign(engineView, { status: 'unavailable', message: "There isn't an engine for this PC's graphics card yet.", details: (engine.reasons ?? []).join('\n') || undefined });
const control_ = report.control ?? {};
+25 -10
View File
@@ -5,8 +5,21 @@ import { Btn, IconBtn } from './ui';
import { SyntaxCode } from './SyntaxCode';
import { MathView } from './MathView';
import { displayBlock, hasMath, splitMath } from '../lib/math';
function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNode {
return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
import { imagePlan } from '../lib/markdown-images';
function inlineMarks(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
return text.split(/(!\[[^\]]*\]\([^\s)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
const image = part.match(/^!\[([^\]]*)\]\(([^\s)]+)\)$/);
if (image) {
const plan = imagePlan(image[2], imageSession);
if (plan.kind === 'img')
return <img key={i} src={plan.src} alt={image[1]} className="md-image" loading="lazy" referrerPolicy="no-referrer"/>;
if (plan.kind === 'link')
return <a key={i} href={plan.href} target="_blank" rel="noreferrer" onClick={e => { if (onOpenLink) {
e.preventDefault();
onOpenLink(plan.href);
} }}>{image[1] || plan.href}</a>;
return <Fragment key={i}>{image[1] || part}</Fragment>;
}
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="md-code">{part.slice(1, -1)}</code>;
if (part.startsWith('**') && part.endsWith('**'))
@@ -23,21 +36,23 @@ function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNod
});
}
/** Text with its inline marks and, outside code spans, its math (#3268). Math is cut out first: a * or _ inside a formula is not emphasis. */
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
function inline(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
if (!hasMath(text))
return inlineMarks(text, onOpenLink);
return inlineMarks(text, onOpenLink, imageSession);
return text.split(/(`[^`]+`)/g).map((chunk, c) => {
if (chunk.length > 1 && chunk.startsWith('`') && chunk.endsWith('`'))
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink)}</Fragment>;
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink, imageSession)}</Fragment>;
return <Fragment key={c}>{splitMath(chunk).map((part, p) => part.kind === 'math'
? <MathView key={p} tex={part.tex} display={part.display}/>
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink)}</Fragment>)}</Fragment>;
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink, imageSession)}</Fragment>)}</Fragment>;
});
}
export function Markdown({ text, onOpenLink, onPreviewHtml }: {
export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
text: string;
onOpenLink?: (url: string) => void;
onPreviewHtml?: (html: string) => void;
/** The chat whose folder a picture path in the answer is read from (the host serves only files inside it). */
imageSession?: string | null;
}) {
const nodes: ReactNode[] = [];
const lines = text.split('\n');
@@ -66,7 +81,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const Tag = heading[1].length <= 2 ? 'h2' : heading[1].length === 3 ? 'h3' : 'h4';
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink))}</Tag>);
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink, imageSession))}</Tag>);
i++;
continue;
}
@@ -90,7 +105,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
i += 2;
while (i < lines.length && lines[i].includes('|'))
rows.push(cells(lines[i++]));
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink))}</td>))}</tr>))}</tbody></table></div>);
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink, imageSession))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink, imageSession))}</td>))}</tr>))}</tbody></table></div>);
continue;
}
if (/^>\s?/.test(line)) {
@@ -98,7 +113,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
i++;
continue;
}
nodes.push(<p key={i}>{tr(inline(line, onOpenLink))}</p>);
nodes.push(<p key={i}>{tr(inline(line, onOpenLink, imageSession))}</p>);
i++;
}
return <>{tr(nodes)}</>;
+3 -2
View File
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useState } from 'react';
import { Btn, Dialog, ErrorNotice, IconBtn, Select } from './ui';
import { MCP_NAME_HELP, validMcpName } from '../lib/mcp-names';
export function NativeMcpEditor({ text, onChange }: {
text: string;
onChange: (text: string) => void;
@@ -19,8 +20,8 @@ export function NativeMcpEditor({ text, onChange }: {
function open(id: string) { const server = servers[id] ?? {}; setEditing(id); setName(id); setTransport(server.url ? 'url' : 'command'); setAddress(server.url ?? server.command ?? ''); setArgs((server.args ?? []).join('\n')); setAdvanced(JSON.stringify(Object.fromEntries(Object.entries(server).filter(([k]) => !['url', 'command', 'args', 'disabled'].includes(k))), null, 2)); setError(''); }
function save() {
try {
if (!/^[a-z0-9_-]{1,64}$/i.test(name))
throw new Error('Use a simple server name: letters, numbers, hyphens or underscores.');
if (!validMcpName(name))
throw new Error(MCP_NAME_HELP);
if (!address.trim())
throw new Error('Enter the server URL or program.');
if (name !== editing && servers[name])
+12 -4
View File
@@ -20,6 +20,7 @@ import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/sto
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { tuneEffective } from '../lib/native-tune';
import { tunedFields } from '../lib/tune-values';
import { draftingChoices as draftingOptions } from '../lib/drafting';
import { NativeSettings } from './NativeSettings';
import { NativeSettingsFile } from './NativeSettingsFile';
import { RunImageModel } from './RunImageModel';
@@ -41,6 +42,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [roles, setRoles] = useKept<Roles | null>('models.roles', null), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto');
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
const [drafting, setDrafting] = useState('auto');
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
const presetPending = useRef(false);
@@ -99,8 +101,11 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
// A layer split written for another number of cards no longer fits: back to automatic.
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
// Drafting (which drafter guesses ahead): DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families), so no other model is offered it.
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
const downloadValues = { ...values, ...(draftingChoices ? { drafting } : {}), vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
useEffect(() => { if (!native?.catalog || family)
return; const current = native.instances.find(i => i.id === native.defaultId); const rec = native.catalog.recommendation ?? { family: null, model: null }; const loadedFamily = families.find(f => f.sizes.some(s => s.id === current?.model)); const have = new Set((native.installed?.models ?? []).filter(m => m.ready).map(m => m.model)); const installedFamily = families.find(f => f.sizes.some(s => have.has(s.id))); const f = loadedFamily ?? installedFamily ?? families.find(f => f.family === rec.family || f.sizes.some(s => s.sourceFamily === rec.family)) ?? families[0]; if (f) {
setFamily(f.family);
@@ -117,7 +122,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
}
if (!selectedSize)
return;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
if (sig === appliedSig)
return;
setAppliedSig(sig);
@@ -128,6 +133,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setVision(installed.visionEnabled === false ? 'no' : installed.visionDevice ?? (installed.images && installed.images !== 'off' ? installed.images : 'yes'));
setKv(installed.kv ?? 'int8');
setLowRam(installed.lowRamMode ?? 'auto');
setDrafting(installed.drafting ?? 'auto');
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
setResourceProfile(installed.resourceProfile);
@@ -151,7 +157,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
} }
async function load() { if (!selectedSize)
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}), values: {} } });
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(draftingChoices ? { values: { drafting } } : {}), ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}) } });
setJobId(result.id);
}
else
@@ -179,8 +185,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
<div className="native-choice__row">
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144], context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => setContext(Number(n))}/></div>
{tr(draftingChoices ? <div className="native-choice__col"><span className="native-choice__label">{tr("Drafting")}</span><Select ariaLabel={tr("Drafting")} value={drafting} disabled={!!active} options={draftingChoices.map(v => ({ value: v, label: tr(draftingWords[v]) }))} onChange={v => setDrafting(String(v))}/></div> : null)}
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave 20% for my work")}</strong><small>{tr("Headroom for your other apps")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
</div>
{tr(loadedSelected?.metrics?.engine?.drafting_note ? <p className="small muted native-spill-note">{tr(String(loadedSelected.metrics.engine.drafting_note))}</p> : null)}
{roleCards.length > 1 ? <GpuRoles cards={roleCards} roles={effectiveRoles} custom={customRoles} recommendedRoles={recommended.roles} vendor={engineVendor} disabled={!!active} onChange={changeRoles} onBack={() => setRoles(null)} memoryGb={selectedSize ? memoryNeeded(selectedSize) || null : null} ramGb={pcRam} spillGb={selectedSize ? fitOn(selectedSize, fitHardware).spillGb : 0} usedBy={i => usedBy([i])}/> : null}
{tr(roleCards.length > 1 && (native?.hardware?.gpus ?? []).length > roleCards.length ? <p className="small muted native-cards__note">{tr('Not used: {0} (another make of graphics card than this engine drives).', [(native?.hardware?.gpus ?? []).filter(g => !roleCards.some(r => r.index === g.index && r.vendor === g.vendor)).map(g => g.name).join(', ')])}</p> : null)}
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
@@ -199,7 +207,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
</div>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(plan?.drafting?.needed ? <p className="small muted">{tr("Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.", [plan.drafting.gb])}</p> : null)}{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
<Dialog open={manage} title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
{tr(downloaded.length ? <div className="native-manage-list" role="list">{tr(downloaded.map(m => { const v = allSizes.find(x => x.id === m.model), loaded = native?.instances.some(i => i.model === m.model && i.status === 'ready'); return <div className="native-manage-row" role="listitem" key={m.model} data-selected={selectedSize?.id === m.model || undefined}>
@@ -6,6 +6,7 @@ import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpda
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { Btn, Dialog, ErrorNotice, Select } from './ui';
import { NativeProgress } from './NativeModels';
import { jobErrorText } from '../lib/native-job-diagnostics';
export function NativeBenchmark({ open, onClose }: {
open: boolean;
onClose: () => void;
@@ -114,7 +115,7 @@ export function NativeOperations() {
}
return <div className="stack stack--lg native-engine-settings">
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={j.error ?? j.summary} detail={j.details}/>))}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={jobErrorText(j, native.jobs)} detail={j.details}/>))}</section>
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
{tr(saved ? <p className="small" role="status">{tr("Saved to {0}", [saved])}</p> : null)}
+3 -2
View File
@@ -6,6 +6,7 @@ import { useStore } from '../lib/store';
import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
import { nativeAction, useNative } from '../lib/native';
import { NativeProgress } from './NativeModels';
import { jobErrorText } from '../lib/native-job-diagnostics';
import { appVersionLabel } from '../lib/app-version';
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
/** A check from an earlier day shows its date too, because the result is kept between visits. */
@@ -89,12 +90,12 @@ export function SettingsUpdates() {
<Btn variant="primary" disabled={component !== 'engine' || !native?.capabilities.rollback || !!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate('rollback')}>{tr("Restore previous engine")}</Btn>
</div>
</Dialog>
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(j.error ?? j.summary)}</p>))}
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(jobErrorText(j, native.jobs))}</p>))}
<Dialog open={!!confirm} compact title={tr(confirm === 'update-strata' ? 'Update Strata?' : 'Update engine?')} onClose={() => setConfirm('')} actions={<><Btn onClick={() => setConfirm('')}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate(confirm)}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p>{tr((native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</Dialog>
</>;
}
/** "Strata 0.2.2 · Engine 0.1.47": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.3 · Engine 0.1.48": the app's own version first, the engine's beside it (About). */
export function AboutVersions() {
const native = useNative();
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
+4 -2
View File
@@ -518,18 +518,20 @@ export function MetricGrid({ children, min = 200 }: {
</div>);
}
/** Shared, announced error with a cause and a concrete recovery action. */
export function ErrorNotice({ message, next, action, onRetry, detail }: {
export function ErrorNotice({ message, next, action, onRetry, detail, secondary }: {
message: string;
next?: string;
action?: string;
onRetry?: () => void;
detail?: string;
/** A second way out, beside the first (a context error offers two). */
secondary?: { label: string; onClick: () => void };
}) {
// A known failure is shown as a sentence and a next step; the original text stays under Details for experts.
const plain = plainFailure(message);
const known = plain.kind !== 'plain';
const nextStep = known ? (plain.next ? tr(plain.next) : next) : next;
const raw = detail ?? (known ? plain.detail : undefined);
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn> : null)}</div>;
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? (secondary ? <div className="row"><Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn><Btn size="sm" variant="ghost" onClick={secondary.onClick}>{tr(secondary.label)}</Btn></div> : <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn>) : null)}</div>;
}
export function PreviewBadge() { const demo = useStore(s => s.library?.mode === 'simulated'); return demo ? <span className="preview-badge">{tr("Preview build")}</span> : null; }
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.2), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.2 app.
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.3), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.3 app.
* Without a real app version (a source checkout reports 'dev') the other number is the best there is.
*/
export function appVersionLabel(app: string | null | undefined, control?: string | null): string | undefined {
+4 -1
View File
@@ -13,7 +13,10 @@ export function chatMetrics(session:Session|null, monitor:MonitorState|null, con
const finished=latest?(latest.contextUsed??latest.inputTokens!+(latest.tokens??0)):null;
const liveUsed=active&&Number.isFinite(monitor!.context.usedTokens)&&monitor!.context.usedTokens>0?monitor!.context.usedTokens:null;
const live=replies.some(m=>m.streaming)||(active&&['reading','thinking','generating'].includes(monitor!.phase));
const used=live?(liveUsed??finished):(finished??liveUsed);
// While a turn runs, its own figure comes first: the host reports what the context holds after each model request of the turn. The engine's live
// figure belongs to the request in flight (a small one between two tools read as 1%), and `finished` to an earlier reply.
const turnUsed=replies.find(m=>m.streaming&&Number.isFinite(m.contextUsed)&&m.contextUsed!>0)?.contextUsed??null;
const used=live?(turnUsed??liveUsed??finished):(finished??liveUsed);
const max=remote?(replies.at(-1)?.contextMax||null):configuredContext??(active?monitor!.context.maxTokens:latest?.contextMax??null);
const streaming=replies.find(m=>m.streaming);
// Answer speed: everything the model generated over its decode time. While a reply streams it is that reply's; after it, the LAST reply's own
+32
View File
@@ -0,0 +1,32 @@
import { tr } from './i18n';
import { sendOversized, startNewChat, summarizeAndContinue } from './store';
/**
* What the error card of a chat offers when the model's window is the problem. A conversation that no longer fits can be summarised and
* carried on, or left for a new chat; a single message that is too large can go as a file the assistant reads in parts, or as a shortened
* preview. "Try the message again" would only fail the same way, so the card never says it for these.
*/
export function contextRecovery(code: string | undefined): {
next: string;
action?: string;
onRetry?: () => void;
secondary?: { label: string; onClick: () => void };
} {
if (code === 'CONTEXT_MESSAGE_TOO_LARGE') {
return {
next: tr('Attach it as a file for the assistant to read in parts, or send a shortened preview.'),
action: 'Attach as a file',
onRetry: () => void sendOversized('file'),
secondary: { label: 'Send a shortened preview', onClick: () => void sendOversized('preview') },
};
}
if (code === 'CONTEXT_WINDOW_EXCEEDED') {
return {
next: tr('Summarize it now and continue, or start a new chat.'),
action: 'Summarize and continue',
onRetry: () => void summarizeAndContinue(),
secondary: { label: 'Start a new chat', onClick: () => startNewChat() },
};
}
return { next: tr('Try sending the message again.') };
}
+10
View File
@@ -0,0 +1,10 @@
/**
* The Drafting choices the Models screen offers for a model. DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families) AND only while
* the engine serves it: strata_control/drafting.py ENGINE_SERVES_DFLASH2 (Control reports it as `capabilities.dflash2`). Until then the setting
* is Auto / MTP / Off; DFlash2 appears by itself when the engine can run it. The Terminal's --drafting flag has it either way, gated with a note.
*/
export const DRAFTING_FAMILIES = ['qwen', 'gyro'];
export function draftingChoices(family: string | null | undefined, dflash2Served: boolean | null | undefined): string[] {
return DRAFTING_FAMILIES.includes(String(family)) && dflash2Served === true ? ['auto', 'mtp', 'dflash2', 'off'] : ['auto', 'mtp', 'off'];
}
+5 -2
View File
@@ -89,17 +89,20 @@ export function pickMain(cs: RoleCard[]): { main: RoleCard | null; why: MainWhy
export type OffWhy = 'cannot_run' | 'igpu' | 'small' | 'helper';
export interface Recommendation { roles: Roles; main: number | null; why: MainWhy; reasons: Record<number, MainWhy | OffWhy> }
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less, built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less when a bigger card is there (or it has under 4 GB), built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
export function recommend(cards: RoleCard[] | null | undefined, vendor?: string | null): Recommendation {
const cs = rows(cards, vendor);
const { main, why } = pickMain(cs);
const roles: Roles = {}, reasons: Recommendation['reasons'] = {};
// An 8 GB card is a poor Helper beside a bigger card, but a perfectly good one when 8 GB is all there is: two GTX 1080s are a working
// 16 GB pool (#3655). A card with 8 GB or less starts Off only when a bigger usable card is there, or when it is too small to help.
const hasBigger = cs.some(c => canRun(c) && !shares(c) && rnd(mem(c)) > OFF_AT_OR_BELOW_GB);
for (const c of cs) {
const i = c.index;
if (main && c === main) { roles[i] = 'main'; reasons[i] = why; }
else if (!canRun(c)) { roles[i] = 'off'; reasons[i] = 'cannot_run'; }
else if (shares(c) && main) { roles[i] = 'off'; reasons[i] = 'igpu'; }
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB) { roles[i] = 'off'; reasons[i] = 'small'; }
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB && (hasBigger || mem(c) < MIN_CARD_GB)) { roles[i] = 'off'; reasons[i] = 'small'; }
else { roles[i] = 'helper'; reasons[i] = 'helper'; }
}
return { roles, main: main ? main.index : null, why, reasons };
+11 -2
View File
@@ -7,6 +7,14 @@ import tune_es from '../locales/tune-es.json';
import tune_ru from '../locales/tune-ru.json';
import tune_pt from '../locales/tune-pt.json';
import tune_ko from '../locales/tune-ko.json';
import tools_en from '../locales/tools-en.json';
import tools_zh from '../locales/tools-zh.json';
import tools_ja from '../locales/tools-ja.json';
import tools_de from '../locales/tools-de.json';
import tools_es from '../locales/tools-es.json';
import tools_ru from '../locales/tools-ru.json';
import tools_pt from '../locales/tools-pt.json';
import tools_ko from '../locales/tools-ko.json';
import base_zh from '../locales/zh.json';
import base_ja from '../locales/ja.json';
import base_de from '../locales/de.json';
@@ -18,8 +26,9 @@ import base_ko from '../locales/ko.json';
export const LANGUAGES=[{value:'en',label:'English'},{value:'zh',label:'中文(简体)'},{value:'ja',label:'日本語'},{value:'de',label:'Deutsch'},{value:'es',label:'Español'},{value:'ru',label:'Русский'},{value:'pt',label:'Português'},{value:'ko',label:'한국어'}] as const;
export type Language=typeof LANGUAGES[number]['value'];
/* The Tune model dialog's strings live in tune-<lang>.json (apps/host/scripts/make-tune-locales.py) so other interface work does not collide with them. */
const english:Record<string,string>={...baseEnglish,...tune_en};
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh},{...base_ja,...tune_ja},{...base_de,...tune_de},{...base_es,...tune_es},{...base_ru,...tune_ru},{...base_pt,...tune_pt},{...base_ko,...tune_ko}] as Record<string,string>[];
/* The tool-server, picture and agent-result strings of the tools and images fixes live in tools-<lang>.json for the same reason. */
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en};
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh,...tools_zh},{...base_ja,...tune_ja,...tools_ja},{...base_de,...tune_de,...tools_de},{...base_es,...tune_es,...tools_es},{...base_ru,...tune_ru,...tools_ru},{...base_pt,...tune_pt,...tools_pt},{...base_ko,...tune_ko,...tools_ko}] as Record<string,string>[];
const catalogs:Record<Language,Record<string,string>>={en:english,zh,ja,de,es,ru,pt,ko};
const entities:Record<string,string>={amp:'&',lt:'<',gt:'>',quot:'"',apos:"'",nbsp:'\u00a0'};
const decode=(text:string)=>text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi,(whole,key:string)=>key[0]==='#'?String.fromCodePoint(key[1].toLowerCase()==='x'?parseInt(key.slice(2),16):parseInt(key.slice(1),10)):entities[key]??whole);
+32
View File
@@ -0,0 +1,32 @@
import { rawUrlOf } from './deliverables';
/**
* Which picture addresses a model's Markdown may put in an <img> (tools and images review O3, 2026-10-22).
*
* An answer can carry `![chart](...)`. It is shown only when the picture is already on this computer or inside the message:
* - a data: address of a PNG, JPEG, WebP or GIF (never SVG: an SVG can carry script and styles);
* - the host's own /api/files/raw address (a file inside the chat's folder, served by the host and by nobody else);
* - a path without a scheme, which is read as a file of the chat's folder through that same address.
* Nothing is fetched from the internet by default (an address in the answer would tell its owner that the answer was read, and
* which IP read it): an http(s) picture stays a link the person can open. file:, javascript:, blob: and every other scheme show as text.
*/
export type ImagePlan =
| { kind: 'img'; src: string }
| { kind: 'link'; href: string }
| { kind: 'text' };
const DATA_IMAGE = /^data:image\/(?:png|jpe?g|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/i;
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
export function imagePlan(src: string, sessionId?: string | null): ImagePlan {
const value = src.trim();
if (!value) return { kind: 'text' };
if (DATA_IMAGE.test(value)) return { kind: 'img', src: value };
if (/^https?:\/\//i.test(value)) return { kind: 'link', href: value };
if (value.startsWith('/api/files/raw?')) return { kind: 'img', src: value };
// a drive letter (C:\x.png, C:/x.png) is a path, not a scheme
if (SCHEME.test(value) && !/^[a-z]:[\\/]/i.test(value)) return { kind: 'text' };
if (value.startsWith('//') || value.startsWith('\\\\')) return { kind: 'text' }; // another computer's share
if (!sessionId) return { kind: 'text' };
return { kind: 'img', src: rawUrlOf(sessionId, value) };
}
+5
View File
@@ -0,0 +1,5 @@
/** The one rule for the name of a tool server. A copy of server/mcp-names.mjs (tests/mcp-rows.test.mjs checks that they agree). */
export const MCP_NAME_MAX = 32;
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
export const validMcpName = (name: string) => MCP_NAME_RE.test(name);
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
@@ -0,0 +1,22 @@
import type { NativeJob } from './native';
/** What the host says about a job a restart cut short. */
export const INTERRUPTED = 'The host restarted before completion. Check diagnostics, then retry; completed files are kept.';
const updateKinds = new Set(['update-strata', 'update-engine']);
/**
* A later finished update of the same component settles the restart interruption of an earlier one (ported from #3578 by medking82).
* The records are never changed: a running retry, another component or an ordinary failure stays a plain failure.
*/
export function recoveredInterruptions(jobs: readonly NativeJob[]): Set<string> {
const time = (job: NativeJob) => Date.parse(job.startedAt);
return new Set(jobs.filter(job => job.state === 'error' && updateKinds.has(job.kind) && job.error === INTERRUPTED && Number.isFinite(time(job))
&& jobs.some(later => later.state === 'done' && later.kind === job.kind && Number.isFinite(time(later)) && time(later) > time(job))).map(job => job.id));
}
/** The text of a failed job's alert. The alert stays red; an interruption a later update has settled says so in plain words. */
export function jobErrorText(job: NativeJob, jobs: readonly NativeJob[]): string {
return recoveredInterruptions(jobs).has(job.id)
? 'This update was cut short when the app restarted. A later update finished, so nothing needs doing.'
: job.error ?? job.summary;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export interface NativeState {
/** The picture reader (strata-vision) is installed with the engine, so models marked for pictures can read them here. */
/** Strata Control's view of the engine for this computer (the app): usable false = none found or recognised; `notice` says why in plain words. */
engine?:{usable:boolean;notice?:string|null;wrongMake?:boolean;version?:string|null};
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;/** Free bytes on the drive that holds the models folder. */downloadFreeBytes?:number|null;downloadTotalBytes?:number|null;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;/** SMs x clock: only the order between cards matters (strata_control/gpu_roles.py). */speed?:number;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;/** Free bytes on the drive that holds the models folder. */downloadFreeBytes?:number|null;downloadTotalBytes?:number|null;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';/** The Drafting choice saved with the model: auto | mtp | dflash2 | off. */drafting?:string;dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;/** SMs x clock: only the order between cards matters (strata_control/gpu_roles.py). */speed?:number;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{/** The engine serves DFlash2 (Control says): the Drafting choice then offers it. */dflash2?:boolean;responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
let state:NativeState|null=null;
const listeners=new Set<()=>void>();
function put(value:NativeState){if(state&&JSON.stringify(state)===JSON.stringify(value))return;state=value;listeners.forEach(fn=>fn());}
+5 -2
View File
@@ -8,13 +8,15 @@ import { tr } from './i18n';
export interface PlainFailure { message: string; next?: string; detail?: string; kind: string }
interface Rule { kind: string; match: RegExp; message: string; next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
interface Rule { kind: string; match: RegExp; message: string | ((m: RegExpMatchArray) => string); next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
const MEMORY = 'The model stopped while starting, most likely because the PC ran low on memory.';
const MEMORY_NEXT = 'Close other programs and try again, or choose “Leave 20% for my work”. In Expert mode you can also turn on the low-RAM option, so part of the model is read from the SSD.';
/** Order matters: the first rule that matches wins. */
const RULES: Rule[] = [
// The host names the files it could not find (#3478). The sentence with the names is a template in every language: tr() fills it.
{ kind: 'model-files-missing', match: /Some model files are missing: (.+?)\. Use Download/i, message: m => `These model files are missing: ${m[1]}.`, next: 'Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.' },
{ kind: 'tunnel-own-running', match: /Strata’s tunnel for this address is already running \(PID/i, message: 'Strata’s tunnel for this address is already running.', next: 'Stop it first, or use it as it is, then connect again here.' },
{ kind: 'gpu-shared', match: /GPU \d+ is already used by .*, so this model did not fit there/i, message: 'Another model on the same graphics card left no room for this one.', next: 'Load it on a different GPU, or unload the other model first.' },
{ kind: 'gpu-no-code', match: /none of this computer.s graphics cards has code in this Strata engine|not an architecture this Strata engine was compiled for/i, message: 'This Strata engine has no code for the graphics card it tried to use.', next: 'Pick another card under Run on, or open Details to see which cards were found and what the engine is built for.' },
@@ -34,6 +36,7 @@ const RULES: Rule[] = [
{ kind: 'blocked', match: /0xC0000022|3221225506|\bEACCES\b|\bEPERM\b|operation not permitted/i, message: 'Windows or your security software stopped the engine from starting.', next: 'Allow Strata in your security software, then try again.' },
{ kind: 'file-missing', match: /\bENOENT\b|no such file or directory|cannot find the (?:file|path)|0x80070002|exit code 127|file not found/i, message: 'Strata could not find a file it needs to start the engine.', next: 'Reinstall Strata or press Repair.' },
{ kind: 'memory', match: /out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate memory|not enough memory|bad_alloc|pinned-memory-exhausted|mapped staging|cudaErrorMemoryAllocation|insufficient memory/i, message: MEMORY, next: MEMORY_NEXT },
{ kind: 'previous-session', match: /lost host child is still present|retry after it exits/i, message: 'The last Strata session is still closing down.', next: 'Wait a moment and try again. If it keeps happening, restart the computer.' },
{ kind: 'start-failed', match: /server_exited|engine stopped|exited before|died before/i, message: 'The model stopped while starting.', next: 'Try again. If it keeps happening, open Details and share them when you ask for help.' },
{ kind: 'slow-load', match: /loading took more than/i, message: 'Loading is taking too long.', next: 'The PC may be short on memory. Close other programs and try again.' },
{ kind: 'engine-missing', match: /no engine for this pc|engine component|never downloads an engine|engine .*not installed|no strata engine/i, message: 'Strata’s engine for this computer isn’t installed yet.', next: 'It is added when you download a model. Download your model again to add it.' },
@@ -50,7 +53,7 @@ export function plainFailure(raw: string | null | undefined): PlainFailure {
for (const rule of RULES) {
const m = text.match(rule.match);
if (!m) continue;
return { kind: rule.kind, message: rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
return { kind: rule.kind, message: typeof rule.message === 'function' ? rule.message(m) : rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
}
return { kind: 'plain', message: text.replace(/^./, c => c.toUpperCase()) };
}
+30 -2
View File
@@ -74,7 +74,7 @@ function normalise(loc: Location): void {
* Navigate, morphing between screens when the browser can.
*
* A view transition needs the DOM update to happen inside its callback, and
* React commits asynchronously — so we wait two frames before resolving. It is
* React commits asynchronously — so we wait for the commit (never for a frame: see afterRouteUpdate). It is
* skipped while a response is streaming (the transcript is being appended to on
* every token, and snapshotting it mid-stream would fight the SSE updates) and
* when the user has asked for reduced motion.
@@ -100,6 +100,34 @@ export function isWebKitGtk(ua: string = navigator.userAgent): boolean {
return /AppleWebKit/.test(ua) && /Linux|X11/.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Android/.test(ua);
}
/**
* Resolve once the new route has been drawn into the page, or after a short deadline, whichever comes first.
*
* The update callback of a view transition must not wait for requestAnimationFrame: while the callback is pending the browser holds
* rendering (Chromium and Firefox both do), so a frame callback never runs, and every click waits for the browser's own guard,
* about 4 s in Chromium and the WebView2 window, about 5 s in Firefox (#3673). A DOM change after the hash flipped means React has
* committed the new screen; the deadline covers a route that changes nothing visible.
*/
export function afterRouteUpdate(done: () => void, timeoutMs = 150): void {
let settled = false;
let observer: MutationObserver | undefined;
const finish = () => {
if (settled) return;
settled = true;
observer?.disconnect();
done();
};
try {
if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) {
observer = new MutationObserver(() => setTimeout(finish, 0));
observer.observe(document.body, { childList: true, subtree: true });
}
} catch {
/* the deadline alone is enough */
}
setTimeout(finish, timeoutMs);
}
function navigateHash(next: string, morph = true) {
if (window.location.hash === next) return;
@@ -121,7 +149,7 @@ function navigateHash(next: string, morph = true) {
() =>
new Promise<void>((resolve) => {
window.location.hash = next;
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
afterRouteUpdate(resolve);
}),
);
// A transition is *expected* to be skipped when one is already running (rapid
+110
View File
@@ -0,0 +1,110 @@
import type { Session } from './types';
/**
* Saving the chat list without hurting the page (#3604, #3603).
*
* Every streamed token used to stringify every chat and write the lot to localStorage. On a long history that is
* megabytes of string work per token, and the WebView2 renderer grew to gigabytes before it died (the window goes
* white). Three rules fix it, and the stored format is unchanged, so older builds and the flow scripts still read it:
*
* 1. Coalesce: the newest state wins and at most one write happens per `delayMs`. The caller flushes when a turn ends
* or the page goes away.
* 2. Only the chats that changed are serialised again. A chat is replaced by a new object when it changes (the store
* never mutates), so the previous JSON of every other chat is reused by object identity.
* 3. The stored copy is capped: a huge tool result or an old chat that no longer fits is left out of the saved copy,
* never out of memory. The saved list is a cache, not the source of truth.
*/
/** Longest text kept for one field in the saved copy. */
export const MAX_FIELD_CHARS = 200_000;
/** Longest saved copy of one chat (JSON characters). A longer chat is shortened from its oldest messages. */
export const MAX_SESSION_CHARS = 1_500_000;
/** Longest saved chat list (JSON characters). localStorage allows about 5M; keep a margin for everything else. */
export const MAX_TOTAL_CHARS = 3_500_000;
type Json = unknown;
function clipFields(value: Json, depth = 0): Json {
if (typeof value === 'string') return value.length > MAX_FIELD_CHARS ? `${value.slice(0, MAX_FIELD_CHARS)}\n[shortened in the saved copy]` : value;
if (depth > 6 || value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(v => clipFields(v, depth + 1));
const out: Record<string, Json> = {};
for (const [k, v] of Object.entries(value as Record<string, Json>)) out[k] = clipFields(v, depth + 1);
return out;
}
/** The JSON text kept for one chat: whole when it fits, otherwise clipped and then shortened from the oldest message. */
export function serializeSession(session: Session): string {
const whole = JSON.stringify(session);
if (whole.length <= MAX_SESSION_CHARS) return whole;
let copy = clipFields(session) as Session;
let text = JSON.stringify(copy);
if (text.length <= MAX_SESSION_CHARS) return text;
let messages = copy.messages ?? [];
// Drop the oldest messages in halves until it fits; always keep the latest two.
while (text.length > MAX_SESSION_CHARS && messages.length > 2) {
messages = messages.slice(Math.max(1, Math.floor(messages.length / 2)));
copy = { ...copy, messages };
text = JSON.stringify(copy);
}
return text;
}
export interface SessionWriterOptions {
/** Where the list is saved (the storage write). Receives the finished JSON text. */
write: (json: string) => void;
delayMs?: number;
maxTotalChars?: number;
/** Counts a serialisation of one chat; for tests. */
onSerialize?: (session: Session) => void;
}
export function createSessionWriter(options: SessionWriterOptions) {
const delayMs = options.delayMs ?? 1_000;
const maxTotal = options.maxTotalChars ?? MAX_TOTAL_CHARS;
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Session[] | null = null;
// Chat object -> its JSON. WeakMap: a replaced chat is forgotten with its object.
const cache = new WeakMap<Session, string>();
const textOf = (s: Session): string => {
let text = cache.get(s);
if (text === undefined) {
options.onSerialize?.(s);
text = serializeSession(s);
cache.set(s, text);
}
return text;
};
const flush = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
const sessions = pending;
pending = null;
if (!sessions) return;
const parts = sessions.map(s => ({ s, text: textOf(s) }));
let total = parts.reduce((n, p) => n + p.text.length + 1, 2);
if (total > maxTotal) {
// Leave the oldest unpinned chats out of the saved copy until the rest fits. The newest chat is always kept.
const order = parts
.map((p, i) => ({ i, p }))
.sort((a, b) => Number(!!a.p.s.pinned) - Number(!!b.p.s.pinned) || (a.p.s.updatedAt < b.p.s.updatedAt ? -1 : 1));
const drop = new Set<number>();
for (const { i, p } of order) {
if (total <= maxTotal || drop.size >= parts.length - 1) break;
drop.add(i);
total -= p.text.length + 1;
}
options.write(`[${parts.filter((_, i) => !drop.has(i)).map(p => p.text).join(',')}]`);
return;
}
options.write(`[${parts.map(p => p.text).join(',')}]`);
};
const schedule = (sessions: Session[]) => {
pending = sessions;
if (timer === null) timer = setTimeout(flush, delayMs);
};
return { schedule, flush, hasPending: () => pending !== null };
}
+89 -8
View File
@@ -19,6 +19,7 @@ import { noModelNotice } from './no-model';
import { taskHandoverMessage, taskHandoverTitle, chatHandoverMessage, chatHandoverTitle } from './task-handover';
import type { Task } from './tasks';
import { postWithFolderFallback } from './folder-pick';
import { createSessionWriter } from './session-persist';
import { setTranslationLanguage, tr, validLanguage, type Language } from './i18n';
/**
* Strata app store.
@@ -1008,12 +1009,33 @@ export async function scopeSkill(id: string, workspaceId: string | null) {
/* ---------------------------------------------------------------- sessions */
function persistSessions(sessions: Session[]) {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(sessions));
} catch {
/* quota — sessions are a cache, not the source of truth */
}
const sessionWriter = createSessionWriter({
write: (json) => {
try {
localStorage.setItem(SESSION_KEY, json);
} catch {
/* quota — sessions are a cache, not the source of truth */
}
},
});
/**
* Save the chat list. A one-off change (new, rename, delete, pin) is written now; `soon` is for the streaming
* path, where a write per token grew the renderer to gigabytes (#3604, #3603): those are coalesced to one a second.
*/
function persistSessions(sessions: Session[], soon = false) {
sessionWriter.schedule(sessions);
if (!soon) sessionWriter.flush();
}
/** Write the newest chat list now: a turn ended, or the page is going away. */
export function flushSessions() {
sessionWriter.flush();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' && window.addEventListener) {
window.addEventListener('pagehide', flushSessions);
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') flushSessions(); });
}
export function newSession(): Session {
@@ -1120,7 +1142,7 @@ export function renameSession(id: string, title: string) {
function patchSession(id: string, fn: (s: Session) => Session) {
const sessions = state.sessions.map((s) => (s.id === id ? fn(s) : s));
persistSessions(sessions);
persistSessions(sessions, true);
set({ sessions });
}
@@ -1366,6 +1388,55 @@ export async function retryLast(messageId?: string) {
}
}
/** The open chat's last question and the answer that failed under it (null when the chat does not end that way). */
function failedExchange(): { prompt: string; sessionId: string } | null {
const sessionId = state.activeSession;
const chat = state.sessions.find(s => s.id === sessionId);
if (!sessionId || !chat || state.streaming) return null;
const reply = chat.messages[chat.messages.length - 1], ask = chat.messages[chat.messages.length - 2];
return reply?.role === 'assistant' && reply.error && ask?.role === 'user' ? { prompt: ask.content, sessionId } : null;
}
/** Takes the failed question and its error out of the transcript, so asking again does not show the question twice. */
function dropFailedExchange(sessionId: string) {
patchSession(sessionId, s => ({ ...s, messages: s.messages.slice(0, -2) }));
}
/**
* The error card of a conversation that no longer fits the model's window: summarise the older messages (the same as typing /compact), then
* ask the assistant to answer. The refused question is already in the assistant's conversation (it is the newest message, and a summary keeps
* the newest message as it is), so it is not sent a second time: a large one twice would not fit either.
*/
export async function summarizeAndContinue() {
if (!failedExchange()) return;
await sendMessage('/compact');
await sendMessage('Please answer my last message now.');
}
/**
* The error card of a message that is too large to send: the host makes it small enough (its text as a file in the chat's folder that the
* assistant reads in parts, or a shortened preview), and that is sent in its place.
*/
export async function sendOversized(mode: 'file' | 'preview') {
const failed = failedExchange();
if (!failed) return;
let reply: { message?: string };
try {
reply = await api.post<{ message?: string }>('/api/chat/oversize', { sessionId: failed.sessionId, mode, text: failed.prompt, nativeModelId: nativeState()?.instances.length ? nativeState()?.defaultId : undefined });
} catch {
pushToast('error', 'The message could not be prepared. Try a shorter message.');
return;
}
if (!reply.message) return;
dropFailedExchange(failed.sessionId);
await sendMessage(reply.message);
}
/** A new, empty chat, opened. */
export function startNewChat() {
const session = newSession();
go('chat', session.id);
}
/**
* Forks a conversation at a message, leaving the original untouched.
*
@@ -1733,9 +1804,15 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
}));
} else if (type === 'context') {
// What the context holds after each model request of an agent turn: the meter follows the turn instead of waiting for its end.
const used = Number(frame.contextTokens), max = Number(frame.contextMax);
if (Number.isFinite(used) && used > 0) patchAssistant((m) => ({ ...m, contextUsed: used, ...(Number.isFinite(max) && max > 0 ? { contextMax: max } : {}) }));
} else if (type === 'error') {
clearStatus(sessionId);
patchAssistant((m) => ({ ...m, error: plainFailure(String(frame.message ?? 'Engine error')).message, streaming: false }));
// A sentence the host wrote for the person (it carries its catalog key) is shown in their language as it is; anything else is made plain here.
const said = typeof frame.key === 'string' ? tr(frame.key, Array.isArray(frame.values) ? frame.values : undefined) : plainFailure(String(frame.message ?? 'Engine error')).message;
patchAssistant((m) => ({ ...m, error: said, streaming: false, ...(typeof frame.code === 'string' ? { errorCode: frame.code } : {}), ...(typeof frame.details === 'string' && frame.details ? { errorDetail: frame.details } : {}) }));
} else if (type === 'done') {
clearStatus(sessionId);
patchSession(sessionId, (sess) => ({
@@ -1756,6 +1833,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
}));
// The final text is in `state`: write it once now instead of waiting for the coalescing timer.
flushSessions();
}
},
turnAbort.signal,
@@ -1772,6 +1851,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
patchAssistant((m) => ({ ...m, streaming: false }));
}
} finally {
// A cancelled or failed turn is still worth keeping.
flushSessions();
clearStatus(sessionId);
markTurn(sessionId, null);
const next = releasedMessages.get(sessionId)?.shift();
+4
View File
@@ -358,6 +358,10 @@ export interface ChatMessage {
promptTps?: number;
durationMs?: number;
error?: string;
/** What went wrong, as a code the page acts on (a context error offers its own recovery buttons). */
errorCode?: string;
/** The engine's own text of the error, kept behind Details. */
errorDetail?: string;
tools?: ToolCall[];
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Nur Ihr Prozessor und Arbeitsspeicher werden verwendet.",
"s8644": "Für Experten: Die Engine startet auf den Karten {0} (Hauptkarte zuletzt).",
"s8645": "Wird nicht gespeichert, solange Sie die Empfehlung behalten: Strata wählt neu, wenn Sie eine Karte hinzufügen oder entfernen.",
"s8646": "Nicht genutzt: {0} (anderer Grafikkarten-Hersteller als der, den diese Engine ansteuert)."
"s8646": "Nicht genutzt: {0} (anderer Grafikkarten-Hersteller als der, den diese Engine ansteuert).",
"s9001": "Diese Nachricht ist etwa {0} Tokens lang, und dieses Modell kann etwa {1} auf einmal lesen. Sie ist zu groß, um so gesendet zu werden.",
"s9002": "Hängen Sie sie als Datei an, die der Assistent in Teilen liest, oder senden Sie eine gekürzte Vorschau.",
"s9003": "Diese Unterhaltung passt nicht mehr in das Fenster des Modells, und das Zusammenfassen der älteren Nachrichten hat nicht genug Platz geschaffen.",
"s9004": "Fassen Sie sie jetzt zusammen und machen Sie weiter, oder beginnen Sie einen neuen Chat.",
"s9005": "Frühere Nachrichten werden zusammengefasst …",
"s9006": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll und ist jetzt zu {1} % voll.",
"s9007": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll.",
"s9008": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen.",
"s9009": "Dieser Chat ist zu {0} % voll, aber es gibt nichts Älteres zusammenzufassen: Die neueste Nachricht füllt ihn. Beginnen Sie einen neuen Chat oder senden Sie eine kürzere Nachricht.",
"s9010": "Es gibt noch nichts Älteres zusammenzufassen. Alles in diesem Chat braucht der Assistent noch.",
"s9011": "Es gibt noch nichts zusammenzufassen. In diesem Chat hat noch keine Unterhaltung mit dem Assistenten begonnen.",
"s9012": "Die älteren Nachrichten konnten gerade nicht zusammengefasst werden. Versuchen Sie es gleich noch einmal oder beginnen Sie einen neuen Chat.",
"s9013": "In diesem Chat läuft noch etwas. Halten Sie ihn an oder warten Sie, bis er fertig ist, und versuchen Sie es dann erneut.",
"s9014": "Als Datei anhängen",
"s9015": "Gekürzte Vorschau senden",
"s9016": "Zusammenfassen und weitermachen",
"s9017": "Neuen Chat beginnen",
"s9018": "Die Nachricht konnte nicht vorbereitet werden. Versuchen Sie es mit einer kürzeren Nachricht.",
"s9019": "Die letzte Strata-Sitzung wird noch beendet.",
"s9020": "Warten Sie einen Moment und versuchen Sie es erneut. Wenn es wieder passiert, starten Sie den Computer neu.",
"s9021": "Vorausraten",
"s9022": "Automatisch (empfohlen)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Vorausraten: MTP (DFlash2 hat nicht hineingepasst)",
"s9026": "Vorausraten: MTP (DFlash2 ist in diesem Modus nicht verfügbar)",
"s9027": "Vorausraten: MTP (DFlash2 gibt es nur für Qwen3.8-Flash-Next)",
"s9028": "Vorausraten: MTP (der DFlash2-Entwurfsbaustein ist noch nicht heruntergeladen)",
"s9029": "Wenn Sie DFlash2 wählen, wird auch sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig. Passt er nicht in den Grafikspeicher, nutzt das Modell stattdessen MTP.",
"s9030": "DFlash2-Entwurfsbaustein wird heruntergeladen …",
"s9031": "DFlash2-Entwurfsbaustein wird für die Engine vorbereitet …",
"s9032": "Der DFlash2-Entwurfsbaustein ist bereit.",
"s9033": "Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell mit MTP. Das Modell wird geladen …",
"s9034": "Das Modell wird geladen …",
"s9035": "Diese Modelldateien fehlen: {0}.",
"s9036": "Drücken Sie noch einmal auf „Herunterladen und laden“, um sie zu holen. Vorhandene Dateien bleiben erhalten. Werden dieselben Dateien wieder aufgelistet, öffnen Sie die Details und teilen Sie sie mit, wenn Sie um Hilfe bitten.",
"s9037": "Dieses Update wurde beim Neustart der App unterbrochen. Ein späteres Update ist abgeschlossen, Sie müssen nichts tun."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Only your processor and memory are used.",
"s8644": "For experts: the engine starts on cards {0} (Main last).",
"s8645": "Not saved while you keep the recommendation: Strata picks again if you add or remove a card.",
"s8646": "Not used: {0} (another make of graphics card than this engine drives)."
"s8646": "Not used: {0} (another make of graphics card than this engine drives).",
"s9001": "This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.",
"s9002": "Attach it as a file for the assistant to read in parts, or send a shortened preview.",
"s9003": "This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.",
"s9004": "Summarize it now and continue, or start a new chat.",
"s9005": "Summarizing earlier messages…",
"s9006": "Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.",
"s9007": "Older messages were summarized to make room. The chat was {0}% full.",
"s9008": "Older messages were summarized to make room.",
"s9009": "This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.",
"s9010": "There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.",
"s9011": "There is nothing to summarize yet. This chat has not started a conversation with the assistant.",
"s9012": "The older messages could not be summarized right now. Try again in a moment, or start a new chat.",
"s9013": "This chat is still working. Stop it or wait for it to finish, then try again.",
"s9014": "Attach as a file",
"s9015": "Send a shortened preview",
"s9016": "Summarize and continue",
"s9017": "Start a new chat",
"s9018": "The message could not be prepared. Try a shorter message.",
"s9019": "The last Strata session is still closing down.",
"s9020": "Wait a moment and try again. If it keeps happening, restart the computer.",
"s9021": "Drafting",
"s9022": "Auto (recommended)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Drafting: MTP (DFlash2 didn't fit)",
"s9026": "Drafting: MTP (DFlash2 isn't available in this mode)",
"s9027": "Drafting: MTP (DFlash2 is for Qwen3.8-Flash-Next only)",
"s9028": "Drafting: MTP (the DFlash2 drafter isn't downloaded yet)",
"s9029": "Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.",
"s9030": "Downloading the DFlash2 drafter…",
"s9031": "Packing the DFlash2 drafter for the engine…",
"s9032": "The DFlash2 drafter is ready.",
"s9033": "The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…",
"s9034": "Loading the model…",
"s9035": "These model files are missing: {0}.",
"s9036": "Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.",
"s9037": "This update was cut short when the app restarted. A later update finished, so nothing needs doing."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Solo se usan tu procesador y tu memoria.",
"s8644": "Para expertos: el motor arranca en las tarjetas {0} (la principal al final).",
"s8645": "No se guarda mientras mantengas la recomendación: Strata elige de nuevo si añades o quitas una tarjeta.",
"s8646": "No se usa: {0} (otra marca de tarjeta gráfica distinta de la que maneja este motor)."
"s8646": "No se usa: {0} (otra marca de tarjeta gráfica distinta de la que maneja este motor).",
"s9001": "Este mensaje tiene unos {0} tokens y este modelo puede leer unos {1} a la vez. Es demasiado grande para enviarlo tal cual.",
"s9002": "Adjúntalo como archivo para que el asistente lo lea por partes, o envía una vista previa acortada.",
"s9003": "Esta conversación ya no cabe en la ventana del modelo, y resumir los mensajes antiguos no dejó espacio suficiente.",
"s9004": "Resúmela ahora y sigue, o empieza un chat nuevo.",
"s9005": "Resumiendo mensajes anteriores…",
"s9006": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} % y ahora está al {1} %.",
"s9007": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} %.",
"s9008": "Se resumieron los mensajes antiguos para hacer sitio.",
"s9009": "Este chat está al {0} %, pero no hay nada antiguo que resumir: el mensaje más reciente es lo que lo llena. Empieza un chat nuevo o envía un mensaje más corto.",
"s9010": "Todavía no hay nada antiguo que resumir. El asistente aún necesita todo lo que hay en este chat.",
"s9011": "Todavía no hay nada que resumir. Este chat aún no ha empezado una conversación con el asistente.",
"s9012": "No se pudieron resumir los mensajes antiguos ahora mismo. Inténtalo de nuevo en un momento o empieza un chat nuevo.",
"s9013": "Este chat sigue trabajando. Deténlo o espera a que termine y vuelve a intentarlo.",
"s9014": "Adjuntar como archivo",
"s9015": "Enviar una vista previa acortada",
"s9016": "Resumir y continuar",
"s9017": "Empezar un chat nuevo",
"s9018": "No se pudo preparar el mensaje. Prueba con un mensaje más corto.",
"s9019": "La última sesión de Strata todavía se está cerrando.",
"s9020": "Espera un momento y vuelve a intentarlo. Si sigue pasando, reinicia el ordenador.",
"s9021": "Predicción",
"s9022": "Automático (recomendado)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Predicción: MTP (DFlash2 no cabía)",
"s9026": "Predicción: MTP (DFlash2 no está disponible en este modo)",
"s9027": "Predicción: MTP (DFlash2 es solo para Qwen3.8-Flash-Next)",
"s9028": "Predicción: MTP (el predictor de DFlash2 aún no está descargado)",
"s9029": "Elegir DFlash2 también descarga su predictor: unos {0} GB, una sola vez. Si no cabe en la memoria gráfica, el modelo usa MTP.",
"s9030": "Descargando el predictor de DFlash2…",
"s9031": "Preparando el predictor de DFlash2 para el motor…",
"s9032": "El predictor de DFlash2 está listo.",
"s9033": "No se pudo descargar el predictor de DFlash2, así que el modelo arranca con MTP. Cargando el modelo…",
"s9034": "Cargando el modelo…",
"s9035": "Faltan estos archivos del modelo: {0}.",
"s9036": "Pulse de nuevo «Descargar y cargar» para obtenerlos. Los archivos que ya están se conservan. Si vuelven a aparecer los mismos archivos, abra los detalles y compártalos cuando pida ayuda.",
"s9037": "Esta actualización se interrumpió cuando la aplicación se reinició. Una actualización posterior terminó, así que no hay nada que hacer."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "プロセッサとメモリのみを使います。",
"s8644": "上級者向け:エンジンはカード {0} で起動します(メインが最後)。",
"s8645": "おすすめのままの間は保存されません。カードを追加・取り外すと Strata が選び直します。",
"s8646": "未使用:{0}(このエンジンが対応するのとは別メーカーのカードです)。"
"s8646": "未使用:{0}(このエンジンが対応するのとは別メーカーのカードです)。",
"s9001": "このメッセージは約{0}トークンで、このモデルは一度に約{1}トークンまで読めます。このまま送るには大きすぎます。",
"s9002": "ファイルとして添付してアシスタントに分けて読んでもらうか、短くしたプレビューを送ってください。",
"s9003": "この会話はモデルのウィンドウに収まらなくなり、古いメッセージを要約しても十分な空きができませんでした。",
"s9004": "今すぐ要約して続けるか、新しいチャットを始めてください。",
"s9005": "これまでのメッセージを要約しています…",
"s9006": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていましたが、現在は{1}%です。",
"s9007": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていました。",
"s9008": "空きを作るために古いメッセージを要約しました。",
"s9009": "このチャットは{0}%埋まっていますが、要約できる古いメッセージはありません。最新のメッセージで埋まっています。新しいチャットを始めるか、もっと短いメッセージを送ってください。",
"s9010": "まだ要約できる古いメッセージはありません。このチャットの内容はどれもアシスタントがまだ必要としています。",
"s9011": "まだ要約するものがありません。このチャットではアシスタントとの会話が始まっていません。",
"s9012": "今は古いメッセージを要約できませんでした。しばらくしてからもう一度試すか、新しいチャットを始めてください。",
"s9013": "このチャットはまだ作業中です。停止するか、終わるのを待ってからもう一度試してください。",
"s9014": "ファイルとして添付",
"s9015": "短くしたプレビューを送る",
"s9016": "要約して続ける",
"s9017": "新しいチャットを始める",
"s9018": "メッセージを準備できませんでした。もっと短いメッセージを試してください。",
"s9019": "前回の Strata セッションがまだ終了処理中です。",
"s9020": "しばらく待ってからもう一度試してください。繰り返し起こる場合はコンピューターを再起動してください。",
"s9021": "先読み",
"s9022": "自動(おすすめ)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "先読み:MTP(DFlash2 は収まりませんでした)",
"s9026": "先読み:MTP(このモードでは DFlash2 を使えません)",
"s9027": "先読み:MTP(DFlash2 は Qwen3.8-Flash-Next 専用です)",
"s9028": "先読み:MTP(DFlash2 のドラフトモデルはまだダウンロードされていません)",
"s9029": "DFlash2 を選ぶと、そのドラフトモデルも一度だけダウンロードします(約 {0} GB)。グラフィックメモリに収まらない場合は、代わりに MTP を使います。",
"s9030": "DFlash2 のドラフトモデルをダウンロードしています…",
"s9031": "DFlash2 のドラフトモデルをエンジン用に準備しています…",
"s9032": "DFlash2 のドラフトモデルの準備ができました。",
"s9033": "DFlash2 のドラフトモデルをダウンロードできなかったため、MTP で起動します。モデルを読み込んでいます…",
"s9034": "モデルを読み込んでいます…",
"s9035": "次のモデルファイルが見つかりません: {0}。",
"s9036": "もう一度「ダウンロードして読み込む」を押すと取得します。すでにあるファイルはそのまま残ります。同じファイルがまた表示される場合は、詳細を開き、助けを求めるときに共有してください。",
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。"
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "프로세서와 메모리만 사용합니다.",
"s8644": "전문가용: 엔진은 카드 {0}에서 시작합니다 (메인이 마지막).",
"s8645": "추천 설정을 유지하는 동안은 저장되지 않습니다. 카드를 추가하거나 빼면 Strata가 다시 고릅니다.",
"s8646": "사용 안 함: {0} (이 엔진이 다루는 것과 다른 제조사의 그래픽 카드입니다)."
"s8646": "사용 안 함: {0} (이 엔진이 다루는 것과 다른 제조사의 그래픽 카드입니다).",
"s9001": "이 메시지는 약 {0}토큰이고, 이 모델은 한 번에 약 {1}토큰까지 읽을 수 있습니다. 그대로 보내기에는 너무 큽니다.",
"s9002": "파일로 첨부해 어시스턴트가 나눠 읽게 하거나, 줄인 미리보기를 보내세요.",
"s9003": "이 대화는 더 이상 모델의 창에 맞지 않으며, 오래된 메시지를 요약해도 충분한 공간이 나오지 않았습니다.",
"s9004": "지금 요약하고 계속하거나, 새 채팅을 시작하세요.",
"s9005": "이전 메시지를 요약하는 중…",
"s9006": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼던 것이 지금은 {1}%입니다.",
"s9007": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼습니다.",
"s9008": "공간을 만들기 위해 오래된 메시지를 요약했습니다.",
"s9009": "이 채팅은 {0}% 찼지만 요약할 오래된 메시지가 없습니다. 가장 최근 메시지가 공간을 차지하고 있습니다. 새 채팅을 시작하거나 더 짧은 메시지를 보내세요.",
"s9010": "아직 요약할 오래된 메시지가 없습니다. 이 채팅의 내용은 어시스턴트가 아직 모두 필요로 합니다.",
"s9011": "아직 요약할 내용이 없습니다. 이 채팅은 아직 어시스턴트와 대화를 시작하지 않았습니다.",
"s9012": "지금은 오래된 메시지를 요약할 수 없었습니다. 잠시 후 다시 시도하거나 새 채팅을 시작하세요.",
"s9013": "이 채팅은 아직 작업 중입니다. 중지하거나 끝날 때까지 기다린 뒤 다시 시도하세요.",
"s9014": "파일로 첨부",
"s9015": "줄인 미리보기 보내기",
"s9016": "요약하고 계속하기",
"s9017": "새 채팅 시작",
"s9018": "메시지를 준비하지 못했습니다. 더 짧은 메시지를 시도해 보세요.",
"s9019": "지난 Strata 세션이 아직 종료되는 중입니다.",
"s9020": "잠시 기다렸다가 다시 시도하세요. 계속 반복되면 컴퓨터를 다시 시작하세요.",
"s9021": "초안 예측",
"s9022": "자동(권장)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "초안 예측: MTP(DFlash2가 들어가지 않았습니다)",
"s9026": "초안 예측: MTP(이 모드에서는 DFlash2를 쓸 수 없습니다)",
"s9027": "초안 예측: MTP(DFlash2는 Qwen3.8-Flash-Next 전용입니다)",
"s9028": "초안 예측: MTP(DFlash2 초안 모델이 아직 내려받아지지 않았습니다)",
"s9029": "DFlash2를 고르면 초안 모델도 한 번 내려받습니다(약 {0}GB). 그래픽 메모리에 들어가지 않으면 모델은 대신 MTP를 씁니다.",
"s9030": "DFlash2 초안 모델을 내려받는 중…",
"s9031": "엔진용 DFlash2 초안 모델을 준비하는 중…",
"s9032": "DFlash2 초안 모델이 준비되었습니다.",
"s9033": "DFlash2 초안 모델을 내려받지 못해 모델을 MTP로 시작합니다. 모델을 불러오는 중…",
"s9034": "모델을 불러오는 중…",
"s9035": "다음 모델 파일이 없습니다: {0}.",
"s9036": "'다운로드 및 불러오기'를 다시 누르면 가져옵니다. 이미 있는 파일은 그대로 둡니다. 같은 파일이 다시 표시되면 자세히 보기를 열어 도움을 요청할 때 함께 알려 주세요.",
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Apenas o processador e a memória são usados.",
"s8644": "Para especialistas: o motor inicia nas placas {0} (a principal por último).",
"s8645": "Não é guardado enquanto mantiver a recomendação: o Strata escolhe de novo se adicionar ou remover uma placa.",
"s8646": "Não usada: {0} (outra marca de placa de vídeo diferente da que este motor controla)."
"s8646": "Não usada: {0} (outra marca de placa de vídeo diferente da que este motor controla).",
"s9001": "Esta mensagem tem cerca de {0} tokens e este modelo consegue ler cerca de {1} de cada vez. É grande demais para ser enviada assim.",
"s9002": "Anexe-a como arquivo para o assistente ler por partes, ou envie uma prévia reduzida.",
"s9003": "Esta conversa já não cabe na janela do modelo, e resumir as mensagens mais antigas não liberou espaço suficiente.",
"s9004": "Resuma agora e continue, ou comece um novo chat.",
"s9005": "Resumindo as mensagens anteriores…",
"s9006": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio e agora está {1}% cheio.",
"s9007": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio.",
"s9008": "As mensagens antigas foram resumidas para liberar espaço.",
"s9009": "Este chat está {0}% cheio, mas não há nada antigo para resumir: a mensagem mais recente é o que o enche. Comece um novo chat ou envie uma mensagem mais curta.",
"s9010": "Ainda não há nada antigo para resumir. O assistente ainda precisa de tudo o que está neste chat.",
"s9011": "Ainda não há nada para resumir. Este chat ainda não iniciou uma conversa com o assistente.",
"s9012": "Não foi possível resumir as mensagens antigas agora. Tente novamente em instantes ou comece um novo chat.",
"s9013": "Este chat ainda está trabalhando. Pare-o ou espere terminar e tente novamente.",
"s9014": "Anexar como arquivo",
"s9015": "Enviar uma prévia reduzida",
"s9016": "Resumir e continuar",
"s9017": "Começar um novo chat",
"s9018": "Não foi possível preparar a mensagem. Tente uma mensagem mais curta.",
"s9019": "A última sessão do Strata ainda está sendo encerrada.",
"s9020": "Aguarde um momento e tente novamente. Se continuar acontecendo, reinicie o computador.",
"s9021": "Previsão",
"s9022": "Automático (recomendado)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Previsão: MTP (o DFlash2 não coube)",
"s9026": "Previsão: MTP (o DFlash2 não está disponível neste modo)",
"s9027": "Previsão: MTP (o DFlash2 é só para o Qwen3.8-Flash-Next)",
"s9028": "Previsão: MTP (o preditor do DFlash2 ainda não foi baixado)",
"s9029": "Escolher o DFlash2 também baixa o preditor dele: cerca de {0} GB, uma vez. Se não couber na memória gráfica, o modelo usa o MTP.",
"s9030": "Baixando o preditor do DFlash2…",
"s9031": "Preparando o preditor do DFlash2 para o motor…",
"s9032": "O preditor do DFlash2 está pronto.",
"s9033": "Não foi possível baixar o preditor do DFlash2, então o modelo inicia com o MTP. Carregando o modelo…",
"s9034": "Carregando o modelo…",
"s9035": "Estes arquivos do modelo estão faltando: {0}.",
"s9036": "Pressione Baixar e carregar de novo para buscá-los. Os arquivos que já estão aí são mantidos. Se os mesmos arquivos aparecerem de novo, abra os Detalhes e compartilhe-os ao pedir ajuda.",
"s9037": "Esta atualização foi interrompida quando o aplicativo reiniciou. Uma atualização posterior terminou, então não há nada a fazer."
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "Используются только процессор и память.",
"s8644": "Для опытных: движок запускается на картах {0} (основная — последняя).",
"s8645": "Не сохраняется, пока вы оставляете рекомендацию: Strata выберет заново, если вы добавите или уберёте карту.",
"s8646": "Не используется: {0} (другой производитель видеокарт, чем тот, с которым работает этот движок)."
"s8646": "Не используется: {0} (другой производитель видеокарт, чем тот, с которым работает этот движок).",
"s9001": "Это сообщение состоит примерно из {0} токенов, а эта модель за раз читает примерно {1}. Оно слишком большое, чтобы отправить его как есть.",
"s9002": "Прикрепите его как файл, чтобы ассистент прочитал его по частям, или отправьте сокращённый предпросмотр.",
"s9003": "Этот разговор больше не помещается в окно модели, а краткое изложение старых сообщений не освободило достаточно места.",
"s9004": "Сжать его сейчас и продолжить или начать новый чат.",
"s9005": "Кратко пересказываю предыдущие сообщения…",
"s9006": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%, а теперь на {1}%.",
"s9007": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%.",
"s9008": "Старые сообщения были кратко пересказаны, чтобы освободить место.",
"s9009": "Этот чат заполнен на {0}%, но пересказывать старые сообщения нечего: место занимает самое новое сообщение. Начните новый чат или отправьте сообщение покороче.",
"s9010": "Пока нечего пересказывать. Всё, что есть в этом чате, ассистенту ещё нужно.",
"s9011": "Пока нечего пересказывать. В этом чате ещё не начался разговор с ассистентом.",
"s9012": "Старые сообщения сейчас не удалось кратко пересказать. Попробуйте ещё раз через минуту или начните новый чат.",
"s9013": "Этот чат ещё работает. Остановите его или дождитесь окончания и попробуйте снова.",
"s9014": "Прикрепить как файл",
"s9015": "Отправить сокращённый предпросмотр",
"s9016": "Сжать и продолжить",
"s9017": "Начать новый чат",
"s9018": "Не удалось подготовить сообщение. Попробуйте сообщение покороче.",
"s9019": "Прошлый сеанс Strata ещё завершается.",
"s9020": "Подождите немного и попробуйте снова. Если это повторяется, перезагрузите компьютер.",
"s9021": "Предсказание",
"s9022": "Авто (рекомендуется)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Предсказание: MTP (DFlash2 не поместился)",
"s9026": "Предсказание: MTP (DFlash2 недоступен в этом режиме)",
"s9027": "Предсказание: MTP (DFlash2 только для Qwen3.8-Flash-Next)",
"s9028": "Предсказание: MTP (черновая модель DFlash2 ещё не скачана)",
"s9029": "Если выбрать DFlash2, будет также скачана его черновая модель: около {0} ГБ, один раз. Если она не поместится в видеопамять, модель будет работать с MTP.",
"s9030": "Скачивается черновая модель DFlash2…",
"s9031": "Черновая модель DFlash2 готовится для движка…",
"s9032": "Черновая модель DFlash2 готова.",
"s9033": "Не удалось скачать черновую модель DFlash2, поэтому модель запускается с MTP. Загружается модель…",
"s9034": "Загружается модель…",
"s9035": "Не хватает этих файлов модели: {0}.",
"s9036": "Нажмите «Скачать и загрузить» ещё раз, чтобы получить их. Уже имеющиеся файлы сохраняются. Если те же файлы снова появятся в списке, откройте «Подробности» и приложите их, когда будете просить о помощи.",
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Verwende einen einfachen Servernamen mit höchstens 32 Zeichen: Buchstaben, Zahlen, Bindestriche oder Unterstriche.",
"tl02": "Diese Tool-Server wurden weggelassen, weil ihre Namen nicht erlaubt sind: {0}. Ein Name darf bis zu 32 Buchstaben, Zahlen, Bindestriche oder Unterstriche enthalten.",
"tl03": "Diese Tool-Server wurden weggelassen, weil sie kein Programm und keine Webadresse haben: {0}.",
"tl04": "Diese Tool-Server wurden weggelassen, weil ein anderer fast den gleichen Namen hat: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.",
"tl02": "These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.",
"tl03": "These tool servers were left out because they have no program or web address: {0}.",
"tl04": "These tool servers were left out because another one has almost the same name: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Usa un nombre de servidor sencillo de hasta 32 caracteres: letras, números, guiones o guiones bajos.",
"tl02": "Estos servidores de herramientas se han omitido porque sus nombres no están permitidos: {0}. Un nombre puede tener hasta 32 letras, números, guiones o guiones bajos.",
"tl03": "Estos servidores de herramientas se han omitido porque no tienen programa ni dirección web: {0}.",
"tl04": "Estos servidores de herramientas se han omitido porque otro tiene casi el mismo nombre: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "サーバー名は32文字以内で、英字、数字、ハイフン、アンダースコアを使ってください。",
"tl02": "名前が使えないため、次のツールサーバーは除外されました: {0}。名前には、英字、数字、ハイフン、アンダースコアを32文字まで使えます。",
"tl03": "プログラムまたはWebアドレスがないため、次のツールサーバーは除外されました: {0}。",
"tl04": "ほぼ同じ名前の別のサーバーがあるため、次のツールサーバーは除外されました: {0}。"
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "32자 이내의 간단한 서버 이름을 사용하세요. 영문자, 숫자, 하이픈, 밑줄을 쓸 수 있습니다.",
"tl02": "이름을 사용할 수 없어 다음 도구 서버를 제외했습니다: {0}. 이름은 영문자, 숫자, 하이픈, 밑줄로 최대 32자까지 지을 수 있습니다.",
"tl03": "프로그램이나 웹 주소가 없어 다음 도구 서버를 제외했습니다: {0}.",
"tl04": "이름이 거의 같은 다른 서버가 있어 다음 도구 서버를 제외했습니다: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Use um nome de servidor simples com até 32 caracteres: letras, números, hífens ou sublinhados.",
"tl02": "Estes servidores de ferramentas foram deixados de fora porque os nomes não são permitidos: {0}. Um nome pode ter até 32 letras, números, hífens ou sublinhados.",
"tl03": "Estes servidores de ferramentas foram deixados de fora porque não têm programa nem endereço web: {0}.",
"tl04": "Estes servidores de ferramentas foram deixados de fora porque outro tem quase o mesmo nome: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Используйте простое имя сервера не длиннее 32 символов: буквы, цифры, дефисы или подчёркивания.",
"tl02": "Эти серверы инструментов пропущены, потому что их имена недопустимы: {0}. Имя может содержать до 32 букв, цифр, дефисов или подчёркиваний.",
"tl03": "Эти серверы инструментов пропущены, потому что у них нет программы или веб-адреса: {0}.",
"tl04": "Эти серверы инструментов пропущены, потому что у другого почти такое же имя: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "请使用不超过 32 个字符的简单服务器名称:字母、数字、连字符或下划线。",
"tl02": "以下工具服务器已被忽略,因为名称不被允许:{0}。名称最多可包含 32 个字母、数字、连字符或下划线。",
"tl03": "以下工具服务器已被忽略,因为没有程序或网址:{0}。",
"tl04": "以下工具服务器已被忽略,因为另一个服务器的名称几乎相同:{0}。"
}
+38 -1
View File
@@ -3489,5 +3489,42 @@
"s8643": "只使用处理器和内存。",
"s8644": "专家信息:引擎在显卡 {0} 上启动(主卡在最后)。",
"s8645": "沿用推荐设置时不会保存:增减显卡后 Strata 会重新选择。",
"s8646": "未使用:{0}(与此引擎驱动的显卡品牌不同)。"
"s8646": "未使用:{0}(与此引擎驱动的显卡品牌不同)。",
"s9001": "这条消息约有 {0} 个令牌,而这个模型一次大约能读 {1} 个。它太长了,不能原样发送。",
"s9002": "把它作为文件附上,让助手分段阅读,或者发送缩短的预览。",
"s9003": "这段对话已经放不进模型的窗口,总结较早的消息也没有腾出足够的空间。",
"s9004": "现在总结并继续,或者开始一个新的聊天。",
"s9005": "正在总结较早的消息…",
"s9006": "为腾出空间,较早的消息已被总结。聊天之前已用 {0}%,现在已用 {1}%。",
"s9007": "为腾出空间,较早的消息已被总结。聊天之前已用 {0}%。",
"s9008": "为腾出空间,较早的消息已被总结。",
"s9009": "这个聊天已用 {0}%,但没有更早的消息可总结:占满它的是最新的消息。请开始一个新的聊天,或发送更短的消息。",
"s9010": "还没有更早的消息可总结。这个聊天里的内容助手都还需要。",
"s9011": "还没有可总结的内容。这个聊天还没有开始和助手的对话。",
"s9012": "暂时无法总结较早的消息。请稍后再试,或开始一个新的聊天。",
"s9013": "这个聊天仍在工作。请停止它,或等它完成后再试。",
"s9014": "作为文件附上",
"s9015": "发送缩短的预览",
"s9016": "总结并继续",
"s9017": "开始新的聊天",
"s9018": "无法准备这条消息。请试试更短的消息。",
"s9019": "上一次 Strata 会话仍在关闭中。",
"s9020": "请稍等片刻再试。如果一直这样,请重启电脑。",
"s9021": "预测草稿",
"s9022": "自动(推荐)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "预测草稿:MTP(DFlash2 放不下)",
"s9026": "预测草稿:MTP(此模式下无法使用 DFlash2)",
"s9027": "预测草稿:MTP(DFlash2 仅适用于 Qwen3.8-Flash-Next)",
"s9028": "预测草稿:MTP(DFlash2 草稿模型尚未下载)",
"s9029": "选择 DFlash2 还会下载它的草稿模型:约 {0} GB,只下载一次。如果显存放不下,模型会改用 MTP。",
"s9030": "正在下载 DFlash2 草稿模型…",
"s9031": "正在为引擎准备 DFlash2 草稿模型…",
"s9032": "DFlash2 草稿模型已就绪。",
"s9033": "无法下载 DFlash2 草稿模型,因此模型将使用 MTP 启动。正在加载模型…",
"s9034": "正在加载模型…",
"s9035": "缺少这些模型文件:{0}。",
"s9036": "再按一次“下载并加载”即可获取。已有的文件会保留。如果再次列出同样的文件,请打开详情,并在求助时一并提供。",
"s9037": "此更新在应用重启时被中断。之后的更新已完成,无需任何操作。"
}
+5 -4
View File
@@ -28,6 +28,7 @@ import { DropZone, DROP_EVENT, pastedFiles } from '../components/DropZone';
import { ActivityLine, EditedFiles, FileCard, SkillsUsed } from '../components/Deliverables';
import { queueMessage, usePlan, usePlanQuiet } from '../lib/plan';
import { plainFailureText } from '../lib/plain-errors';
import { contextRecovery } from '../lib/context-recovery';
import { useLiveStatus } from '../lib/live-status';
import { askQuestion, askSub, askIcon } from '../lib/ask';
import { artifactFromUrl, type PreviewArtifact } from '../lib/artifacts';
@@ -615,7 +616,7 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
const [sentTime, setSentTime] = useState(() => relTime(m.at));
if (m.role === 'user') {
return (<div className="st-msg st-msg--user" onMouseEnter={() => setSentTime(relTime(m.at))} onFocus={() => setSentTime(relTime(m.at))}>
<div className="st-bubble">{tr(m.photos?.length ? <ChatPhotos photos={m.photos}/> : null)}{m.markdown ? <Markdown text={m.content} onOpenLink={onOpenLink}/> : m.content}</div>
<div className="st-bubble">{tr(m.photos?.length ? <ChatPhotos photos={m.photos}/> : null)}{m.markdown ? <Markdown text={m.content} onOpenLink={onOpenLink} imageSession={activeSession}/> : m.content}</div>
<div className="st-msg__meta">
<time dateTime={m.at} title={tr(new Date(m.at).toLocaleString(localeTag()))}>{tr(sentTime)}</time>
<span className="st-msg__actions">
@@ -638,9 +639,9 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
{tr(!(m.timeSkipId && timeSkipReal && !timeSkipDone) && (m.content || m.error || !m.streaming || !liveStatus) ? <div className="st-bubble">
{tr(planCard ? <PlanCard chatId={activeSession} planId={m.planId!}/> : null)}
{tr(m.error ? (<ErrorNotice message={m.error} next={tr("Try sending the message again.")}/>) : null)}
{tr(grouped && !planCard && !m.timeSkipId ? m.blocks!.map((b, i) => b.kind === 'group' ? <ActivityLine key={b.id} group={b} sessionId={activeSession ?? ''} onPreview={onPreview}/> : <Markdown key={i} text={b.text} onOpenLink={onOpenLink} onPreviewHtml={content => onPreview({ id: m.id + '-html-' + i, kind: 'website', title: 'HTML preview', content })}/>) : null)}
{tr(m.content && !planCard && !grouped ? (<Markdown text={m.timeSkipId && !timeSkipReal ? hostLive ? tr('This was a sample run made while the app could not reach its host.') : timeSkipDone ? tr('The result is ready. Switch between Original and Updated, or compare them side by side. This example uses sample outputs and simulated checks.') : tr('Revising the saved draft. Follow the result in the sidebar. This example uses sample outputs and simulated checks.') : m.content} onOpenLink={onOpenLink} onPreviewHtml={content => onPreview({ id: m.id + '-html', kind: 'website', title: 'HTML preview', content })}/>) : !m.error && m.streaming && !liveStatus ? (<span className="typing" aria-label={tr("Generating")}>
{tr(m.error ? (<ErrorNotice message={m.error} detail={m.errorDetail} {...contextRecovery(m.errorCode)}/>) : null)}
{tr(grouped && !planCard && !m.timeSkipId ? m.blocks!.map((b, i) => b.kind === 'group' ? <ActivityLine key={b.id} group={b} sessionId={activeSession ?? ''} onPreview={onPreview}/> : <Markdown key={i} text={b.text} onOpenLink={onOpenLink} imageSession={activeSession} onPreviewHtml={content => onPreview({ id: m.id + '-html-' + i, kind: 'website', title: 'HTML preview', content })}/>) : null)}
{tr(m.content && !planCard && !grouped ? (<Markdown text={m.timeSkipId && !timeSkipReal ? hostLive ? tr('This was a sample run made while the app could not reach its host.') : timeSkipDone ? tr('The result is ready. Switch between Original and Updated, or compare them side by side. This example uses sample outputs and simulated checks.') : tr('Revising the saved draft. Follow the result in the sidebar. This example uses sample outputs and simulated checks.') : m.content} onOpenLink={onOpenLink} imageSession={activeSession} onPreviewHtml={content => onPreview({ id: m.id + '-html', kind: 'website', title: 'HTML preview', content })}/>) : !m.error && m.streaming && !liveStatus ? (<span className="typing" aria-label={tr("Generating")}>
<i />
<i />
<i />
+6
View File
@@ -2467,6 +2467,12 @@ textarea.st-input {
border: 1px solid var(--st-line);
}
.md-image {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.typing {
display: inline-flex;
gap: 4px;
+1 -1
View File
@@ -36,7 +36,7 @@ test('a long chat on a small window compacts before it overflows, and the conver
for (let i = 0; i < 6; i++) {
const frames = [];
await bridge.runTurn({ chatId: 'long', message: `SCRIPT:echo round${i} ${big}`, workspacePath: ws, mode: 'bypass', onFrame: f => frames.push(f) });
replies.push(frames.filter(f => f.type === 'delta').map(f => f.text).join('').slice(0, 12));
replies.push(frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('').slice(0, 12));
assert.equal(frames.at(-1).type, 'done', `round ${i} ended normally`);
}
assert.ok(replies.every(r => /^round\d/.test(r)), 'every round was answered: ' + replies.join(' | '));
+286
View File
@@ -0,0 +1,286 @@
// Context pressure and compaction (2026-10-22 hands-on test): compaction reads the size the model server measured, a summary request
// always fits the window, and a refused prompt is recovered from. The Harness is real; the model server is the scripted worker, set to
// count a prompt the way the engine does (characters over a density) and to refuse one that leaves no room.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startScriptedWorker } from './helpers/scripted-worker.mjs';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { resolveHarness } from '../server/agent/harness-runtime.mjs';
import { buildProfile } from '../server/agent/profile.mjs';
import { clipMessage, densityOf, estimateMessage, isContextOverflow, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent } from '../server/agent/context-budget.mjs';
const HARNESS = process.env.STRATA_HARNESS_DIR;
const skip = !HARNESS || !resolveHarness({ env: process.env }).ok ? 'STRATA_HARNESS_DIR is not set to a built Harness' : false;
test('the profile loads the context plugin, so compaction and /compact read the measured prompt', () => {
const profile = buildProfile({ local: [{ id: 'a', modelId: 'm', baseUrl: 'http://x', context: 65536 }], connections: [] });
assert.match(profile.patch, /"id": "strata-context"/);
assert.match(profile.patch, /strata-context\.mjs/);
});
test('density: the engine said 59,048 tokens for a prompt the Harness priced at 31,794', () => {
const d = densityOf(59048, 31794);
assert.ok(d > 1.85 && d < 1.87, `density ${d}`);
assert.equal(densityOf(0, 100), 1);
assert.equal(densityOf(100, 0), 1);
assert.equal(densityOf(10, 100), 1, 'plain prose is never priced lower than the Harness already does');
assert.equal(densityOf(10000, 100), 6, 'a wild sample is capped');
});
test('pressure: the measured part is exact, what came after is priced at the measured density, and it never reads lower than the Harness', () => {
const sample = { real: 59048, estimated: 31794 };
assert.equal(pressureNow({ sample, now: 31794 }), 59048, 'nothing added since the sample: the measured number');
const grown = pressureNow({ sample, now: 31794 + 1000 });
assert.ok(grown > 59048 + 1800 && grown < 59048 + 1900, `1000 added tokens are worth about 1,860 real ones (${grown})`);
assert.ok(pressureNow({ sample, now: 31794 - 5000 }) < 59048, 'a compaction lowers it');
assert.equal(pressureNow({ sample: null, now: 1234 }), 1234, 'no measurement yet: the Harness price');
assert.equal(pressureNow({ sample: { real: 100, estimated: 5000 }, now: 5000 }), 5000, 'never below the Harness price');
});
test('the compaction threshold on a 64K window leaves room for the reply even when the prompt is measured in real tokens', () => {
// Gyro-S at 64K: the reply keeps 19,660 tokens and compaction keeps 5,734 of headroom (profile.mjs), so it starts at 40,142 REAL tokens.
const window = 65536, reply = 19660, headroom = 5734;
const threshold = Math.floor(Math.min(window * 0.8, window - reply - headroom));
assert.equal(threshold, 40142);
assert.ok(threshold + reply < window, 'the prompt at the threshold plus a full reply still fits');
// The 2026-10-22 chat: 31,794 priced = 59,048 real. It is over the threshold now (before: under it, and the engine refused the next request).
assert.ok(pressureNow({ sample: { real: 59048, estimated: 31794 }, now: 31794 }) > threshold);
});
test('usage helpers: prompt side only, from a message or from its stream', () => {
assert.equal(promptTokensOf({ inputTokens: 100, outputTokens: 50, cacheReadTokens: 30, cacheWriteTokens: 5 }), 135);
assert.equal(promptTokensOf(null), 0);
assert.deepEqual(usageOfEvent({ data: { usage: { inputTokens: 7 } } }), { inputTokens: 7 });
assert.deepEqual(usageOfEvent({ data: { stream: [{ type: 'chunk', chunk: { type: 'text-delta' } }, { type: 'chunk', chunk: { type: 'usage', usage: { inputTokens: 9 } } }] } }), { inputTokens: 9 });
assert.equal(usageOfEvent({ data: {} }), null);
});
test('a refusal for the context window is recognised by its code and by the engine\'s own words', () => {
assert.ok(isContextOverflow({ code: 'CONTEXT_WINDOW_EXCEEDED', message: 'x' }));
assert.ok(isContextOverflow(new Error('400: {"type":"invalid_request_error","message":"request exceeds the context window: prompt (70141 tokens) leaves no room to answer in the context (65536); requests are never truncated"}')));
assert.ok(!isContextOverflow(new Error('fetch failed')));
assert.ok(!isContextOverflow(null));
});
test('a summary request is split into pieces that fit, each message whole when it can be, a huge one cut in the middle', () => {
const text = n => ({ role: 'user', content: [{ type: 'text', text: 'x'.repeat(n) }] });
const many = Array.from({ length: 12 }, () => text(4000)); // 1,000 tokens each by the Harness price
const runs = splitMessages(many, 3500, 1);
assert.deepEqual(runs.map(r => r.length), [3, 3, 3, 3]);
assert.deepEqual(runs.flat().length, 12, 'nothing is dropped');
const dense = splitMessages(many, 3500, 2);
assert.ok(dense.length > runs.length, 'a denser text means more pieces');
const huge = clipMessage(text(400000), 8000, 1);
assert.ok(estimateMessage(huge) <= 8000, `cut to its share (${estimateMessage(huge)})`);
assert.match(huge.content[0].text, /left out of this summary request/);
assert.equal(clipMessage(text(100), 8000, 1).content[0].text.length, 100, 'a small message is untouched');
const oneBig = splitMessages([text(10), text(600000), text(10)], 6000, 1);
assert.ok(oneBig.every(r => r.reduce((n, m) => n + estimateMessage(m), 0) <= 6000), 'every piece fits after the cut');
// A tool call and its result stay together when a clean cut exists.
const call = { role: 'assistant', content: [{ type: 'tool-call', id: 'c', name: 'read', arguments: '{}' }] };
const result = { role: 'tool', toolCallId: 'c', content: [{ type: 'text', text: 'y'.repeat(2000) }] };
const paired = splitMessages([text(2000), call, result, text(2000)], 1200, 1);
assert.ok(paired.every(r => !r.includes(call) || r.includes(result)), 'the call and its result are in one piece');
assert.ok(summaryBudget({ window: 65536, summaryTokens: 5734, toolsTokens: 10133, systemTokens: 400 }) > 40000);
});
/** The scripted model server counting like the engine (3 characters a token here: logs and code) in a 32K window. */
async function rig(t, { window = 32768, charsPerToken = 3, noCount = false } = {}) {
const worker = await startScriptedWorker({ window, charsPerToken, noCount });
const dir = mkdtempSync(join(tmpdir(), 'strata-ctx-'));
const ws = join(dir, 'ws'); mkdirSync(ws, { recursive: true });
const bridge = createAgentBridge({ dataDir: dir, localInstances: () => [{ id: 'i', label: 'S', baseUrl: worker.url, modelId: 'scripted-model', context: window, slots: 2 }], connections: () => [], env: process.env, startTimeoutMs: 90_000 });
t.after(async () => { await bridge.close(); await worker.close(); try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ } });
const send = async (message) => { const frames = []; await bridge.runTurn({ chatId: 'c', message, workspacePath: ws, mode: 'bypass', onFrame: f => frames.push(f) }); return frames; };
const harnessLog = () => { try { return readFileSync(join(dir, 'agent', 'harness.log'), 'utf8'); } catch { return ''; } };
return { worker, bridge, send, ws, harnessLog };
}
const answerOf = frames => frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('');
const dense = 'q=0x1f ok;'; // 10 characters: 2.5 tokens by the Harness price, 3.3 by the model server's count here
test('the plugin loads in the real Harness (a plugin that fails to import would leave compaction as it was, silently)', { skip }, async (t) => {
const { send, harnessLog } = await rig(t);
const frames = await send('SCRIPT:echo hello');
assert.equal(frames.at(-1).type, 'done');
assert.doesNotMatch(harnessLog(), /did not activate|failed to import|strata-context/, harnessLog());
});
test('dense text compacts in time: no request is refused, and every message is answered', { skip }, async (t) => {
const { worker, send } = await rig(t);
const replies = [];
for (let i = 0; i < 8; i++) {
const frames = await send(`SCRIPT:echo round${i} ${dense.repeat(900)}`);
assert.equal(frames.at(-1).type, 'done', `round ${i} ended normally: ${JSON.stringify(frames.at(-1)).slice(0, 300)}`);
replies.push(answerOf(frames).slice(0, 12));
}
assert.ok(replies.every(r => /^round[0-9]/.test(r)), replies.join(' | '));
const refused = worker.requests.filter(r => r.refused);
assert.equal(refused.length, 0, `the model server refused ${refused.length} request(s): ${refused.map(r => r.refused).join(', ')} tokens`);
assert.ok(worker.requests.some(r => r.summary), 'the older history was summarised');
// Compaction starts before the next message would push the prompt past the threshold, so no answer was asked for from a nearly full window.
const peak = Math.max(...worker.requests.filter(r => !r.summary).map(r => Math.round(r.promptChars / 3)));
assert.ok(peak < 32768 - 4000, `no prompt came within 4,000 tokens of the window (peak ${peak})`);
});
test('a chat the model server refused once is not stuck: the next message compacts (the summary itself fits the window) and goes through', { skip }, async (t) => {
// (An engine that cannot count a message before it is sent: the check lets it through, and this is the recovery that remains.)
// 2026-10-22: a 103 KB message made a 70K-token prompt, the 400 left it in the history, and then neither the next message nor /compact
// could go through, because the summary request carried the same history and was refused for its size too.
const { worker, send } = await rig(t, { noCount: true });
assert.equal((await send(`SCRIPT:echo one ${dense.repeat(300)}`)).at(-1).type, 'done');
const huge = await send(`SCRIPT:echo two ${dense.repeat(7000)}`);
assert.equal(huge.at(-1).type, 'done', 'the Harness compacted after the refusal and asked again: ' + JSON.stringify(huge.at(-1)).slice(0, 200));
const small = await send('SCRIPT:echo three please');
assert.equal(small.at(-1).type, 'done', JSON.stringify(small.at(-1)).slice(0, 300));
assert.match(answerOf(small), /three/);
const again = await send('SCRIPT:echo four please');
assert.equal(again.at(-1).type, 'done');
const refused = worker.requests.filter(r => r.refused);
assert.equal(refused.length, 1, `only the first, too large, prompt was refused: ${refused.map(r => r.refused).join(', ')}`);
assert.ok(worker.requests.filter(r => r.summary).every(r => Math.round(r.promptChars / 3) < 32768 - 64), 'every summary request fitted the window');
});
// ------------------------------------------------------------------------------------------------ the message is checked before it is sent
import { COUNT_ABOVE_CHARS, FIT_TEXT, countTokens, decideFit, fill, oversizedReplacement } from '../server/agent/context-fit.mjs';
import { TurnProjector, friendlyFailure, isContextFailure } from '../server/agent/events.mjs';
test('fit: a message that fits is sent, one that fits after a summary is summarised first, one that never fits is not sent', () => {
// Gyro-S at 64K: 65,536 window, a new chat already holds about 12,000 (system prompt and tools), the reply needs 4,096.
assert.equal(decideFit({ window: 65536, pressure: 12000, messageTokens: 5000 }).action, 'send');
assert.equal(decideFit({ window: 65536, pressure: 30000, messageTokens: 25000 }).action, 'send', 'it fits with the reply room');
assert.equal(decideFit({ window: 65536, pressure: 59048, messageTokens: 5000 }).action, 'summarize', 'the chat is full but the message alone is small');
const huge = decideFit({ window: 65536, pressure: 0, messageTokens: 70141 });
assert.equal(huge.action, 'too-large', 'the 103 KB message of 2026-10-22 (70,141 tokens) can never fit a 65,536 window');
assert.equal(decideFit({ window: 65536, pressure: 0, messageTokens: 49000 }).action, 'send');
assert.equal(decideFit({ window: 65536, pressure: 20000, messageTokens: 49000 }).action, 'summarize');
assert.equal(decideFit({ window: 65536, pressure: 20000, messageTokens: 50000 }).action, 'too-large', 'with 12,000 for the empty chat and 4,096 for the reply, 49,440 is the most');
assert.equal(decideFit({ window: 0, messageTokens: 99999 }).action, 'send', 'no window known: nothing is blocked');
assert.equal(decideFit({ window: 8192, pressure: 0, messageTokens: 5000 }).action, 'too-large', 'a small window has less room for a message');
});
test('fit: a big message on a chat already near the compaction threshold summarises first, so the window is never come close to', () => {
// 64K window: the Harness starts summarising at 40,142 (profile.mjs), but looks only before its NEXT step; a message that arrives now is not in that count.
const at = { window: 65536, threshold: 40142, messageTokens: 14400 };
const near = decideFit({ ...at, pressure: 39840 });
assert.equal(near.action, 'summarize'); assert.equal(near.why, 'threshold');
assert.equal(decideFit({ ...at, pressure: 15000 }).action, 'send', 'still under the threshold with the message on top');
assert.equal(decideFit({ ...at, pressure: 13000, messageTokens: 30000 }).action, 'send', 'a chat that holds little beyond its first prompt has nothing worth summarising');
assert.equal(decideFit({ window: 65536, messageTokens: 14400, pressure: 39840 }).action, 'send', 'no threshold given: only the window decides');
assert.equal(decideFit({ ...at, pressure: 59048, messageTokens: 5000 }).why, undefined, 'a refusal-sized case is the old rule, not the threshold');
});
test('count: the engine\'s own number, or nothing when it cannot be had (then nothing is blocked)', async () => {
const sent = [];
const ok = async (url, init) => { sent.push([url, JSON.parse(init.body), init.headers]); return { ok: true, json: async () => ({ input_tokens: 70141 }) }; };
assert.equal(await countTokens({ baseUrl: 'http://127.0.0.1:18500/v1', key: 'k', model: 'm', text: 'hello', fetchImpl: ok }), 70141);
assert.equal(sent[0][0], 'http://127.0.0.1:18500/v1/messages/count_tokens');
assert.deepEqual(sent[0][1].messages, [{ role: 'user', content: 'hello' }]);
assert.equal(sent[0][2].authorization, 'Bearer k');
assert.equal(await countTokens({ baseUrl: 'http://x/v1', text: 'a', fetchImpl: async () => ({ ok: false }) }), null);
assert.equal(await countTokens({ baseUrl: 'http://x/v1', text: 'a', fetchImpl: async () => { throw new Error('refused'); } }), null);
assert.equal(await countTokens({ baseUrl: 'http://x/v1', text: 'a', fetchImpl: async () => ({ ok: true, json: async () => ({}) }) }), null);
assert.ok(COUNT_ABOVE_CHARS >= 1000);
});
test('the two ways out for a message that is too large: a file the assistant reads in parts, or a shortened preview', () => {
const text = 'q=0x1f ok;'.repeat(11000); // 110,000 characters
const file = oversizedReplacement({ text, mode: 'file', tokens: 36000, window: 32768, path: 'C:\\work\\pasted-message.txt' });
assert.match(file.message, /saved it as a file: C:\\work\\pasted-message\.txt/);
assert.ok(file.message.length < 2000, 'the file message is short');
const preview = oversizedReplacement({ text, mode: 'preview', tokens: 36000, window: 32768, pressure: 12000 });
assert.ok(preview.message.length < text.length / 2, 'cut to what the window can hold');
assert.match(preview.message, /characters from the middle are left out/);
assert.ok(preview.message.startsWith('q=0x1f ok;') && preview.message.endsWith('q=0x1f ok;'), 'the start and the end are kept');
const small = oversizedReplacement({ text: 'short', mode: 'preview', tokens: 5, window: 32768 });
assert.equal(small.message, 'short');
assert.equal(fill(FIT_TEXT.tooLarge, ['70,141', '65,536']), 'This message is about 70,141 tokens long, and this model can read about 65,536 at a time. It is too large to send as it is.');
});
test('a context failure is told in plain words, with the engine\'s text kept for Details and a code the page acts on', () => {
const raw = '400: {"type":"invalid_request_error","message":"request exceeds the context window: prompt (85340 tokens) leaves no room to answer in the context (65536); requests are never truncated"}';
assert.ok(isContextFailure({ message: raw }));
assert.ok(isContextFailure({ code: 'CONTEXT_WINDOW_EXCEEDED', message: 'x' }));
assert.ok(!isContextFailure({ message: 'fetch failed' }));
const said = friendlyFailure({ message: raw });
assert.equal(said, FIT_TEXT.full);
assert.doesNotMatch(said, /\{|invalid_request|400/, 'no raw JSON in what the person reads');
const projector = new TurnProjector();
projector.consume({ type: 'event', event: { type: 'turn/end', data: { reason: { kind: 'error', error: { message: raw } } } } });
const [frame] = projector.finish({ model: 'm', contextMax: 65536 });
assert.equal(frame.type, 'error');
assert.equal(frame.code, 'CONTEXT_WINDOW_EXCEEDED');
assert.equal(frame.message, FIT_TEXT.full);
assert.equal(frame.details, raw);
});
test('compaction shows in the chat: a status line while it runs and one plain line when it is done, with how full the chat was', () => {
const projector = new TurnProjector({ window: 65536 });
const statuses = [];
projector.onStatus = u => statuses.push(u);
const usage = { type: 'assistant-stream', frame: { type: 'chunk', attemptId: 'a', chunk: { type: 'usage', usage: { inputTokens: 58000, outputTokens: 1048 } } } };
const live = projector.consume(usage);
assert.deepEqual(live.find(f => f.type === 'context'), { type: 'context', contextTokens: 59048, contextMax: 65536 }, 'the page is told what the context holds after every request');
assert.deepEqual(projector.consume({ type: 'event', event: { type: 'compaction/start', data: { compactionId: 'c' } } }), []);
assert.equal(statuses.at(-1).code, 'compacting');
assert.equal(statuses.at(-1).text, FIT_TEXT.summarizing);
const done = projector.consume({ type: 'event', event: { type: 'compaction/end', data: { compactionId: 'c' } } });
assert.equal(done.length, 1);
assert.equal(done[0].key, FIT_TEXT.summarizedWas);
assert.deepEqual(done[0].values, [90]);
assert.match(done[0].text, /Older messages were summarized to make room\. The chat was 90% full\./);
assert.equal(done[0].own, true);
// A summary that failed says nothing here (the turn's own error does), and an end with no start (an older log) says nothing.
const failed = new TurnProjector({ window: 65536 });
failed.consume({ type: 'event', event: { type: 'compaction/start', data: {} } });
assert.deepEqual(failed.consume({ type: 'event', event: { type: 'compaction/end', data: { error: 'x' } } }), []);
assert.deepEqual(new TurnProjector().consume({ type: 'event', event: { type: 'compaction/end', data: {} } }), []);
});
test('a message too large for the window is not sent: the person is told plainly and nothing reaches the model', { skip }, async (t) => {
const { worker, send } = await rig(t);
const frames = await send(`SCRIPT:echo big ${dense.repeat(11000)}`); // 110,000 characters: about 36,000 tokens in a 32,768 window
const last = frames.at(-1);
assert.equal(last.type, 'error');
assert.equal(last.code, 'CONTEXT_MESSAGE_TOO_LARGE');
assert.match(last.message, /^This message is about 3\d,\d{3} tokens long, and this model can read about 32,768 at a time/);
assert.equal(last.key, FIT_TEXT.tooLarge);
assert.equal(worker.requests.length, 0, 'no prompt was sent to the model');
assert.equal(worker.counts.length, 1, 'the model server counted it once');
});
test('a message that fits only after a summary is sent after one, and the chat says so', { skip }, async (t) => {
const { worker, send } = await rig(t);
for (let i = 0; i < 2; i++) assert.equal((await send(`SCRIPT:echo part${i} ${dense.repeat(900)}`)).at(-1).type, 'done');
const before = worker.requests.length;
const frames = await send(`SCRIPT:echo then ${dense.repeat(4000)}`); // 40,000 characters: about 13,000 tokens on top of about 18,000
assert.equal(frames.at(-1).type, 'done', JSON.stringify(frames.at(-1)).slice(0, 300));
const note = frames.find(f => f.type === 'delta' && f.noteReason === 'compacted');
assert.ok(note, 'a line says the older messages were summarized');
assert.match(note.text, /summarized to make room/);
const later = worker.requests.slice(before);
const firstAnswer = later.findIndex(r => !r.summary);
assert.ok(later.slice(0, firstAnswer).some(r => r.summary), 'the summary was asked for before the message was sent');
assert.equal(worker.requests.filter(r => r.refused).length, 0, 'nothing was refused');
});
test('/compact summarises the older messages with the real figures, and says so plainly when there is nothing to summarise', { skip }, async (t) => {
const { worker, bridge, ws } = await rig(t);
const run = async (line) => { const frames = []; await bridge.compactCommand({ chatId: 'c', message: line, workspacePath: ws, mode: 'bypass', choice: null, onFrame: f => frames.push(f) }); return frames; };
const fresh = await run('/compact');
assert.equal(fresh.find(f => f.type === 'delta').key, FIT_TEXT.noChatYet, 'a chat that has not started has nothing to summarise');
const frames = [];
for (let i = 0; i < 3; i++) { await bridge.runTurn({ chatId: 'c', message: `SCRIPT:echo r${i} ${dense.repeat(700)}`, workspacePath: ws, mode: 'bypass', onFrame: f => frames.push(f) }); }
const done = await run('/compact');
const said = done.find(f => f.type === 'delta');
assert.equal(said.key, FIT_TEXT.summarized, 'the figures are the measured ones: ' + said.text);
const [was, now] = said.values;
assert.ok(was > now && now > 0 && was <= 100, `the chat was ${was}% full and is now ${now}%`);
assert.equal(done.at(-1).type, 'done');
assert.ok(worker.requests.some(r => r.summary));
const again = await run('/compact');
assert.ok([FIT_TEXT.nothingToSummarize, FIT_TEXT.nothingFull, FIT_TEXT.summarized].includes(again.find(f => f.type === 'delta').key), 'a second /compact is answered in plain words');
});
+1 -1
View File
@@ -60,7 +60,7 @@ test('routeFor picks the chat\'s own choice', () => {
test('the agent environment keeps what a shell needs and drops ambient credentials', () => {
const env = agentEnvironment({ PATH: '/bin', HOME: '/h', OPENAI_API_KEY: 'x', GITHUB_TOKEN: 'y', AWS_SECRET_ACCESS_KEY: 'z', SSH_AUTH_SOCK: 's', SystemRoot: 'C:\\Windows' }, { STRATA_API_KEY_X: 'k' });
assert.deepEqual(Object.keys(env).sort(), ['DSH_PERMISSION_MODE', 'DSH_TELEMETRY_DISABLED', 'HOME', 'PATH', 'STRATA_API_KEY_X', 'SystemRoot']);
assert.deepEqual(Object.keys(env).sort(), ['DSH_PERMISSION_MODE', 'DSH_TELEMETRY_DISABLED', 'HOME', 'NODE_USE_SYSTEM_CA', 'PATH', 'STRATA_API_KEY_X', 'SystemRoot']);
});
test('redact removes secret values and launch tokens from log text', () => {
+37 -1
View File
@@ -15,7 +15,7 @@ import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { resolveHarness } from '../server/agent/harness-runtime.mjs';
import { buildProfile, GATE_PLUGIN } from '../server/agent/profile.mjs';
import { STANDARD_PLUGIN_IDS, REMOVED_PLUGIN_IDS, standardPreset } from '../server/agent/preset.mjs';
import { isReadOnlyShell, manualDecision, READ_ONLY_TOOLS } from '../server/agent/plugins/strata-gate.mjs';
import { isReadOnlyShell, manualDecision, maskQuotes, READ_ONLY_TOOLS } from '../server/agent/plugins/strata-gate.mjs';
import { plainToolError, withPlain } from '../server/agent/events.mjs';
const HARNESS = process.env.STRATA_HARNESS_DIR;
@@ -35,6 +35,42 @@ test('the gate: only commands that provably just look are allowed without a ques
assert.equal(isReadOnlyShell('x'.repeat(700)), false, 'a long command is never trusted');
});
test('the gate: quoted text is data, so an & ( ) ; { } in a quoted path does not make a look-only command ask (2026-10-22)', () => {
for (const ok of [
'Get-ChildItem "C:\\Users\\AI-Server\\Downloads\\GUI Q&A" | Select-Object -First 5 Name',
'Get-Content "C:\\Users\\AI-Server\\Downloads\\GUI Q&A\\a.txt"',
"Get-ChildItem 'C:\\Users\\x\\Notes (old) {draft}; v2'",
'Select-String -Path a.txt "a|b"',
'Select-String needle "C:\\x\\a & b.txt" | Measure-Object',
'git log --oneline "docs/Q&A.md"',
'cat "my file (1).txt"',
"Get-ChildItem 'it''s here'",
]) assert.equal(isReadOnlyShell(ok), true, ok);
// Quoting does not hide anything that acts: outside the quotes the same characters still ask, and inside double quotes an expansion does.
for (const bad of [
'Get-ChildItem C:\\Q&A',
'Get-ChildItem "C:\\a" ; Remove-Item x',
'Get-ChildItem "C:\\a" & calc',
'Get-ChildItem "C:\\a" > out.txt',
'Get-ChildItem "$env:USERPROFILE"',
'Get-ChildItem "$(whoami)"',
'Get-ChildItem "a`b"',
"Get-ChildItem 'unterminated",
'Get-ChildItem "unterminated',
'git diff "--output=out.txt"',
'git diff --out"put"=out.txt',
'git diff --"output"=out.txt',
'Get-Content "a"b',
'"Remove-Item" x',
'Set-Content "a.txt" "x"',
'Get-ChildItem "a" | Remove-Item',
'curl "http://x"',
]) assert.equal(isReadOnlyShell(bad), false, bad);
assert.equal(maskQuotes('Get-ChildItem "C:\\Q&A" | Select-Object Name'), 'Get-ChildItem "_" | Select-Object Name');
assert.equal(maskQuotes("echo 'a' \"b\""), 'echo "_" "_"');
assert.equal(maskQuotes('echo "$HOME"'), null);
});
test('the gate: every tool outside the read-only list asks, including unknown and connected ones', () => {
for (const name of READ_ONLY_TOOLS) assert.equal(manualDecision({ name, arguments: {} }), null, name);
for (const name of ['write', 'edit', 'job_kill', 'mcp__github__create_issue', 'something_new']) assert.equal(manualDecision({ name, arguments: {} })?.kind, 'ask', name);
+16
View File
@@ -26,3 +26,19 @@ test('a silent model ends the turn with a plain message, and the chat works agai
await bridge.runTurn({ chatId: 'w1', message: 'SCRIPT:echo fine', workspacePath: ws, mode: 'bypass', onFrame: f => again.push(f) });
assert.match(again.filter(f => f.type === 'delta').map(f => f.text).join(''), /fine/);
});
test('engine progress keeps a long prompt read alive past the stall limit, then the normal limit applies (#3623)', { skip }, async (t) => {
const worker = await startScriptedWorker({});
const dir = mkdtempSync(join(tmpdir(), 'strata-wd-')); const ws = join(dir, 'ws'); mkdirSync(ws, { recursive: true });
const started = Date.now();
const bridge = createAgentBridge({ dataDir: dir, localInstances: () => [{ id: 'i', label: 'S', baseUrl: worker.url, modelId: 'scripted-model', context: 32768, slots: 2 }], connections: () => [], env: process.env, startTimeoutMs: 90_000, stallMs: 2500, stallToolMs: 6000, engineGraceMs: 60_000,
// The engine moves for 7 s (a prompt being read), then goes quiet.
engineProgress: () => (Date.now() - started < 7_000 ? `reading:${Date.now()}` : null) });
t.after(async () => { await bridge.close(); await worker.close(); try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ } });
const out = [];
const t0 = Date.now();
await bridge.runTurn({ chatId: 'w2', message: 'SCRIPT:hang', workspacePath: ws, mode: 'bypass', onFrame: f => out.push(f) });
assert.ok(Date.now() - started > 7_000, 'the 2.5 s stall limit did not fire while the engine was reading');
assert.ok(Date.now() - t0 < 40_000, 'and it still ended by itself once the engine went quiet');
assert.match(out.find(f => f.type === 'error').message, /stopped answering/);
});
+12
View File
@@ -173,3 +173,15 @@ test('an AMD build on an NVIDIA PC is not unpacked and the sentence names the ri
test('wrongMakeNotice names the make, not a list of reasons', () => {
assert.equal(wrongMakeNotice({ backend: 'cuda', archs: ['sm_86'] }, 'hip'), 'This Strata is for NVIDIA cards. Your PC has an AMD card: download the AMD version from https://github.com/Niko1221/Strata/releases');
});
test('Windows: the display adapters decide the make, and a leftover nvidia-smi does not outvote them (#3688)', async () => {
const { gpuBackendHere, windowsDisplayVendors } = await import('../server/bundled-engine.mjs');
assert.equal(gpuBackendHere({}, { vendors: new Set(['1002']) }), 'hip');
assert.equal(gpuBackendHere({}, { vendors: new Set(['10de', '1002']) }), 'cuda');
assert.equal(gpuBackendHere({}, { vendors: new Set(['8086']) }), 'sycl');
assert.equal(gpuBackendHere({ STRATA_ENGINE_BACKEND: 'cuda' }, { vendors: new Set(['1002']) }), 'cuda', 'an explicit choice wins');
const out = 'MatchingDeviceId REG_SZ PCI/VEN_1002&DEV_7551&SUBSYS_0' + String.fromCharCode(10) + 'MatchingDeviceId REG_SZ PCI/VEN_8086&DEV_46A6';
assert.deepEqual([...windowsDisplayVendors({ platform: 'win32', run: () => out })].sort(), ['1002', '8086']);
assert.equal(windowsDisplayVendors({ platform: 'linux', run: () => { throw new Error('must not run'); } }).size, 0);
assert.equal(windowsDisplayVendors({ platform: 'win32', run: () => { throw new Error('no registry'); } }).size, 0);
});
@@ -122,6 +122,11 @@ test('the Settings update check maps the feed to the existing result shape', asy
assert.equal(result.engine.status, 'unavailable');
assert.doesNotMatch(result.engine.message, /windows-x86_64/);
assert.match(result.engine.details, /windows-x86_64/, 'the technical reason is kept for the Details disclosure');
result = await checkComponentUpdates({ control: make(report({ engine: { installed: { version: '0.1.46', backend: 'hip' }, offered: null, status: 'wrong-make', note: 'The engine installed here is for AMD cards, but this update would give your PC NVIDIA one. Nothing was changed.', reasons: ['x'] } })) });
assert.equal(result.engine.status, 'unavailable');
assert.equal(result.engine.canInstall, false);
assert.match(result.engine.message, /Nothing was changed/, 'the app says why in plain words, not that no engine exists for the card');
assert.doesNotMatch(result.engine.message, /isn't an engine for this PC/);
result = await checkComponentUpdates({ control: make(new Error('this build trusts no update key')) });
assert.equal(result.strata.status, 'error');
assert.equal(result.engine.status, 'error');
+119
View File
@@ -0,0 +1,119 @@
// The page side of the context fixes (2026-10-22 hands-on test): what the error card offers, what the context meter reads while a turn runs,
// and that every sentence the host or the page says about the context is in the catalog in all eight languages.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
import { FIT_TEXT } from '../server/agent/context-fit.mjs';
const here = rel => fileURLToPath(new URL(rel, import.meta.url));
const read = rel => readFileSync(here(rel), 'utf8').replace(/\r\n/g, '\n');
globalThis.window ??= globalThis; globalThis.localStorage ??= { getItem: () => null, setItem: () => {} };
globalThis.navigator ??= { language: 'en' };
async function load(file, stubs = {}) {
const result = await build({
entryPoints: [here(`../src/lib/${file}`)], bundle: true, format: 'esm', platform: 'node', write: false, logLevel: 'silent', loader: { '.json': 'json' },
plugins: [{ name: 'stubs', setup(b) { b.onResolve({ filter: /^\.\/(store)$/ }, () => ({ path: 'store', namespace: 'stub' })); b.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ contents: stubs.store, loader: 'js' })); } }],
});
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`);
}
const calls = [];
globalThis.__calls = calls;
const recovery = await load('context-recovery.ts', {
store: `export const sendOversized = (mode) => globalThis.__calls.push(['oversized', mode]);
export const summarizeAndContinue = () => globalThis.__calls.push(['summarize']);
export const startNewChat = () => globalThis.__calls.push(['new-chat']);`,
});
const { chatMetrics } = await load('chat-metrics.ts');
test('a conversation that no longer fits offers "Summarize and continue" and "Start a new chat", never "try again"', () => {
const r = recovery.contextRecovery('CONTEXT_WINDOW_EXCEEDED');
assert.equal(r.action, 'Summarize and continue');
assert.equal(r.secondary.label, 'Start a new chat');
assert.match(r.next, /Summarize it now and continue, or start a new chat\./);
assert.doesNotMatch(r.next, /again/i);
calls.length = 0;
r.onRetry(); r.secondary.onClick();
assert.deepEqual(calls, [['summarize'], ['new-chat']]);
});
test('a message that is too large offers the two ways that fit: a file the assistant reads in parts, or a shortened preview', () => {
const r = recovery.contextRecovery('CONTEXT_MESSAGE_TOO_LARGE');
assert.equal(r.action, 'Attach as a file');
assert.equal(r.secondary.label, 'Send a shortened preview');
calls.length = 0;
r.onRetry(); r.secondary.onClick();
assert.deepEqual(calls, [['oversized', 'file'], ['oversized', 'preview']]);
});
test('any other error keeps the card as it was: one sentence, no buttons of ours', () => {
for (const code of [undefined, 'UNKNOWN', 'AGENT_STOPPED']) {
const r = recovery.contextRecovery(code);
assert.equal(r.next, 'Try sending the message again.');
assert.equal(r.onRetry, undefined);
assert.equal(r.secondary, undefined);
}
});
test('the chat shows the error card with Details and the recovery buttons, and the card can hold a second button', () => {
const chat = read('../src/screens/Chat.tsx');
assert.match(chat, /<ErrorNotice message=\{m\.error\} detail=\{m\.errorDetail\} \{\.\.\.contextRecovery\(m\.errorCode\)\}\/>/);
const ui = read('../src/components/ui.tsx');
assert.match(ui, /secondary\?: \{ label: string; onClick: \(\) => void \}/);
assert.match(ui, /<Btn size="sm" variant="ghost" onClick=\{secondary\.onClick\}>/);
});
test('the store keeps the code and the engine\'s text of an error, shows a sentence the host wrote in the person\'s language, and follows the context live', () => {
const store = read('../src/lib/store.ts');
assert.match(store, /typeof frame\.key === 'string' \? tr\(frame\.key,/);
assert.match(store, /errorCode: frame\.code/);
assert.match(store, /errorDetail: frame\.details/);
assert.match(store, /type === 'context'/);
const summarize = store.slice(store.indexOf('export async function summarizeAndContinue'), store.indexOf('export async function sendOversized'));
assert.ok(summarize.indexOf("sendMessage('/compact')") > 0 && summarize.indexOf("sendMessage('Please answer my last message now.')") > summarize.indexOf("sendMessage('/compact')"), 'summarise first, then ask for the answer');
assert.doesNotMatch(summarize, /failed.prompt|dropFailedExchange/, 'the refused question is not sent a second time (it is already in the conversation, and a large one twice would not fit)');
const oversized = store.slice(store.indexOf('export async function sendOversized'), store.indexOf('export function startNewChat'));
assert.match(oversized, /'\/api\/chat\/oversize'/);
assert.ok(oversized.indexOf('dropFailedExchange') > oversized.indexOf("'/api/chat/oversize'"), 'the failed message stays until the shorter one is ready');
});
const reply = (extra) => ({ id: 'r', role: 'assistant', content: '', at: '2026-10-22T12:00:00Z', ...extra });
const session = messages => ({ id: 's', title: 't', createdAt: '', updatedAt: '', messages, tokensIn: 0, tokensOut: 0, statsAvailable: true });
test('the context meter follows the turn that is running: a tool step does not read as 1%', () => {
// The 2026-10-22 chat: an earlier reply left the meter at 1% (about 600 tokens), then a turn with tools ran on a 65,536 window.
const earlier = reply({ id: 'a', inputTokens: 590, tokens: 20, contextUsed: 610, contextMax: 65536 });
const running = reply({ id: 'b', streaming: true, contextMax: 65536 });
const before = chatMetrics(session([earlier, running]), null, 65536);
assert.equal(Math.round(before.pct), 1, 'with nothing reported yet for the turn, the earlier reply is all there is');
const stepped = reply({ id: 'b', streaming: true, contextMax: 65536, contextUsed: 43000 });
const during = chatMetrics(session([earlier, stepped]), null, 65536);
assert.equal(during.used, 43000);
assert.equal(Math.round(during.pct), 66);
const monitor = { phase: 'idle', context: { sessionId: 's', usedTokens: 600, maxTokens: 65536 } };
assert.equal(chatMetrics(session([earlier, stepped]), monitor, 65536).used, 43000, 'the engine\'s figure for the request in flight does not override the turn\'s own');
const done = reply({ id: 'b', inputTokens: 43000, tokens: 90, contextUsed: 43090, contextMax: 65536 });
assert.equal(chatMetrics(session([earlier, done]), null, 65536).used, 43090, 'when the turn has ended its final figure stands');
});
test('every sentence about the context is in the catalog in all eight languages, with the same slots', () => {
const langs = ['en', 'zh', 'ja', 'de', 'es', 'ru', 'pt', 'ko'];
const catalogs = Object.fromEntries(langs.map(l => [l, JSON.parse(read(`../src/locales/${l}.json`))]));
const idOf = text => Object.entries(catalogs.en).find(([, v]) => v === text)?.[0];
const sentences = [...Object.values(FIT_TEXT), 'Attach as a file', 'Send a shortened preview', 'Summarize and continue', 'Start a new chat',
'The message could not be prepared. Try a shorter message.', 'The last Strata session is still closing down.', 'Wait a moment and try again. If it keeps happening, restart the computer.'];
const slots = t => (t.match(/\{\d+\}/g) ?? []).sort().join();
for (const text of sentences) {
const id = idOf(text);
assert.ok(id, `"${text.slice(0, 60)}" is in the English catalog`);
for (const l of langs) {
const t = catalogs[l][id];
assert.ok(typeof t === 'string' && t.trim(), `${id} in ${l}`);
assert.equal(slots(t), slots(text), `${id} in ${l} has the same slots`);
if (l !== 'en' && text.length > 25) assert.notEqual(t, text, `${id} is translated in ${l}`);
}
}
});
@@ -0,0 +1,144 @@
// T7 (tools and images review, 2026-10-22): a custom command tool runs in the shell that suits it, in the folder it needs, and with a time
// limit the person can raise. These tests run real commands; they use only what every machine has (node).
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createAgentTools } from '../server/agent/tools-settings.mjs';
import { runCommand, planShell, looksLikePowerShell, resolveCwd, clampTimeoutSec, DEFAULT_TIMEOUT_SEC } from '../server/agent/custom-command-run.mjs';
const SERVER = fileURLToPath(new URL('../server/agent/custom-tools-mcp.mjs', import.meta.url));
const node = JSON.stringify(process.execPath);
const WIN = process.platform === 'win32';
test('a Windows command that reads as PowerShell goes to PowerShell, the rest to cmd.exe, and a named shell is honoured', () => {
for (const c of ['Get-Content notes.txt', 'Start-Process notepad', 'Get-ChildItem | Where-Object { $_.Length -gt 1 }', '$env:HOME', 'echo $(Get-Date)'])
assert.ok(looksLikePowerShell(c), c);
for (const c of ['dir', 'type notes.txt', 'python tools\\foo.py --x', 'npm run build && echo done', 'echo %PATH%', 'git status'])
assert.ok(!looksLikePowerShell(c), c);
const ps = planShell('Get-Content x', { platform: 'win32' });
assert.match(ps.file, /^(pwsh|powershell\.exe)$/);
assert.deepEqual(ps.args.slice(0, 5), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']);
assert.equal(ps.args.at(-1), 'Get-Content x');
assert.deepEqual(planShell('dir', { platform: 'win32' }), { file: 'dir', args: [], shell: true, name: 'cmd.exe' });
assert.equal(planShell('dir', { platform: 'win32', shell: 'powershell' }).args.at(-1), 'dir');
assert.equal(planShell('Get-Content x', { platform: 'win32', shell: 'cmd' }).shell, true);
assert.deepEqual(planShell('ls -la', { platform: 'linux' }), { file: 'ls -la', args: [], shell: true, name: 'sh' });
assert.deepEqual(planShell('ls | wc -l', { platform: 'linux', shell: 'bash' }), { file: 'bash', args: ['-c', 'ls | wc -l'], shell: false, name: 'bash' });
assert.equal(planShell('Get-Content x', { platform: 'linux' }).name, 'sh', 'on Linux and macOS a PowerShell-looking command is not rerouted');
});
test('the time allowed is 2 minutes unless the tool says more, at most an hour', () => {
assert.equal(DEFAULT_TIMEOUT_SEC, 120);
assert.equal(clampTimeoutSec(undefined), 120);
assert.equal(clampTimeoutSec(600), 600);
assert.equal(clampTimeoutSec(99999), 3600);
assert.equal(clampTimeoutSec(0), 120);
assert.equal(clampTimeoutSec('abc'), 120);
});
test('a command runs in the folder the call names, then the tool\'s, then the home folder; a missing folder is a plain refusal', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmd-')));
const other = join(dir, 'other'); mkdirSync(other);
try {
const pwd = `${node} -e "console.log(process.cwd())"`;
const here = await runCommand({ command: pwd, cwd: dir });
assert.equal(realpathSync(here.text), dir);
const named = await runCommand({ command: pwd, cwd: dir, callCwd: other });
assert.equal(realpathSync(named.text), other, 'the folder named in the call wins');
const home = await runCommand({ command: pwd });
assert.equal(realpathSync(home.text), realpathSync(homedir()));
const gone = await runCommand({ command: pwd, cwd: join(dir, 'nope') });
assert.equal(gone.ok, false);
assert.match(gone.text, /does not exist, so the command was not run/);
assert.deepEqual(resolveCwd(''), { cwd: homedir() });
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('a relative path in the command finds its file in the tool\'s folder', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmd-')));
try {
mkdirSync(join(dir, 'tools'));
writeFileSync(join(dir, 'tools', 'foo.js'), 'console.log("from foo " + (process.env.STRATA_INPUT || ""));');
const r = await runCommand({ command: `${node} tools/foo.js`, cwd: dir, input: 'hello' });
assert.deepEqual([r.ok, r.text], [true, 'from foo hello']);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('a command over the limit is ended and says so; output over the cap says it was cut', async () => {
const slow = await runCommand({ command: `${node} -e "setTimeout(()=>{}, 30000)"`, timeoutSec: 1 });
assert.equal(slow.ok, false);
assert.match(slow.text, /stopped after 1 seconds/);
const big = await runCommand({ command: `${node} -e "process.stdout.write('x'.repeat(50000))"` });
assert.equal(big.ok, true);
assert.match(big.text, /\(The output was cut after 20,000 characters\.\)$/);
assert.ok(big.text.length < 20100);
});
test('PowerShell commands run on Windows (Get-ChildItem, $env:)', { skip: !WIN && 'PowerShell is checked on Windows' }, async () => {
const r = await runCommand({ command: 'Write-Output ("a" + $env:STRATA_INPUT)', input: 'b' });
assert.deepEqual([r.ok, r.text], [true, 'ab']);
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmd-')));
try {
writeFileSync(join(dir, 'note.txt'), 'hi');
const read = await runCommand({ command: 'Get-Content note.txt', cwd: dir });
assert.deepEqual([read.ok, read.text], [true, 'hi']);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('Settings keeps a command tool\'s folder, shell and time, validates them, and an edit that does not send them keeps them', () => {
let stored;
const dir = mkdtempSync(join(tmpdir(), 'strata-cmdset-'));
try {
const tools = createAgentTools({ read: () => stored, write: v => { stored = v; }, dataDir: dir });
tools.addCustom({ name: 'Build', about: 'Builds the project.', kind: 'command', command: 'npm run build', cwd: 'C:\\work\\app', shell: 'cmd', timeoutSec: 900 });
let tool = tools.view().custom[0];
assert.deepEqual([tool.cwd, tool.shell, tool.timeoutSec], ['C:\\work\\app', 'cmd', 900]);
tools.editCustom(tool.id, { name: 'Build', about: 'Builds the project, quickly.', kind: 'command', command: 'npm run build' }); // the form sends none of the three
tool = tools.view().custom[0];
assert.deepEqual([tool.about, tool.cwd, tool.shell, tool.timeoutSec], ['Builds the project, quickly.', 'C:\\work\\app', 'cmd', 900]);
assert.throws(() => tools.editCustom(tool.id, { name: 'Build', about: 'x', kind: 'command', command: 'npm run build', timeoutSec: 99999 }), /1 to 3600/);
assert.throws(() => tools.editCustom(tool.id, { name: 'Build', about: 'x', kind: 'command', command: 'npm run build', shell: 'fish' }), /Choose how the command runs/);
tools.addCustom({ name: 'Plain', about: 'Plain.', kind: 'command', command: 'echo hi' });
const plain = tools.view().custom[1];
assert.deepEqual([plain.cwd, plain.shell, plain.timeoutSec], ['', 'auto', 120]);
// the tool server's file carries them
const file = JSON.parse(require_read(tools.filePath));
assert.deepEqual(file.commands.build, { description: 'Builds the project, quickly.', command: 'npm run build', cwd: 'C:\\work\\app', shell: 'cmd', timeoutSec: 900 });
} finally { rmSync(dir, { recursive: true, force: true }); }
});
import { readFileSync } from 'node:fs';
const require_read = file => readFileSync(file, 'utf8');
test('the tool server over stdio: the call may name a folder, and the tool\'s own settings apply', async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'strata-cmdsrv-')));
const work = join(dir, 'work'); mkdirSync(work);
const file = join(dir, 'tools.json');
writeFileSync(file, JSON.stringify({ commands: {
where: { description: 'Prints its folder.', command: `${node} -e "console.log(process.cwd())"` },
pinned: { description: 'Runs in work.', command: `${node} -e "console.log(process.cwd())"`, cwd: work },
} }));
const child = spawn(process.execPath, [SERVER, 'commands', file], { stdio: ['pipe', 'pipe', 'inherit'] });
const lines = []; let buffer = '';
child.stdout.on('data', d => { buffer += d; let i; while ((i = buffer.indexOf('\n')) >= 0) { lines.push(JSON.parse(buffer.slice(0, i))); buffer = buffer.slice(i + 1); } });
const ask = async (id, method, params) => {
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
for (let n = 0; n < 200; n++) { const hit = lines.find(l => l.id === id); if (hit) return hit.result; await new Promise(r => setTimeout(r, 25)); }
throw new Error('no answer');
};
try {
const listed = await ask(1, 'tools/list', {});
assert.ok(listed.tools.every(t => t.inputSchema.properties.cwd?.type === 'string'), 'the optional folder is offered');
const pinned = await ask(2, 'tools/call', { name: 'pinned', arguments: {} });
assert.equal(realpathSync(pinned.content[0].text), work);
const named = await ask(3, 'tools/call', { name: 'where', arguments: { cwd: dir } });
assert.equal(realpathSync(named.content[0].text), dir);
const missing = await ask(4, 'tools/call', { name: 'where', arguments: { cwd: join(dir, 'nope') } });
assert.equal(missing.isError, true);
assert.match(missing.content[0].text, /does not exist/);
} finally { child.kill(); rmSync(dir, { recursive: true, force: true }); }
});
+2 -2
View File
@@ -45,11 +45,11 @@ test('withoutDraftLayer takes out --mtp, --spec-min-p and --batch-mtp and keeps
test('"Draft layer (MTP)" is a setting of the app, not frozen, and "MTP per request" is not frozen either', async () => {
assert.ok(NATIVE_FIELDS.some(f => f.id === 'mtp' && f.type === 'boolean'));
assert.deepEqual(APP_OWNED_FIELDS.sort(), ['batchMtp', 'mtp']);
assert.deepEqual(APP_OWNED_FIELDS.sort(), ['batchMtp', 'drafting', 'mtp']);
const f = fixture();
try {
await f.native.refresh(true);
for (const id of ['mtp', 'batchMtp']) assert.equal(f.native.state().fields.find(x => x.id === id).supported, true, `${id} must not be disabled`);
for (const id of ['mtp', 'batchMtp', 'drafting']) assert.equal(f.native.state().fields.find(x => x.id === id).supported, true, `${id} must not be disabled`);
assert.deepEqual(validateNativeSettings({ mtp: false }), { mtp: false });
assert.equal(checkRunConfig(JSON.stringify({ exe: 'e', tokenizer: 't', args: [], mtp: false })).issues.length, 0, 'the file check knows the key');
} finally { await f.native.close(); f.cleanup(); }
+123
View File
@@ -0,0 +1,123 @@
// The Drafting choice in the app (MTP / DFlash2 / Off / Auto): the Models screen row, the setting the host saves, what the host asks Control before
// a load, the download plan line, and the words in all eight languages. The table of which card gets which drafter lives in Python
// (strata_control/drafting.py, tests/terminal/test_drafting.py); the app never reads it.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { NATIVE_FIELDS, APP_OWNED_FIELDS, validateNativeSettings, withoutDraftLayer } from '../server/native-settings.mjs';
import { createControl } from '../server/control-runtime.mjs';
const read = rel => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8').replace(/\r\n/g, '\n');
test('Drafting is a setting of the model with four choices, saved by the app itself', () => {
const field = NATIVE_FIELDS.find(f => f.id === 'drafting');
assert.ok(field);
assert.deepEqual(field.choices, ['auto', 'mtp', 'dflash2', 'off']);
assert.equal(field.type, 'enum');
assert.match(field.hint, /Auto \(recommended\)/);
assert.match(field.hint, /Qwen3\.8-Flash-Next only/);
assert.ok(APP_OWNED_FIELDS.includes('drafting'), 'the engine\'s own source never lists it: the app applies it');
for (const ok of ['auto', 'mtp', 'dflash2', 'off']) assert.deepEqual(validateNativeSettings({ drafting: ok }), { drafting: ok });
assert.deepEqual(validateNativeSettings({ drafting: null }), { drafting: null }, 'cleared = Auto');
assert.throws(() => validateNativeSettings({ drafting: 'fastest' }), /Drafting: choose a supported option/);
});
test('Off leaves the draft layer out of every start, the same as "Draft layer (MTP)" off', () => {
assert.deepEqual(withoutDraftLayer(['--pack', 'p', '--mtp', '/m', '--spec', '4', '--spec-min-p', '0.5', '--batch-mtp']), ['--pack', 'p', '--spec', '4']);
const source = read('../server/native-control.mjs');
assert.match(source, /\(cfg\.mtp===false\|\|cfg\.drafting==='off'\)&&Array\.isArray\(cfg\.args\)\)cfg\.args=withoutDraftLayer\(cfg\.args\)/);
});
test('the Models screen offers the choice next to context, DFlash2 only for Qwen3.8-Flash-Next, and carries it to the plan and the load', () => {
const screen = read('../src/components/NativeModels.tsx');
assert.match(screen, /draftingOptions\(selectedSize\.sourceFamily \?\? selectedFamily\?\.family, native\?\.capabilities\?\.dflash2\)/);
assert.match(screen, /draftingWords: Record<string, string> = \{ auto: 'Auto \(recommended\)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' \}/);
const row = screen.slice(screen.indexOf('<div className="native-choice__row">'), screen.indexOf('{roleCards.length > 1 ? <GpuRoles'));
assert.ok(row.indexOf('Conversation context') < row.indexOf('tr("Drafting")') && row.indexOf('tr("Drafting")') < row.indexOf('Computer use'), 'in the same row, between context and computer use');
assert.match(row, /<Select ariaLabel=\{tr\("Drafting"\)\}/, 'the existing Select, no new component');
assert.match(screen, /setDrafting\(installed\.drafting \?\? 'auto'\)/, 'a model shows what it was saved with');
assert.match(screen, /const downloadValues = \{ \.\.\.values, \.\.\.\(draftingChoices \? \{ drafting \} : \{\}\)/);
assert.match(screen, /\.\.\.\(draftingChoices \? \{ values: \{ drafting \} \} : \{\}\)/);
assert.match(screen, /Choosing DFlash2 also downloads its drafter: about \{0\} GB, once\. If it does not fit in graphics memory, the model uses the MTP instead\./);
assert.match(screen, /loadedSelected\?\.metrics\?\.engine\?\.drafting_note/, 'what the engine said is shown');
});
test('the host asks Control before a load, and Control decides (the app never reads the table)', async () => {
const calls = [];
const control = createControl({ control: { dir: 'C:/c', python: 'py', cli: 'strata.py' }, run: async (args, options) => {
calls.push({ args, options });
return args[1] === 'plan'
? { code: 0, json: { ok: true, needed: true, gb: 1.2, line: 'Drafting: DFlash2' }, text: '', stderr: '' }
: { code: 0, json: { ok: true, event: 'result', pack: 'C:/d/dflash2/x.pack' }, text: '', stderr: '' };
} });
const plan = await control.draftingPlan({ mode: 'dflash2', family: 'gyro', dataDir: 'C:/d' });
assert.deepEqual(calls[0].args, ['drafting', 'plan', '--mode', 'dflash2', '--family', 'gyro', '--data-dir', 'C:/d', '--output', 'json']);
assert.equal(plan.needed, true);
const events = [];
await control.draftingFetch({ configPath: 'C:/w/cfg.json', mode: 'dflash2', onEvent: e => events.push(e) });
assert.deepEqual(calls[1].args, ['drafting', 'fetch', '--config', 'C:/w/cfg.json', '--mode', 'dflash2', '--if-needed', '--output', 'jsonl']);
assert.equal(calls[1].options.acquire, true, 'only this explicit step may reach the network');
assert.equal(calls[0].options.acquire, undefined, 'planning never does');
const failing = createControl({ control: { dir: 'C:/c', python: 'py', cli: 'strata.py' }, run: async () => ({ code: 5, json: { ok: false, error: { code: 'drafting_failed', message: 'The DFlash2 download did not finish.' } }, text: '', stderr: '' }) });
await assert.rejects(failing.draftingFetch({ configPath: 'x' }), /DFlash2 download did not finish/);
});
test('a load fetches the drafter first, never stops for it, and says what ran', () => {
const source = read('../server/native-control.mjs');
const prepare = source.slice(source.indexOf('async function prepareDrafter'), source.indexOf('function rememberSetting'));
assert.match(prepare, /rememberSetting\(configFile,'drafting',asked===null\|\|asked==='auto'\?null:asked\)/, 'the choice is kept for the next load');
assert.match(prepare, /cfg\.drafting==='auto'\|\|cfg\.drafting==='mtp'\|\|cfg\.drafting==='off'\)return/, 'Auto, MTP and Off never download');
assert.match(prepare, /control\.draftingFetch\(/);
assert.match(prepare, /catch\(error\)\{if\(job\.state==='running'\)\{job\.drafterNote=error\.message;/, 'a failed download leaves the MTP and the job says so');
assert.match(source, /await prepareDrafter\(job,args,cfg,configFile\)/);
assert.match(source, /async function noteDrafting\(job,instance,key\)/);
assert.match(source, /instance\.status='ready';instance\.phase='idle';await noteDrafting\(job,instance,key\)/);
assert.match(source, /choice!=='auto'&&typeof control\.draftingPlan==='function'/, 'the plan lists the drafter only when it is chosen and Control says it is needed');
assert.match(source, /drafting:cfg\.drafting\?\?'auto'/, 'the installed model reports its saved choice');
});
test('every sentence of the Drafting choice is in the catalog in all eight languages, with the same slots', () => {
const langs = ['en', 'zh', 'ja', 'de', 'es', 'ru', 'pt', 'ko'];
const catalogs = Object.fromEntries(langs.map(l => [l, JSON.parse(read(`../src/locales/${l}.json`))]));
const idOf = text => Object.entries(catalogs.en).find(([, v]) => v === text)?.[0];
const sentences = ['Drafting', 'Auto (recommended)', 'MTP', 'DFlash2',
"Drafting: MTP (DFlash2 didn't fit)", "Drafting: MTP (DFlash2 isn't available in this mode)", 'Drafting: MTP (DFlash2 is for Qwen3.8-Flash-Next only)',
"Drafting: MTP (the DFlash2 drafter isn't downloaded yet)",
'Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.',
'Downloading the DFlash2 drafter…', 'Packing the DFlash2 drafter for the engine…', 'The DFlash2 drafter is ready.',
'The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…', 'Loading the model…'];
const slots = t => (t.match(/\{\d+\}/g) ?? []).sort().join();
for (const text of sentences) {
const id = idOf(text);
assert.ok(id, `"${text.slice(0, 60)}" is in the English catalog`);
for (const l of langs) {
const t = catalogs[l][id];
assert.ok(typeof t === 'string' && t.trim(), `${id} in ${l}`);
assert.equal(slots(t), slots(text), `${id} in ${l} has the same slots`);
if (l !== 'en' && text.length > 25) assert.notEqual(t, text, `${id} is translated in ${l}`);
}
}
});
test('DFlash2 is offered only for Qwen3.8-Flash-Next AND only once the engine serves it; until then the setting is Auto / MTP / Off', async () => {
const { transform } = await import('esbuild');
const code = (await transform(read('../src/lib/drafting.ts'), { loader: 'ts', format: 'esm' })).code;
const { draftingChoices } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
const off = ['auto', 'mtp', 'off'], all = ['auto', 'mtp', 'dflash2', 'off'];
// the engine does not serve it (this release): never offered
for (const family of ['qwen', 'gyro', 'coder', 'swift', undefined]) {
assert.deepEqual(draftingChoices(family, false), off, `${family}, not served`);
assert.deepEqual(draftingChoices(family, undefined), off, `${family}, Control has not answered`);
assert.deepEqual(draftingChoices(family, null), off);
}
// the engine serves it: only the models it is for
assert.deepEqual(draftingChoices('qwen', true), all);
assert.deepEqual(draftingChoices('gyro', true), all);
for (const family of ['coder', 'swift', 'unsloth', 'nvfp4', undefined]) assert.deepEqual(draftingChoices(family, true), off, `${family}, served`);
// the app learns it from Control, once, and false until Control answers
const host = read('../server/native-control.mjs');
assert.match(host, /dflash2:dflash2Served===true/);
assert.match(host, /dflash2Served=p\.serves===true/);
assert.match(host, /function probeDrafting\(\)\{if\(!packaged\|\|dflash2Served!==null/);
});
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { engineProgressMark } from '../server/agent/engine-progress.mjs';
import { readFileSync } from 'node:fs';
test('the mark moves with the prompt read and with generation, and is null when the engine is idle or silent', () => {
const at = live => engineProgressMark({ metrics: { live } });
assert.equal(engineProgressMark(null), null);
assert.equal(engineProgressMark({ metrics: null }), null);
assert.equal(at({ state: 'idle' }), null);
assert.equal(at({ state: 'unloaded' }), null);
const a = at({ state: 'reading', prompt_read: 4096, prompt_total: 90000 });
const b = at({ state: 'reading', prompt_read: 8192, prompt_total: 90000 });
assert.notEqual(a, b);
assert.equal(a, at({ state: 'reading', prompt_read: 4096, prompt_total: 90000 }), 'no progress, same mark');
assert.notEqual(at({ state: 'generating', generated: 1 }), at({ state: 'generating', generated: 2 }));
assert.notEqual(at({ state: 'reading', slots: [{ state: 'reading', prompt_read: 1 }] }), at({ state: 'reading', slots: [{ state: 'reading', prompt_read: 2 }] }));
});
test('the host wires it in and the local profile lifts the 300 s stream-idle timer', () => {
assert.match(readFileSync(new URL('../server/index.mjs', import.meta.url), 'utf8'), /engineProgress: instanceId => engineProgressMark\(/);
assert.match(readFileSync(new URL('../server/agent/profile.mjs', import.meta.url), 'utf8'), /streamIdleTimeoutMs: 60 \* 60_000/);
});
+10 -4
View File
@@ -96,22 +96,28 @@ test('item 7: tool rows say what happened in plain words, with what it was about
}
});
test('item 8: starting a program that prints nothing is reported as started, plainly', () => {
test('item 8 / T9: starting a program that prints nothing is reported as started, never as opened', () => {
for (const command of ['Start-Process explorer.exe C:\\work', 'explorer.exe C:\\work', 'start notepad', 'Invoke-Item C:\\work\\a.pdf', 'ii .', 'xdg-open .', 'open .']) {
assert.match(outcomeText('pwsh', '(no output)', { command }), /^Started\./, command);
const said = outcomeText('pwsh', '(no output)', { command });
assert.match(said, /^The command finished without an error, so the program was started\./, command);
assert.match(said, /cannot confirm that a window or page actually opened/, command);
assert.ok(!/was opened|is open/i.test(said), command);
}
assert.match(outcomeText('pwsh', '(no output)', { command: 'New-Item x.txt | Out-Null' }), /finished without any error/, 'other silent commands keep their wording');
assert.equal(outcomeText('pwsh', 'hello', { command: 'echo hello' }), 'hello', 'a command that printed something shows it');
const p = new TurnProjector({ startedAt: 0 });
p.consume(ev('tool/call', { callId: 'e1', name: 'pwsh', arguments: JSON.stringify({ command: 'Start-Process explorer.exe C:\\work' }) }));
const res = p.consume(ev('tool/result', { message: { toolCallId: 'e1', content: [{ type: 'text', text: '(no output)' }] } }))[0].tool;
assert.match(res.result, /^Started\./); assert.equal(res.verb, 'Ran a command');
assert.match(res.result, /cannot confirm that a window or page actually opened/); assert.equal(res.verb, 'Ran a command');
});
test('item 7/10: our prompt has no internal terms and is short', () => {
assert.ok(AGENT_PROMPT.length < 1600, `${AGENT_PROMPT.length} chars`);
assert.doesNotMatch(AGENT_PROMPT, /DSH|DeepSeek|sandbox|isolated session|file policy|Harness/i);
assert.match(AGENT_PROMPT, /do not say you could not confirm/i);
// O6 / #3576: the prompt points at the present tool for local files and never tells the model to claim a window opened
assert.match(AGENT_PROMPT, /use the present tool/);
assert.match(AGENT_PROMPT, /Never say a window opened, a page rendered/);
assert.doesNotMatch(AGENT_PROMPT, /say plainly that it is open|do not say you could not confirm/i);
});
test('items 3, 7, 10: what the agent sends the model is small, plain, and carries Thinking Off', { skip }, async t => {
+36
View File
@@ -0,0 +1,36 @@
// T4 (tools and images review, 2026-10-22): the approval gate fails closed. When the permission mode of a session cannot be read, a call
// that is not provably read-only asks, as in Manual; it used to run without a card.
import test from 'node:test';
import assert from 'node:assert/strict';
import { apply, MANUAL_PRESET } from '../server/agent/plugins/strata-gate.mjs';
function gate(current) {
let handler;
apply({ on: (_e, h) => { handler = h; }, inject: () => {}, sessions: new Map(), permissionPresets: { current }, approval: { overrideOf: () => 'never' } });
return handler;
}
const session = { header: { origin: 'user' } };
const allow = async () => ({ kind: 'allow' });
const quiet = async fn => { const warn = console.warn; const lines = []; console.warn = (...a) => lines.push(a.join(' ')); try { return [await fn(), lines]; } finally { console.warn = warn; } };
test('a call that changes things asks when the preset cannot be resolved', async () => {
const handler = gate(() => { throw new Error('unknown session'); });
const [result, lines] = await quiet(() => handler({ name: 'write', agent: { session }, input: { file_path: 'x' } }, allow));
assert.equal(result.kind, 'ask');
assert.match(lines.join('\n'), /could not read the permission mode.*unknown session.*Manual/);
});
test('a provably read-only call still goes through, and a shell write asks', async () => {
const handler = gate(() => { throw new Error('gone'); });
const [read] = await quiet(() => handler({ name: 'read', agent: { session }, input: { file_path: 'x' } }, allow));
assert.equal(read.kind, 'allow');
const [shell] = await quiet(() => handler({ name: 'bash', agent: { session }, input: { command: 'rm -rf x' } }, allow));
assert.equal(shell.kind, 'ask');
});
test('with a readable preset nothing changes: Bypass runs, Manual asks', async () => {
const bypass = gate(() => 'strata-bypass');
assert.equal((await bypass({ name: 'write', agent: { session }, input: { file_path: 'x' } }, allow)).kind, 'allow');
const manual = gate(() => MANUAL_PRESET);
assert.equal((await manual({ name: 'write', agent: { session }, input: { file_path: 'x' } }, allow)).kind, 'ask');
});
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff. Show more