diff options
| -rw-r--r-- | CHANGELOG.md | 854 | ||||
| -rw-r--r-- | docs/usage.html | 2 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 200 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 62 | ||||
| -rw-r--r-- | modules/core/module-billing.js | 33 | ||||
| -rw-r--r-- | modules/core/module-fetcher.js | 17 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-orchestrator.js | 88 | ||||
| -rw-r--r-- | modules/ext/cryptogzip.js | 67 | ||||
| -rw-r--r-- | modules/ext/media.js | 6 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 20 | ||||
| -rw-r--r-- | modules/test/test-orchestrator-parallel-harvest.js | 132 | ||||
| -rw-r--r-- | scripts/build-changelog.py | 207 | ||||
| -rw-r--r-- | trade-history.json | 61 | ||||
| -rw-r--r-- | wallet-snapshot.json | 15 |
16 files changed, 1408 insertions, 364 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f98e2..b5dbd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,569 +4,1115 @@ Generated from git history by `scripts/build-changelog.py` — do not edit by ha <!-- generated:start --> -## v4.6.146 — 2026-11-05 - -_Touches modules (3), README.md (2), main.html (1), LICENSE (1) · docs 2, project 1_ - -- `One dashboard — port billing/API keys, 301 the duplicate` `8bf70f9` -- `Publishable README, MIT licence, security policy` `447be4b` -- `Clone instructions in the README; publisher sets gitweb owner/description` `350e1d0` +## v4.6.150 — 2026-11-06 + +_Touches modules (7), main.html (1) · project 1_ + +- **Commit the module work that has been live but uncommitted** `6bea062` + - Operator commit-approve, 2026-11-06. These changes have been served since 2026-11-02/03 while git HEAD still stamped v4.6.146, so the mirror published a tree four builds behind the running instance and the release tag named the wrong build. + - … run `git show <sha>` for the rest + +## v4.6.146 — 2026-11-06 + +_Touches docs (11), CHANGELOG.md (4), modules (3), README.md (3) · docs 10, fix 4, release 3_ + +- …and 5 earlier commit(s) in this build +- **Drop hardcoded file/commit counts from the usage page** `d2bb158` + - 310 files / 182 commits were already wrong at publish time (184). A count that changes on every publish is a rot vector; the page now says what the tree is and lets the reader run the commands. +- **No byte count either — it grows with every publish** `a24ec6b` +- **Usage page fits a 390 px screen** `373f6ca` + - Two measured overflows, both structural: a grid item defaults to min-width:auto so one long command stretched the column to 521 px, and the troubleshooting table had no fixed layout so a column of long code strings pushed the page 75 px wide. minmax(0,1fr) + min-width:0 + table-layout:fixed + overflow-wrap:anywhere. verify-mobile: 4/4 viewports OK. +- **Last stale count on the usage page (181 commits)** `bc4d180` +- **Automating with the pi agent (recipes verified against pi 0.80.3)** `83cb1c7` + - Four recipes on the usage page: one-shot @file analysis, --mode json + jq for scripts, a read-only --tools allowlist for tree inspection, and a cron-able digest. Every flag shown was exercised on this host first; the retired-default model gotcha (HTTP 410 'was retired') is documented because it wasted a run. Adds the untrusted-input warning: pi auto-loads AGENTS.md/skills from the working directory, so a public clone must run with --no-approve and no write tools. +- **Install pi + point it at Ollama; fix dead /about/ link** `d99962d` + - Install is npm (node>=22.19). Ollama wiring is two files under ~/.pi/agent: models.json (baseUrl http://localhost:11434/v1, api openai-completions, model list) and settings.json (defaultProvider/defaultModel). Documents the trap that a pulled model must also be declared — undeclared gives 'Model not found', which reads like a connection failure. Both paths verified on this host: declared coder:latest -> 'LOCAL OK', undeclared llama3.2:latest -> error. + - The two /about/ links pointed at the host root, where nothing exists; the repo page is /newos4/about/. +- **Reconcile pi version (verified on 0.80.3, npm ships 1.5.0)** `5fdeb6d` +- **Contrib push surface with automated moderation** `e90dc38` + - Users push to push.newos.webx11.duckdns.org with a per-user token; the read-only host is untouched. http.receivepack is unset so git only allows receive-pack when REMOTE_USER is present, which only that vhost sets. A pre-receive hook (installed by the publisher every run) allows refs/heads/contrib/* only and refuses banned paths, credential-shaped blobs, >200 files or >2MB, logging user+ref+decision. + - moderate-contrib.py triages every 10 min: deletes hard violations, stamps the rest ready-docs/review/stale into a queue rendered at /admin/git-contrib.html. A human still decides what merges. + - Verified end to end: contrib push accepted, main refused, credential file refused, accepted ref visible on the read-only host; guard extended (push host 401, hook present+executable, receivepack unset) and proven RED by removing the hook. +- **Namespace contrib refs per account (contrib/<user>/<topic>)** `4acb065` + - Done before anyone has a token or a branch, because renaming later breaks every contributor. The hook compares the middle path segment to REMOTE_USER, so a token can only write under its own namespace; deletion and cleanup become a prefix operation instead of a guess. + - Verified 4/4: own namespace accepted, neighbour's namespace refused naming the authenticated user, missing owner segment refused with the correct form, main refused. Test branch and token removed afterwards; queue back to 0. +- **Self-service git push tokens from the dashboard** `5b55af9` + - GET/POST /api/auth/gitpush-token (+ /revoke) on auth-api: session-scoped, CSRF-gated, 3 mints per account per hour, bcrypt into the htpasswd, plaintext returned once and never stored, audit at /var/lib/webx11/git-push-audit.jsonl. + - No sudo or reload needed: nginx re-opens auth_basic_user_file per request, so a new line is live immediately — proved by a 500 whose error line was per request. + - Dashboard card 'Git push token' shows none/active, mints and reveals the remote once, revokes. Verified in a real browser: badge none -> mint -> active, remote shape checked, then revoked; htpasswd back to 0 bytes and the test session removed. +- **Remove published dashboard fixtures from the space** `0fe3af9` + - Symptom: an outside review of the public mirror reported trade-history.json and wallet-snapshot.json at repo root as personal financial records, contradicting README's promise that personal data is absent by design. + - Cause: they are fixtures, not records (trade-history.json:60 "Sample data for dashboard rendering", wallet-snapshot.json:13 source=manual-baseline, address 0xA12b…3d4 synthetic), committed by an auto-commit in July and left at the space root. Nothing in the tree ever referenced either path (grepped the whole web root, 0 hits), and both answered HTTP 200 unauthenticated at /spaces/newos4/<file>.json — so a reader could only take them at face value. + - Fix: deleted both files, and admin/scripts/publish-newos4.py now lists them in EXCLUDE_GLOBS, which strips them from the mirror's rewritten history too (a tip deletion alone would leave them in every earlier commit). + - … run `git show <sha>` for the rest +- **Render the commit body, not just the subject** `c759c50` + - Symptom: the mirrored CHANGELOG.md (and the page) showed one-line commit summaries while README promises "every shipped fix, symptom -> cause -> fix -> verified". A reviewer measured the gap from the public mirror and could only conclude the claim overpromised. + - Cause: scripts/build-changelog.py:59 formatted commits with %s only; %b was never read, so the verification prose every fix commit carries (5b55af9 is a typical one) was discarded before rendering. The MD writer then replaced every tag with a backtick, so nothing richer could have survived anyway. + - … run `git show <sha>` for the rest +- **Keep the newest commits of a build; wrap long detail URLs** `9751798` + - Symptom: after bodies were rendered, this fix's own commit was missing from its own entry, and a long URL inside an expanded detail was clipped at the right edge on a 390 px screen. + - Cause: items took group[:MAX_ITEMS] from a CHRONOLOGICAL list, so a 17-commit build showed its first 14 and dropped the newest three (the count line said "…and 3 more", hiding which three). And the injected detail CSS set no overflow-wrap, so a 47-character URL pushed the paragraph to 320 px of unbreakable text — the document stayed at 0 overflow only because the li clipped it. + - … run `git show <sha>` for the rest +- **Regenerate after the render and ordering fixes** `2feefc0` ## v4.6.145 — 2026-11-02 _Touches modules (2), main.html (1) · fix 1_ -- `Stop offering a Telegram widget where it cannot work` `532e2ff` +- **Stop offering a Telegram widget where it cannot work** `532e2ff` + - Two hosts cannot complete the widget flow and both are in use: an Android WebView / in-app browser (the operator's phone UA ends "; wv)") and a desktop browser that blocks the popup or hands the link to the Telegram app. The widget needs a popup that can postMessage back into the opener; in those hosts the link lands in a browser tab or in Chrome with no opener, onTelegramAuth never fires, and the login card looks dead — "it opens my default browser" (reported twice: phone, then Max's laptop). + - modules/wx11-tg-webview.js (new) WX11TG.isWebView() -> "; wv)", Telegram in-app, Meta in-app UAs WX11TG.dropWidgets()-> removes already-rendered oauth.telegram.org iframes/scripts + - … run `git show <sha>` for the rest ## v4.6.144 — 2026-11-02 _Touches modules (3), main.html (1) · fix 1_ -- `One session writer for every login path; expire dead credentials` `2421eb4` +- **One session writer for every login path; expire dead credentials** `2421eb4` + - Third occurrence of the same class of bug, and the first fix is why: commit e95b7be (2026-11-01, "multi-session .newsession v3") touched exactly ONE hunk of auth-file-api.py — handle_login. handle_device_poll, handle_telegram_login and handle_register kept overwriting the whole .newsession with a single-session v2 payload, so a phone sign-in logged the desktop out and the loser held a session id no store knew: permanent `401 Invalid session or API key` (newOS4 asked for a login it could never complete). Measured: agando's store held 1 entry (sha8 45cb7819) while the phone sent 9ac607f1 for 3 h; 26 device-polls in 2 s from one page minted 26 cookies and only the last write survived, which also drained the shared nginx `webx11` zone (11x 503 on /admin/api/*). + - api/webx11_auth.py + - add_session() is now THE writer: v3 `sessions[]` (cap 5) under a per-user + - … run `git show <sha>` for the rest ## v4.6.143 — 2026-11-02 _Touches modules (6), main.html (1) · fix 1_ -- `Read_file pages were silently truncated; delegation no longer invents "completed"` `b380ec8` +- **Read_file pages were silently truncated; delegation no longer invents "completed"** `b380ec8` + - Two defects found by re-deriving a report that claimed both were still open. + - read_file paging (module-aiassist.js) + - A page larger than the result clip was clipped AFTER read_file had computed + - next_offset. On the last page next_offset is null, so the model was told truncated:false while its tail had been removed: measured 11 890 of 19 999 chars delivered, returned:19999, no pointer to the rest - 8 109 chars lost with no way to ask for them. + - the clip now sets truncated=true and next_offset=offset+keep when it removes + - the tail of a page that had no continuation (and never splits a surrogate pair) + - read_file caps a page at the result budget (min(pageCap, toolResultChars-200)), + - … run `git show <sha>` for the rest ## v4.6.141 — 2026-11-02 _Touches modules (7), main.html (1) · fix 1_ -- `Memory-graph loads stop rewriting user data; unknown sizes say unknown` `8eaf114` +- **Memory-graph loads stop rewriting user data; unknown sizes say unknown** `8eaf114` + - Three follow-ups from the merge review, each with the assertion that pins it. + - module-memory-graph.js + - _normalizeEdges no longer mutates stored weights. It capped legacy 'failed' edges + - from 0.8 to 0.2 in memory, and the next debounced save made that permanent - an irreversible migration of the user's graph with no schema version. The cap moved to _effectiveWeight(), which ranking (showStats "Strongest relationships"), the session view and prune() now use; the stored record is left exactly as written. + - config.json is stamped version: 2 so a future format change can migrate deliberately. + - ext/webdav.js + - the two fallback parsers emit size: null instead of 0. They cannot see the size + - column, and 0 rendered as "0 B" - the same "every file looks empty" symptom as the parseInt bug fixed in e639c2d, one regex miss away. + - … run `git show <sha>` for the rest ## v4.6.140 — 2026-11-02 _Touches modules (6), main.html (1) · fix 1_ -- `WebDAV listing sizes + pull, and five MemoryGraph defects` `e639c2d` +- **WebDAV listing sizes + pull, and five MemoryGraph defects** `e639c2d` + - Closes STEER-20261102-205416-facd and STEER-20261102-204031-f82a. + - WebDAV (modules/ext/webdav.js): + - listings showed "21 B" for a 21 KB file: nginx emits humanised sizes + - (autoindex_exact_size off, vhost :412) and _parseAutoindex did parseInt("21K"). _parseAutoindexSize() handles K/M/G, decimals, lowercase and "-"; PROPFIND's exact getcontentlength path is untouched. + - webdav-pull into a missing local directory printed "Pulled: ... (N bytes)" and + - wrote nothing: it ignored fs.writeFile's false return (virtual-fs.js:561). It now creates the parent chain, checks the write, and only reports success after a confirmed write. The byte count uses TextEncoder, not .length (a Cyrillic file under-reported ~2x). + - _formatSize gained the GB branch the parser already accepted (3 GB printed + - "3072.0 MB") and no longer prints "NaN MB" for a missing size. + - … run `git show <sha>` for the rest ## v4.6.139 — 2026-11-02 _Touches modules (4), main.html (1) · fix 1_ -- `Delegated agent runs no longer serialise; a waiter can't steal a lock` `7491c34` +- **Delegated agent runs no longer serialise; a waiter can't steal a lock** `7491c34` + - Closes the second half of STEER-20261102-201045-ab08. + - chat() gated the main-turn lock on nothing, so orchestrate-parallel's fan-out + - (each sub-task calling chat() on the same instance with its own agentTag) ran strictly one at a time. Measured 2x300ms delegated runs: 617ms before, 306ms now; the two 200ms user turns still take 400ms (main path stays serialised). + - the 10s lock timeout called this._chatLock = false and printed "force + - released", which cleared a lock the waiter did not own - that released the main turn's lock while it was still running. A waiter now throws and the owner is the only writer. + - delegated runs no longer clear the user's cancel flag on entry (an agent start + - used to reset this._cancelled, disarming a Stop the user had pressed). + - tool-log entries carry the run they belong to and _recentToolLog(tag, n) is + - … run `git show <sha>` for the rest ## v4.6.138 — 2026-11-02 _Touches modules (4), main.html (1) · fix 1, review 1_ -- `Chat() threw on every tool-using turn and never returned its answer` `8a9333b` -- `Guard chat()'s return and tool-turn survival` `af28b5b` +- **Chat() threw on every tool-using turn and never returned its answer** `8a9333b` + - Regression from 32608bc, caught by the .chat() caller sweep before it aged: + - `recent` was referenced in chat()'s finally block but never declared, so any + - turn with toolTurns > 0 threw ReferenceError there and chat() REJECTED. delegateTask's catch turned that into "agent failed"; the 'completed' fallback was only ever reachable on tool-free turns. + - chat() still had no return at all, so a tool-free turn resolved undefined and + - module-orchestrator.js:1250 fell through to `|| 'completed'`. + - Both fixed: `const recent` is now built from _toolCallLog, and chat() returns { content: finalAnswer, toolTurns } before the archive block (a return at the end of the method would let _archiveSession swallow the answer). + - … run `git show <sha>` for the rest +- **Guard chat()'s return and tool-turn survival** `af28b5b` + - The regression in 32608bc shipped because nothing exercised chat() itself. test-chat-return.js runs 7 assertions against the real module: a tool-using turn must not throw, must return {content, toolTurns}, the orchestrator projection must keep the answer, a tool-free turn must return too, the lock must be released after a failed turn, and a second turn must return its own answer. + - Green 7/7 on 8a9333b; against 32608bc it exits 1 (ReferenceError: recent). Auto-discovered by newos4-maintain (23/23 node tests). ## v4.6.137 — 2026-11-02 _Touches modules (6), help.html (1), main.html (1), modules-index.json (1) · release 1_ -- `Configurable tool budgets, per-model defaults, real tool telemetry` `32608bc` +- **Configurable tool budgets, per-model defaults, real tool telemetry** `32608bc` + - Closes STEER-20261102-201234-7200 (read_file capped at 6000 while the schema promised 32000). + - Budgets (module-aiassist.js + new modules/core/model-profiles.js): + - one resolver, precedence: call arg -> loop profile -> model profile -> + - ai-config -> code default. Keys: read-page-chars, read-result-chars, tool-context-budget, fetch-chars. + - model classes tiny/small/mid/large keyed by id then by size token; `mid` + - reproduces the old numbers so nothing moves for a mid model. + - _clipToolResult now clips the `content` field, never the serialised JSON + - envelope: clipping the envelope deleted truncated/next_offset, so a 16k page lost its continuation pointer (the reported symptom). + - … run `git show <sha>` for the rest ## v4.6.136 — 2026-11-02 _Touches scripts (4), modules (3), main.html (1) · release 4_ -- `No model-class gate; free allowance becomes $0.10/day` `a7cbc19` -- `R5 stale-artefact rule + dashboard queues a decision once` `89910fd` -- `Ingest report-like chat messages into the feedback queue` `b7fef93` -- `Chat plane on the dashboard + honest triage ranking` `9f0b042` +- **No model-class gate; free allowance becomes $0.10/day** `a7cbc19` + - Operator policy 2026-11-01: every subscription may call any model, and the free tier resets daily. + - Model gate removed (api/ai-gateway.py) `plan == 'free' and model_weight > 1 -> 403 "Advanced models require Pro"` ran before any money logic, so a free-plan account holding $1086 of purchased credit was refused and told to upgrade. A model tier is not something we sell: the allowance and the USD balance bound spend. A caller who cannot pay now gets the honest 429 (with period + needed amount). model_weight survives only for track_usage() analytics. + - Free allowance: $0.50/month -> $0.10/day ~$3/month retail = $2.14/month of provider cost at 1.4x markup, ~6x the previous giveaway and still bounded per member (the 50-slot invite cap bounds the total; at full occupancy ~$107/mo or $2.14/user at 100% utilisation). + - The window is policy, not code: + - … run `git show <sha>` for the rest +- **R5 stale-artefact rule + dashboard queues a decision once** `89910fd` + - Feedback queue triage 2026-11-02: 23 reports, 22 decided, 1 open -- and the open one (RPT-20261029-115241, "[Terminal] Error in ShellModule.test: {\"silent\":true}") was already fixed in v4.6.135 (4472c81, modules/core/terminal.js:1966) yet could never be cleared by automation. + - Root cause: identifiers() keeps only generic class names (Terminal, ShellModule), both of which necessarily stay in the code, so R2 (every identifier absent) cannot fire; R4 needs a stack and this report has none. A stack-less "Class.method: {json}" console.error whose emitter gets *guarded* -- rather than deleted -- therefore sits "awaiting decision" forever. + - newos4-feedback-autoreview.py: new R5 stale artefact -- auto report, no stack, + - … run `git show <sha>` for the rest +- **Ingest report-like chat messages into the feedback queue** `b7fef93` + - The chat plane (api/chat/post.php -> data/chat/*.json) was read by nothing in the feedback pipeline: 22 report-like messages over 11 days produced 0 queue items. This adds scripts/newos4-chat-ingest.py - a read-only scanner by default, --apply writes RPT-* files in the report.php schema with diagnostics.auto=false so the auto-reviewer leaves them for a human, deduped on (thread, msg_id). + - First apply filed 5 items (2 x 2026-10-28, 3 x 2026-11-02); snapshot went reports_total 23 -> 28, awaiting_decision 0 -> 5. Second run is silent. +- **Chat plane on the dashboard + honest triage ranking** `9f0b042` + - Tasks 3-8 of the chat-plane plan: + - cron newos4-chat-ingest (0cc4bb4d2f82, */10, script-only, silent when idle) + - collect.py: plan_key() normalises the two ledger spellings on read (5 rows + - carried a plan name without .md and were silently unmatched); per-item bulk_pass for the 2026-10-26 pass; counts.chat_total + - feedback.html: 5th tile "from chat"; card shows who closed an item and when, + - with a bulk marker + - triage: UNFILEABLE class -> top_real now ranks actionable errors only + - (first row went from an 18 203 fetch failure to the 989 toLowerCase runtime error); network/abort signatures move to policy_noise + - … run `git show <sha>` for the rest ## v4.6.135 — 2026-10-30 _Touches modules (6), data (6), course (2), help.html (1) · release 1, docs 1, project 1_ -- `Register `test` / `[` so chains decide locally instead of asking a model (v4.6.135)` `4472c81` -- `Regenerate after the metering and shell commits (73 entries)` `4fca1c9` -- `Untrack cron/API runtime state, keep it on disk` `8555cd4` +- **Register `test` / `[` so chains decide locally instead of asking a model (v4.6.135)** `4472c81` + - `[ -f "/qt.txt" ] && echo exists` — the probe that closed the v4.6.132 batch — was not a shell test at all: both commands were unregistered, so the line fell through to the AI fallback and a model was asked whether a file exists. `_evalTest()` has implemented `-f -d -e -z -n ! = !=` for the if/while parser since the script runner was written; only the command was missing. + - `test` and `[` registered in ShellModule. Success resolves silently; false rejects with a + - `silent` error so `&&`/`||` skip it — POSIX prints nothing on exit 1. + - `[` requires the closing bracket (`[ : missing ']'`), and that message is NOT silent. + - `test` already names the on-demand regression-suite runner ("test auto" / "test list" in + - … run `git show <sha>` for the rest +- **Regenerate after the metering and shell commits (73 entries)** `4fca1c9` +- **Untrack cron/API runtime state, keep it on disk** `8555cd4` + - These files are rewritten in place every few minutes by cron jobs and the APIs (visits.json, agent-items.json, subscribers.json, newOS4 debug/report/ maintenance state, changelog feed, course atlas...). Tracking them made `git status` useless and put runtime noise into every commit. The daily backup (admin/scripts/backup-spaces.sh) already owns this state; nothing is deleted from disk here — files stay in place, only removed from the index. + - No source files touched: api/*.py, admin/scripts/*, modules/, *.html pages staged nowhere; the two ambiguous data files (data/income-tracker.json, admin/dead-jobs.html) are deliberately left tracked. ## v4.6.132 — 2026-10-29 _Touches modules (4), CHANGELOG.md (1), data (1), main.html (1) · fix 1_ -- `Shell tokenises command lines quote-aware everywhere (v4.6.132)` `3b1773c` +- **Shell tokenises command lines quote-aware everywhere (v4.6.132)** `3b1773c` + - v4.6.131 taught the pipe stages to forward their raw text, but the shell's own wrapper still split each stage with `cmdText.split(/\s+/)` — so `grep "alpha beta" /qt.txt | wc -l` handed grep the two args `"alpha` and `beta"` and matched nothing. Measured live: the wrapper (ShellModule.init() replaces terminal.executePipeChain) is the implementation that runs, so the core fix alone is decorative. + - All six command-line split sites in modules/ext/shell.js now go through one helper, `_tokenize()`, which delegates to `security._parseArgs` — the parser the bare-command path already uses — so the two cannot drift again: + - … run `git show <sha>` for the rest ## v4.6.131 — 2026-10-29 _Touches modules (8), scripts (3), help.html (1), main.html (1) · fix 1_ -- `Pipe stage quoting, maintain ordering, help cost row, plan status (v4.6.131)` `2c6c218` +- **Pipe stage quoting, maintain ordering, help cost row, plan status (v4.6.131)** `2c6c218` + - Four items closed in one batch; three ran as parallel subagents on disjoint files, the bundle was rebuilt once by the parent (rebuild-bundles.py serialises the working tree, which carries other sessions' work — see 161372f). + - … run `git show <sha>` for the rest ## v4.6.130 — 2026-10-29 _Touches modules (5), main.html (1) · fix 1_ -- `Js keeps its string literals — dispatcher forwards the raw command line (v4.6.130)` `161372f` +- **Js keeps its string literals — dispatcher forwards the raw command line (v4.6.130)** `161372f` + - _parseArgs() treats quote characters as delimiters and drops them, so every string literal in a js expression died before eval: `js "abc".toUpperCase()` reached the module as `abc.toUpperCase()` → ReferenceError: abc is not defined. Same for `js import('fs')` → `import(fs)`. Found while verifying the v4.6.128 console fix (plan 2026-10-29_112000-newos4-parseargs-strips-js-string-literals.md). + - terminal.js: checkAndRun() records the untokenised line (`_currentRaw`), _dispatch() forwards it, + - dispatchCommand(cmd, args, rawText) passes it to the module. Security classification still runs on the tokenised form, and pipes/AI/script-runner calls pass nothing, so they behave as before. + - console.js: `js` evaluates _expr(args, rawText) — the raw remainder. `node -e` deliberately does + - … run `git show <sha>` for the rest ## v4.6.128 — 2026-10-29 _Touches modules (2), help.html (1), main.html (1) · fix 1_ -- `Js console handles promise rejections + Node-builtin hint (v4.6.128)` `75c5113` +- **Js console handles promise rejections + Node-builtin hint (v4.6.128)** `75c5113` + - evalJS() evaluated the expression bare and printed JSON.stringify(result), so a rejected thenable (e.g. `js import('fs')`) became `{}` on screen while the rejection escaped to window.onunhandledrejection and was auto-filed as a runtime bug report (RPT-20261028-172211-9c7f2adb, cluster 480x). evalJSFile() already had .then/.catch — the asymmetry was the defect. + - modules/ext/console.js: thenable branch in evalJS() awaits and catches; new + - _printError() is the single error sink for all four catch paths and appends a hint when a bare specifier fails to resolve ("'fs' is a Node built-in; the browser has no Node — use the VFS (ls/cat/run, terminal.fs) instead"). + - help.html: js / js-run rows say "browser only — no Node built-ins". + - module-loader.js + main.html: v4.6.128. + - … run `git show <sha>` for the rest ## v4.6.119 — 2026-10-27 _Touches scripts (6), modules (4), CHANGELOG.md (3), guide.html (2) · release 3, fix 2_ -- `if test …` was always false and `for` items never globbed` `e575ee8` -- `Generate the changelog from git and keep it current in maintenance` `22bb505` -- `Link the changelog in and deep-link each board fix to its build` `b26bfb8` -- `Tag builds, feed the changelog to machines, fail maintenance when it ages` `782b3db` -- `Apply the GLM-5.3 review to the changelog pipeline` `f3b2664` +- **`if test …` was always false and `for` items never globbed** `e575ee8` + - Found by running the shipped runner the way a user would, not by re-reading it. + - _evalTest() stripped [ ] but not the `test` keyword, so `if test -f /work/alpha.txt` evaluated + - the word "test" as an operator, returned false, and every such branch took the else arm — measured live: "alpha missing" for a file that exists. Both spellings work now (`test -f x`, `[ -f x ]`), plus `-d`, `-e`, `-z`, `-n`, `!` and the = / != comparisons. + - `for f in /work/*.txt` ran ONCE with $f set to the literal pattern, because only command + - arguments went through _expandGlobs. Loop items are expanded per iteration now, so the body runs once per matched file with the real path. + - _expandGlobs() falls back to readDir() when a fs has no ls(), so the helper works against either + - shape instead of silently returning the literal pattern. + - Verification, live on v4.6.119: + - … run `git show <sha>` for the rest +- **Generate the changelog from git and keep it current in maintenance** `22bb505` + - The page at spaces/agando/downloads/changelog.html was hand-built from VFS notes by the in-page agent: 11 entries, huge honest holes ("v4.6.59 - v4.6.100 unknown"), and it had stopped at v4.6.101 while the app was running v4.6.119. A docs page that lags the release reads as current, which is worse than no page. + - scripts/build-changelog.py derives every entry from git: it walks commits touching + - spaces/newos4, stamps each with the LATEST_VER that was live at that commit, and groups them into builds. Kinds map onto the page's own vocabulary (feat->release, fix->fix, test->review, docs->docs, chore/style/refactor->project), and a build's chip is its dominant kind. Commits whose prefix carries `backticks` render as <code>, escaped first. + - The page is its own template: everything visible comes from the #changelog-data JSON + - … run `git show <sha>` for the rest +- **Link the changelog in and deep-link each board fix to its build** `b26bfb8` + - The generated changelog was an orphan: no page linked to it, and nothing linked from it, so "generated and maintained" still meant "nobody lands here". + - guide.html + help.html footers and the board header now link the changelog (and the board, from + - the app side). All three verified live: HTTP 200, 1 link each. + - Every closed board card gains "fixed in <sha> · v4.6.XXX" where the version is a link to + - changelog.html#v4-6-XXX. The anchor rule is the changelog renderer's own (non-alphanumerics -> "-", lowercased), not a second guess at it. + - The page now actually jumps to a hash. Previously the list is built by JS after load, so the + - browser's own anchor scroll ran against an empty list and every deep link landed at the top. jumpToHash() runs after render and on hashchange. + - A build can legitimately be missing: v4.6.83 fixed MAINT-002 but never reached a commit (the + - … run `git show <sha>` for the rest +- **Tag builds, feed the changelog to machines, fail maintenance when it ages** `782b3db` + - Tasks 3, 4, 5, 6, 7 of the changelog plan. + - 67 build tags (v4.6.10 … v4.6.119) now exist, so a build is a ref instead of something that has + - to be re-inferred: `git log v4.6.118..v4.6.119` finally means something. + - The generator prefers tags, but via ONE `git for-each-ref` pass rather than a `git describe` per + - commit. That matters: describe-per-commit made the build 2.4-2.7 s against a 1.0-1.45 s baseline — i.e. tags made it slower, which the plan's gate said to treat as "tags bought nothing". The right fix was a bulk lookup, not dropping the tags: now 0.80-1.04 s (median 0.84 s) with output byte-identical to the untagged baseline (68 entries, same version order). + - changelog.json + feed.xml (Atom) are generated from the same page dict: 36 KB / 21 KB, 68/68 + - entries, both HTTP 200, item markup stripped so a reader gets text not HTML. + - … run `git show <sha>` for the rest +- **Apply the GLM-5.3 review to the changelog pipeline** `f3b2664` + - Verdict was APPROVE WITH CHANGES: 21 findings, 17 fixed, 3 refuted with the command that killed them, 1 kept with the audit that justifies it. The reviewer's best find was a design bug of mine, not a typo. + - The age guard was unfixable by --fix and fired on a quiet week. check_changelog() regenerated only + - on a version mismatch, then returned from the version-match branch before the age check could act, so a page older than the threshold stayed red forever with the false diagnosis "the daily job is not running". Now --fix always regenerates (0.84 s) and age is read from a new `checked` stamp separate from `generated` (content-change time), which also stops a daily 65 KB rewrite of an unchanged page. Proved: seeded 641 h -> FAIL + "run maintain --fix to refresh" -> --fix -> PASS. + - Atomic writes everywhere (temp + os.replace): nginx serves these files off disk, so an in-place + - … run `git show <sha>` for the rest ## v4.6.118 — 2026-10-27 _Touches modules (5), main.html (1) · fix 1_ -- `Send num_ctx to Ollama, and make one auto-connect failure read as one line` `3bba2c2` +- **Send num_ctx to Ollama, and make one auto-connect failure read as one line** `3bba2c2` + - Two things I had flagged as "worth your attention" and one leftover nit. + - num_ctx was never sent. Ollama's server-side default context is 2k-4k depending on the build, so + - a system prompt + 20 messages + up to 24k chars of tool results could be truncated server-side, which is exactly the "it forgot what it just read" class of report. Every Ollama request now carries options.num_ctx (default 16384, overridable via webx11_ollama_numctx, floor 512); the OpenAI-compatible branch is untouched because num_ctx is not one of its parameters. + - Automatic connect prints one outcome instead of one line per endpoint tried (7 -> 3 on a dead + - endpoint). Interactive `ollamaconnect` keeps the per-endpoint detail. + - write_file's failure message no longer echoes a trailing slash. + - Verification: + - test-ollama-numctx.js (7 asserts): generate and chat payloads carry num_ctx=16384, a stored + - … run `git show <sha>` for the rest ## v4.6.116 — 2026-10-27 _Touches modules (7), main.html (1) · fix 1, review 1_ -- `Piped grep searched the working directory instead of its input` `8b9c826` -- `Prove the mixed-batch loop ceiling stops an agent at 9 identical calls` `b09fea8` +- **Piped grep searched the working directory instead of its input** `8b9c826` + - The stdin work in 5cf5010 fixed wc/head/tail but left grep alone, and grep's no-operand branch sweeps the cwd. Measured on v4.6.115: `cat /gdir/in.txt | grep zzz` answered `other.txt:1: zzz in a cwd file` — a file that was never piped, and `cat in.txt | grep bbb` reported the match twice (`in.txt` from the cwd sweep plus `__pipebuf__` from the injected file). + - module-fs.js: when terminal._pipeInput is set and no file operand was given, grep searches the + - staged text and prints `<line>: <text>` (stdin has no filename, as in GNU). The cwd sweep stays for a bare interactive `grep x`. + - shell.js: _isPipeAware now covers only sort/cut/uniq/expand. grep/head/tail/wc read _pipeInput + - themselves, so injecting the __pipebuf__ file produced duplicate hits and named stdin results. + - Verification: + - test-shell-stdin.js 20 asserts (+4 for grep: no cwd match, non-match reports none, match prints + - … run `git show <sha>` for the rest +- **Prove the mixed-batch loop ceiling stops an agent at 9 identical calls** `b09fea8` + - MAINT-006 was the last open board item and it needed a live run, not another code argument. The fix landed in 5058d3b (the hard stop was only reachable when EVERY call in a batch was blocked, so one repeated call + one novel call per turn ran to maxTurns). + - Live probe on v4.6.116 with the deployed class and a deterministic model stub (repeat read_file + - + a fresh list_files each turn): the loop stopped at turn 9 with "⛔ main: no progress after 9 identical calls — ending loop with partial result" and returned {finalAnswer, toolTurns} with a 400-char partial answer. + - test-agent-loop-ceiling.js (9 asserts, real module via its constructor): A mixed batch stops before + - maxTurns / B all-blocked batch still stops / C a productive loop that never repeats is NOT stopped. + - RED on 52d188a (pre-fix): 8 passed, 1 FAILED — the mixed batch ran to "AI reached the 12/12 tool + - … run `git show <sha>` for the rest ## v4.6.115 — 2026-10-27 _Touches modules (7), main.html (1) · release 1_ -- `The script runner runs scripts — control flow, variables, globs, redirects, stdin` `5cf5010` +- **The script runner runs scripts — control flow, variables, globs, redirects, stdin** `5cf5010` + - MAINT-021 and MAINT-017, executed from .hermes/plans/2026-10-27_145500-newos4-script-runner.md. + - shell.js: new block parser (_parseBlocks) and executor (_runInstructions/_runSimple/_runCommand/ + - _dispatchOne). `for`/`while`/`if` bodies now run, including one-line `if [ -f x ]; then y; fi`. Variables expand through _expandVars ($VAR, ${VAR}, $?, single quotes literal), globs expand through _expandGlobs (unquoted args only, per-command), and `>`/`>>` capture the command's output into the VFS instead of being passed to the command as text. + - shell.js: a script never answers an unknown command by calling the AI any more. It prints + - `script:<path>:<line>: <cmd>: command not found` and sets $? — the AI fallback stays for typing. + - shell.js: unclosed blocks and bad `for` headers surface as `Script <path> failed: …` (the parse + - used to sit outside the try, so a bad script crashed the runner). + - … run `git show <sha>` for the rest ## v4.6.110 — 2026-10-27 _Touches modules (4), main.html (1) · fix 1_ -- `The loop guard reads the config it documents` `5425e66` +- **The loop guard reads the config it documents** `5425e66` + - MAINT-004. The guard read warnAt/blockAt/hardStopAt while one config path wrote `threshold`, and `enabled` was never consulted anywhere — writing {"enabled": false} into webx11_ai_config changed nothing at all. One resolver, _loopPolicy(), now normalises both spellings, guarantees warnAt <= blockAt < hardStopAt, and both the block and the warning are gated on enabled. + - Live on v4.6.110 (_loopPolicy called in the page): defaults {warn 3, block 6, hardstop 9}; {threshold: 4} -> {warn 2, block 4, hardstop 7}; {enabled: false, threshold: 3} -> enabled false. test-loop-policy.js: 17 asserts, RED on HEAD ("_loopPolicy is not a function"), suite 16/16. ## v4.6.109 — 2026-10-27 _Touches modules (6), main.html (1) · fix 1_ -- `Agent registration is absolute-path and honest about what it stored` `5058d3b` +- **Agent registration is absolute-path and honest about what it stored** `5058d3b` + - Three of the four agent-cluster items, each with a test that is red on HEAD. + - MAINT-008 (module-agent-registry.js + module-orchestrator.js): registerAgent took args[0] as the + - agent id and only args[1] as the capability list, while both orchestrator callers passed the command word first — so the boot log read "Agent 'register' registered with 1 capabilities" and four capabilities were stored as one. The command word is now stripped, args[1..] are joined and split on commas (both "a,b" and "a b c" work), and the callers pass [id, ...caps]. + - MAINT-007 (registry + aiassist): mkdir/rm used relative paths, so a registration made from /docs + - created /docs/agents/<id> and an unregister from any other cwd removed nothing — the leaked directories the report suspected. Every path in the registry (and the four /_sessions writes in module-aiassist.js) is absolute now. + - … run `git show <sha>` for the rest ## v4.6.108 — 2026-10-27 _Touches modules (5), main.html (1) · fix 1_ -- `Help names the owner of an undocumented command; loop lessons leave /skills` `243a766` +- **Help names the owner of an undocumented command; loop lessons leave /skills** `243a766` + - Two board items plus the context-window close-out. + - MAINT-019 (module-system.js): `help <cmd>` answered "No help available for X" for a command + - that is registered but has no help line, which reads as if the command does not exist. It now says "X — registered by <Module>, which publishes no help line for it. Try: help". A command that genuinely does not exist still gets the old message. + - MAINT-020 (module-aiassist.js): auto-generated loop lessons were written into /skills with + - their own index entries, so `skill list` filled up with system noise. Lessons now live in /lessons with /lessons/index.json; the reader still falls back to legacy loop-lesson-* entries in /skills for one release, and the one-time migration deletes them from the skills index. + - … run `git show <sha>` for the rest ## v4.6.107 — 2026-10-27 _Touches modules (8), main.html (1) · fix 1_ -- `Bound one turn's tool context, honest skill-create, quarantine a corrupt memory graph` `d4d80f0` +- **Bound one turn's tool context, honest skill-create, quarantine a corrupt memory graph** `d4d80f0` + - The three items the last review and probes left open, each with a test that is red on HEAD. + - MAINT-023 (module-aiassist.js): one agentic turn grew without limit — every role:'tool' body + - was pushed verbatim and only this.context was trimmed BETWEEN turns (measured 6 -> 112 705 tokens across 8 model calls against maxContextTokens 12 000). Observations are now clipped to maxToolResultChars (6 000) with a note saying how much was withheld and to re-read with offset/limit, and _boundToolContext() elides the oldest tool bodies once the running total passes toolContextBudgetChars (24 000), always keeping the newest 4 verbatim. The ceiling is turn-independent: the same ~26 k chars at 8 turns and at 20. + - MAINT-024 (modules/ext/skill-pack-core.js): the command printed "✅ Skill created" with + - … run `git show <sha>` for the rest ## v4.6.106 — 2026-10-27 _Touches modules (7), main.html (1) · fix 1_ -- `Name the real Ollama failure, bound auto-connect, fix the review findings` `1f07ef9` +- **Name the real Ollama failure, bound auto-connect, fix the review findings** `1f07ef9` + - Second pass over v4.6.105, driven by an independent review that reproduced each item. + - ollama-manager.js: chat() now reports the captured cause ("Ollama error: Failed to fetch", + - "…: HTTP 500") instead of the generic "No provider responded"; _lastChatError is reset per call so an old reason is never replayed. chatWithTools carries the reason in its error string. + - ollama-manager.js: _ensureConnected() bounds connect() with a deadline (connectTimeoutMs, + - default 10 s). connect() uses a bare fetch(), so a blackholed endpoint previously left the shared in-flight promise pending forever and hung every later message — the old code failed fast. The attempt is now try/finally, so a throwing terminal.log() can no longer poison _connecting and suppress the retry on the next message. + - ollama-manager.js: an automatic connect no longer silently rewrites the user's saved model; + - … run `git show <sha>` for the rest ## v4.6.105 — 2026-10-27 _Touches modules (6), main.html (1) · fix 1_ -- `Connect to Ollama on first use, honest write_file, list_files sees /sys` `d6c5cb9` +- **Connect to Ollama on first use, honest write_file, list_files sees /sys** `d6c5cb9` + - Three defects, each re-probed first and covered by a test that is red on HEAD. + - ollama-manager.js: nothing connects at boot; only the Ollama tab button called connect(), + - so a user's first message died with "Ollama not connected. Use ollamaconnect first." even when the endpoint was healthy. New _ensureConnected() runs from chat(), chatStream() and chatWithTools(): one attempt, shared by concurrent callers, a "Connecting…" hint, and only then the error — which now says the endpoint is unreachable instead of telling the user to run a command they already expected to work. + - module-aiassist.js write_file tool: fs.writeFile()'s false return was dropped and the tool + - answered "File written: …" anyway, sending the model on to a read that found nothing (MAINT-012). It now returns "Write FAILED: … — parent directory missing or target is a directory; nothing was written". + - … run `git show <sha>` for the rest ## v4.6.104 — 2026-10-27 _Touches modules (5), main.html (1) · fix 1_ -- `Never overwrite a directory node; ls honours a path after its flags` `ec9d523` +- **Never overwrite a directory node; ls honours a path after its flags** `ec9d523` + - MAINT-010 and MAINT-016, the two high items the review left open. Both were re-probed before the change and the new test is red on HEAD (5 passed, 7 FAILED). + - virtual-fs.js rename()/copy(): they assigned dst.dir.content[dst.name] with no destination + - type check, so `mv file /docs` replaced the /docs node and every child vanished while the command printed "Moved: …". Refuse when the destination is a real directory node (the virtual /sys entry is exempt). rename() also erased the file on `mv a a`, because assigning and then deleting the same key removes it; that path is now an explicit no-op. + - module-fs.js mv/cp: a refusal used to surface as "No such file or directory", which sent + - the operator looking for a missing source. The destination is now inspected first and the message says "is a directory — refusing to overwrite it". + - … run `git show <sha>` for the rest ## v4.6.103 — 2026-10-27 _Touches modules (6), data (2), scripts (2), main.html (1) · fix 1, project 1_ -- `Rollback keeps scaffolding, checkpoints cannot collide, tests gate the build` `07544cd` -- `Point the predictor closure at the shipped commit` `a5f1b92` +- **Rollback keeps scaffolding, checkpoints cannot collide, tests gate the build** `07544cd` + - Review of the 08:30 implementer run (v4.6.102, checkpoint name fix). The shipped fix was correct; these are the defects it introduced or left behind, each measured first. + - virtual-fs.js rollback(): the snapshot strips _snapshots/_sessions/.trash for bloat. Restoring + - it verbatim deleted every checkpoint, every agent session and the trash, made rollback irreversible and unrepeatable. Re-attach the live copies after the root swap. + - checkpoint(): names are millisecond-resolution, so tight calls overwrote each other + - (measured 6 calls -> 3 snapshots). Dedupe with a -N suffix, refuse names containing "/" (written but unlistable), and prune by snapshot createdAt instead of filename sort, which let a custom name ("aaa-oldest") evict newer auto-named checkpoints. + - module-predictor.js: legacy pre-v2 points have no `usable`, so a 0.079 s aborted run + - … run `git show <sha>` for the rest +- **Point the predictor closure at the shipped commit** `a5f1b92` + - The code commit was amended (39b61ad -> 07544cd) after the first stamp, so re-stamped the steering entry against the commit that actually exists. ## v4.6.102 — 2026-10-27 _Touches modules (4), main.html (1) · fix 2_ -- `Print the checkpoint name actually saved (auto)` `c768584` -- `Regression test for checkpoint name round-trip (auto)` `2be415c` +- **Print the checkpoint name actually saved (auto)** `c768584` + - Task 2 + ship of plan 2026-10-27_061724-newos4-checkpoint-name-mismatch.md. + - module-system.js `checkpoint` read the clock a second time for the printed name, so the name in "✅ Checkpoint created: <name>" differed from the name on disk by >=1ms whenever no explicit name was given. It now prints the value returned by fs.checkpoint(). try/catch and error strings unchanged. + - Bundles rebuilt (rebuild-bundles.py); LATEST_VER in module-loader.js and all three ?v= tags in main.html (core-bundle, module-loader, critical-ext-bundle) bumped v4.6.101 -> v4.6.102. + - … run `git show <sha>` for the rest +- **Regression test for checkpoint name round-trip (auto)** `2be415c` + - Task 3 of plan 2026-10-27_061724-newos4-checkpoint-name-mismatch.md. + - New modules/test/test-vfs-checkpoint.js loads the real VirtualFileSystem and SystemModule with guarded localStorage/indexedDB/window stubs and asserts: A1-A4 checkpoint('') returns the resolved name; two calls differ; the file exists under the returned name B1-B2 checkpoint-list shows the returned name; rollback(<that name>) is true C1-C4 the `checkpoint` command prints exactly the name it created (and an explicit name verbatim) + - RED before the fix (v4.6.101): FAIL C2 printed name exists on disk — printed=2026-10-27T07:31:21.575Z listed=["2026-10-27T07:31:21.574Z"] test-vfs-checkpoint: 2 passed, 8 FAILED + - GREEN after: $ node modules/test/test-vfs-checkpoint.js test-vfs-checkpoint: 10 passed, 0 failed ## v4.6.101 — 2026-10-27 _Touches modules (5), main.html (1) · release 1, fix 1_ -- `Cut/copy in Files, paste into WebDAV (Issue D2)` `dca2c3b` -- `Checkpoint() returns resolved name (auto)` `f7249ff` +- **Cut/copy in Files, paste into WebDAV (Issue D2)** `dca2c3b` + - The two panes kept separate clipboards (filemgr._clipboard vs webdav._selected), so "cut here, paste there" did not exist — the operator's last phone-test item. + - filemgr.js _openOverlay() publishes a read-only view of its clipboard on the terminal object: webx11.fileClipboard() / fileClipboardClear() / fileClipboardSet(paths, mode). webdav.js the bulk bar gains "📥 Paste N" (hidden when the clipboard is empty). Handler: for each path readFile -> _webdavRequest('PUT', dest + '/' + name, {body}); only 200/201/204 counts. For mode "cut" the local file is deleted AFTER the server accepted the bytes, never before. A failed PUT keeps the local copy, prints "📥 Paste failed: <name> — HTTP n", and leaves that path in the clipboard so "📥 Paste 1" is a retry. Successes leave it. Summary "📥 Pasted N item(s) to <path>" (+ "— M failed"), then the pane reopens there. + - … run `git show <sha>` for the rest +- **Checkpoint() returns resolved name (auto)** `f7249ff` + - Task 1 of plan 2026-10-27_061724-newos4-checkpoint-name-mismatch.md. + - virtual-fs.js checkpoint(name) generated an ISO name when none was given but returned bare `true`, so callers could not know which name the snapshot was written under. It now returns the resolved name (string). Prune (max 3) and saveToStorage() behaviour unchanged. + - Verification: $ node --check modules/core/virtual-fs.js SYNTAX OK $ node modules/test/test-vfs-checkpoint.js test-vfs-checkpoint: 9 passed, 1 FAILED (remaining FAIL was the module-system display path, fixed in the next commit) ## v4.6.99 — 2026-10-26 _Touches modules (4), main.html (1) · fix 1_ -- `Four phone-test defects — no preview close, single-file share, dead-end fullscreen, silent WebDAV move` `2023728` +- **Four phone-test defects — no preview close, single-file share, dead-end fullscreen, silent WebDAV move** `2023728` + - All four came from the operator's phone pass on v4.6.95 and each collapsed to a line of code. + - … run `git show <sha>` for the rest ## v4.6.95 — 2026-10-26 _Touches modules (4), main.html (1) · release 1_ -- `Mobile download/share UX — deferred blob revoke, multi-select, Web Share, 44px targets` `6ef379f` +- **Mobile download/share UX — deferred blob revoke, multi-select, Web Share, 44px targets** `6ef379f` + - Executes .hermes/plans/2026-10-24_051600-newos4-mobile-download-share.md (approved via the feedback dashboard; marker autonomy: safe). Front-end only, v4.6.94 -> v4.6.95. + - … run `git show <sha>` for the rest ## v4.6.94 — 2026-10-26 _Touches scripts (7), modules (3), help.html (1), main.html (1) · release 5, docs 1_ -- `Review dashboard, public guide, terminal commands (v4.6.94)` `de37884` -- `Open the Agent tab on the queue, guide out of empty states, cleaner directives` `db25ef0` -- `Preview a plan or report in a modal before deciding` `85133ab` -- `Reconstruct _docs/decisions-2026-10-23.md — two feedback reports referenced a file that did not exist` `1d8f658` -- `Filter network-class auto reports at ingest — count, don't file` `a976c35` -- `Autonomous first-pass reviewer — decide what is provable, leave the rest` `0446064` +- **Review dashboard, public guide, terminal commands (v4.6.94)** `de37884` + - Dashboard (/admin/feedback.html, admin-gated) + - User-feedback tab over the live :8908 API: list, filter, search, publish reply, + - archive/reopen. Reply is what the reporter sees in /stear -> Your reports & replies. + - Agent-items tab: 3 plans parked at autonomy:review + 19 defect reports, one click each. + - Snapshot age line + stale warning (>30 min), and the last apply outcome with failures. + - Existing /admin/ tab relabelled "Client error log" (it renders feedback_log.json, not + - the feedback store) and now links to the review dashboard. + - Decision path + - … run `git show <sha>` for the rest +- **Open the Agent tab on the queue, guide out of empty states, cleaner directives** `db25ef0` +- **Preview a plan or report in a modal before deciding** `85133ab` + - The cards only carried a title, a path and buttons — approving blind was the only option. The page cannot read .hermes/plans/*.md itself (600 darkbot, php-fpm is www-data), so the collector — already running as darkbot every 10 minutes — now embeds what a decision needs: + - plans -> excerpt (first 60 lines, 2.4 KB cap), task headings, status/implementation lines reports -> body, page url, newOS4 version, user agent, first 3 error strings + - Modal: click a card title (or, on a report, read it in place) -> title, meta line with path/size/modified/task count/decision, the text in a scrollable monospace block, and the same Approve / Reject / Noise / Resolve actions. Esc, backdrop and the X close it; on a phone it is full-screen. Cards and modal share one actButtons() helper instead of duplicating markup. + - … run `git show <sha>` for the rest +- **Reconstruct _docs/decisions-2026-10-23.md — two feedback reports referenced a file that did not exist** `1d8f658` +- **Filter network-class auto reports at ingest — count, don't file** `a976c35` + - The debug supervisor files an auto report for any failed fetch, so a flaky phone connection or a retired endpoint became a review card. One cluster held 14 407 + 1 031 occurrences and produced 3 of the 19 cards in the queue; 10 of 21 items reviewed on 2026-10-26 were noise for this reason. + - api/debug/report.php: an auto report whose *every* error is network-class (fetch failed / failed to fetch / network error / load failed / aborted / signal is aborted / ERR_INTERNET_*) now increments data/debug/network_failures.json (per day, per source path, top 40 kept) and returns 202 {filtered:"network"}. No RPT file, no card. + - … run `git show <sha>` for the rest +- **Autonomous first-pass reviewer — decide what is provable, leave the rest** `0446064` + - The operator asked: "when I don't do anything, an agent can do it for me and I can just follow up or steer it." This is that agent, deterministic (no LLM): same input, same decision, and every reason is a grep or a commit hash that can be checked. + - spaces/newos4/scripts/newos4-feedback-autoreview.py — rules, first match wins: R1 duplicate same fingerprint already decided -> resolve R2 already-fixed identifiers absent from modules/**/*.js AND `git log -S<token>` proves a commit removed them -> resolve R3 noise fetch/abort/probe patterns -> reject R4 still-present auto report + stack + identifiers still in code -> approve (max 2/day) R5 plan / R6 user report -> never decided + - … run `git show <sha>` for the rest ## v4.6.91 — 2026-10-23 _Touches modules (3), help.html (1), main.html (1) · release 1_ -- `Context window warns at 80% and says what trimming dropped (v4.6.91)` `227ee77` +- **Context window warns at 80% and says what trimming dropped (v4.6.91)** `227ee77` + - P0-5 asked what to do about a 422k-token session against a 400k threshold. There is no 400k threshold in this tree — the budgets are maxContextTokens (12000) and maxContext*2 messages — so rather than chase a number that does not exist, this applies the decision that is defensible either way: warn before the cliff, never lose history without saying so. + - 80% of the token budget prints one warning per crossing (no per-turn nagging) + - the message-count trim unshifts a marker naming how many messages left the window and quoting + - the earliest one, instead of splicing them away silently + - 'max-context-tokens (default: 2000)' now reports the real code default, 12000 + - … run `git show <sha>` for the rest ## v4.6.90 — 2026-10-23 _Touches modules (4), help.html (1), main.html (1) · fix 1_ -- `The virtual /sys entry leaked into listings, trees and counts (v4.6.88-90)` `dc0114a` +- **The virtual /sys entry leaked into listings, trees and counts (v4.6.88-90)** `dc0114a` + - One phantom entry, three symptoms: + - ls() injected 'sys' whenever it was called without a path, so 'cd _agents; ls' advertised a + - sys directory that does not exist, and list_files('_agents/sys') answered 'Directory not found' — the reported false negative. Injection is now gated on dir === root. + - tree() pushed the same virtual name into the walk and dereferenced it, throwing mid-output on + - every root listing. Guarded. + - _gatherFileInfo() walked into /sys and counted a synthetic subtree: 146 files / 114 dirs for a + - 44-file / 15-dir store. Virtual entries are skipped. + - Verified: root still lists sys; _agents and skills do not; tree('') completes; world model total (44) equals an independent walk of fs.root (44), agree: true. ## v4.6.87 — 2026-10-23 _Touches modules (2), help.html (1), main.html (1) · fix 1_ -- `Audit names broken refs instead of counting documentation (v4.6.86-87)` `f57108f` +- **Audit names broken refs instead of counting documentation (v4.6.86-87)** `f57108f` + - The reference checker ran /skill-view \S+/ over every skill's prose, so 'skill-view <name>' and a quoted example in skills-system-guide were counted as missing skills: 'Broken refs: 2' with no names, both phantom. Placeholders and quoted names are now ignored (trailing punctuation stripped first), and the survivors are listed in the audit line and in the suggestion. + - Verified: clean tree -> Broken refs: 0. A probe skill referencing 'ghost-skill' -> Broken refs: 1 (probe-broken-ref → ghost-skill), while '<name>.' and '"module-management"' in the same file are ignored. ## v4.6.85 — 2026-10-23 _Touches modules (4), help.html (1), main.html (1) · fix 1_ -- `The audit stops inventing '999d unused' (v4.6.85)` `497cd96` +- **The audit stops inventing '999d unused' (v4.6.85)** `497cd96` + - getStaleSkills() returned daysUnused=999 for a skill that had useCount 0 and no lastUsed — a placeholder rendered by the audit as '999d unused', indistinguishable from a measurement. It told the operator to archive procedural memory that had simply never been instrumented. + - Now: daysUnused null + neverUsed true when there is no lastUsed; real day count otherwise; the audit prints '(never used)'. + - Verified: probe skill with no telemetry -> {daysUnused: null, neverUsed: true}; one last used 2026-08-01 -> 83; no 999 anywhere in getStaleSkills() or the audit output. ## v4.6.84 — 2026-10-23 _Touches modules (4), help.html (1), main.html (1) · fix 1_ -- `World_model_query default path parses again (v4.6.83-84)` `a05f696` +- **World_model_query default path parses again (v4.6.83-84)** `a05f696` + - _modelDigest() returned JSON.stringify(...) cut at 4000 chars plus '…(digest truncated)'. The tool then did JSON.parse() on it, so the default detail threw 'Unexpected end of JSON input' every single call; detail:'full' worked only because it returned the object untouched. + - The digest now returns an OBJECT (_modelSnapshot) and shrinks by reducing list lengths, then by dropping whole sections with an explicit 'omitted' list. The string form is generated at the edge and is always valid JSON. + - Verified: default call -> no error, 13-key snapshot, facts answered; caps 400/1200/4000 all under cap and parseable; the residual floor at cap 200 is identity fields only. ## v4.6.82 — 2026-10-23 _Touches modules (2), help.html (1), main.html (1) · fix 1_ -- `Skill-create stops mangling what the agent wrote (v4.6.82)` `c532594` +- **Skill-create stops mangling what the agent wrote (v4.6.82)** `c532594` + - Two defects in one parser, both silent: + - content = args.slice(2).join(' ') destroyed every newline, so markdown arrived as one line + - the last argument was treated as tags whenever it contained a comma, and stayed in the body: + - a multi-line skill became a single line with ~13 bogus tags, reported as success + - Bodies can now be read from a VFS path (the only way markdown survives an argv splitter), tags are opt-in via --tags=, category via --category=. skill-patch takes a path too. + - Verified: body from file -> 6 newlines preserved, tags [alpha,beta], category testing; inline body containing four commas -> tags [], body intact. ## v4.6.81 — 2026-10-23 _Touches modules (3), api (1), help.html (1), main.html (1) · fix 1_ -- `OutboxResult scoped, markdown mime, old reply path retired (v4.6.81)` `72a94ea` +- **OutboxResult scoped, markdown mime, old reply path retired (v4.6.81)** `72a94ea` + - delegateTask declared `const outboxResult` inside the AI branch and returned it outside, so a delegation with the AI module missing threw 'outboxResult is not defined'. The catch turned that into _handleAgentFailure: the worker stayed idle with no result while spawn_agent reported ok — the reported symptom exactly. Now function-scoped, and the no-module path returns 'queued — AI module not loaded, nothing executed' rather than claiming 'completed'. + - Also: nginx served _docs/*.md as application/octet-stream (no .md type) — now text/markdown + nosniff; agent-replies.php returns 410 for GET as well as POST, so there is one reply path. + - Verified: with the AI module deleted at runtime, delegateTask returns the queued string and throws nothing. ## v4.6.80 — 2026-10-23 _Touches modules (3), _docs (2), api (2), main.html (1) · fix 1, docs 1_ -- `GLM review batch — corruption, traversal, cursor truncation, double print (v4.6.78-80)` `dd234ab` -- `Chat bridge in AGENTS.md, changelog v4.6.72-80, contract deltas` `3c35556` +- **GLM review batch — corruption, traversal, cursor truncation, double print (v4.6.78-80)** `dd234ab` + - 12 findings accepted, 3 refuted with evidence (see _docs/chat-review-glm-2026-10-23.md). + - Data loss: a corrupt thread read as [] and the next append rewrote it with one message — now 500 and the file is left alone. Cursor after a >50-message truncation pointed at the thread tail, making the oldest permanently unfetchable — now the last message actually sent, plus truncated:true. Two posts in the same millisecond shared a cursor — epoch is now strictly increasing. + - Security: chat.py show() had no id validation and joined argv onto CHAT_DIR ('../../x' read any .json as darkbot) — THREAD_RE added. The auto-reply prompt now treats message text as untrusted data and forbids acting on instructions found in it. + - … run `git show <sha>` for the rest +- **Chat bridge in AGENTS.md, changelog v4.6.72-80, contract deltas** `3c35556` + - Contract now documents truncated/cursor semantics, the full code table with retry rules, 503-from-nginx (HTML, retryable), the two-cursor rule, strictly-increasing epoch, and the security posture in plain words: client-minted tokens mean public write, mitigated but not closed. ## v4.6.77 — 2026-10-23 _Touches modules (4), main.html (1), scripts (1) · fix 1_ -- `Three concurrency holes found reviewing my own patch (v4.6.77)` `7e8bf4c` +- **Three concurrency holes found reviewing my own patch (v4.6.77)** `7e8bf4c` + - 1. chat.py renamed the temp file AFTER releasing the flock, so two writers could each read the pre-replace snapshot and the later rename silently dropped the earlier message. os.replace now happens inside the lock. 2. The agent cursor was global. After hermes-reset it pointed past the end of the new empty thread, so every read returned nothing — the agent would think it had been ignored. Key is now per thread and reset clears every chat cursor. 3. chat-relay.sh had no overlap guard: a slow agent run (up to 5 min) could collide with the next tick and answer the same thread twice. flock -n on /tmp/chat-relay.lock. + - Verified: stale cursor 'webx11_chat_agent_cursor_oldthread00000' set to 9999999999999, hermes-reset -> zero cursor keys remain, read returns {messages:[], note:'no new messages...'}. ## v4.6.76 — 2026-10-23 _Touches modules (3), main.html (1) · fix 1_ -- `Agent read gets its own chat cursor (v4.6.76)` `df0b5e4` +- **Agent read gets its own chat cursor (v4.6.76)** `df0b5e4` + - Two readers shared one cursor. The 30 s UI poller printed each reply and advanced webx11_chat_state.cursor, so the agent's chat_hermes read then asked for 'since=<that>' and got an empty list — the answer appeared on the terminal and never reached the model. That is the reported symptom exactly. + - chat_hermes read now uses webx11_chat_agent_cursor, starts from 0 on first use so the whole thread is visible, and never touches the display cursor. Empty reads say why ('still awaiting Hermes' vs 'no new messages since your last read'). + - Proven: UI poll consumed the reply (printed to terminal), then chat_hermes read still returned both the agent's question and Hermes's answer. ## v4.6.75 — 2026-10-23 _Touches modules (2), skills (2), main.html (1) · fix 1_ -- `Skill points at the served contract URL, not a VFS path (v4.6.75)` `83b399d` +- **Skill points at the served contract URL, not a VFS path (v4.6.75)** `83b399d` + - The OS4 agent reported the skill cites /_docs/chat-contract.md, which does not exist in its VFS — correct: the file lives on the host and is served over HTTP. Browser VFS and disk are two stores, so a path-only pointer is a dead end. Now names the URL. ## v4.6.74 — 2026-10-23 _Touches skills (3), modules (2), main.html (1) · docs 1_ -- `Seed a chatting-with-hermes skill (v4.6.74)` `020f519` +- **Seed a chatting-with-hermes skill (v4.6.74)** `020f519` + - A command that exists but is undocumented might as well not exist: the agent had no way to learn that `hermes` exists, that `chat` is taken, that replies are asynchronous, or that the cursor is milliseconds. Skill covers the commands, the chat_hermes tool, the failure table (401/413/429/503/507), the rules that keep it useful, and how to verify a send. + - Seeded from skill-pack-core._buildSkills() so every fresh session gets it; mirrored to skills/chatting-with-hermes.{md,json} + index.json (19 entries). ## v4.6.73 — 2026-10-23 _Touches modules (4), _docs (2), help.html (1), main.html (1) · release 1, docs 1_ -- `Hermes command, auto-poll, chat_hermes tool, token reset (v4.6.73)` `cfbc835` -- `Update brief for the agent, v4.6.57 to v4.6.73` `6163429` +- **Hermes command, auto-poll, chat_hermes tool, token reset (v4.6.73)** `cfbc835` + - Phase C as repo edits (browser edits in _mods/ do not survive a reload). + - `hermes <msg>`: sends through the chat endpoint and always prints an outcome — + - '📨 Sending…' then either '✅ sent id=… epoch=…' or a coded error with a hint (429 retry later, 503 host busy, 401 run hermes-reset). `feedback` stays as the alias. + - NOT named `chat`: ChatStudioModule already registers `chat` and dispatch walks modules + - in order, so a second `chat` was silently unreachable. Found by test, not by reading. + - `hermes-poll` / `feedback-inbox`, `hermes-reset` (token rotation). + - Poller: 30 s, paused while the tab is hidden, polls on visibilitychange, first poll + - adopts the cursor and prints nothing, cursor persisted in webx11_chat_state. + - … run `git show <sha>` for the rest +- **Update brief for the agent, v4.6.57 to v4.6.73** `6163429` + - Seven behaviour changes it has to act on, not just seven code changes: the command is `hermes` not `chat` (ChatStudio owns that name), replies arrive untyped within 30s, the cursor is epoch milliseconds, only 429 is retryable, the old reply endpoint is 410, multi-path grep/head/tail, and on-demand agents are not a fault. ## v4.6.70 — 2026-10-23 _Touches modules (4), api (4), help.html (1), main.html (1) · release 2_ -- `On-demand worker labels and a server-canonical predictor store (v4.6.70)` `e1d1167` -- `Token-scoped chat endpoints, contract, host CLI, old POST retired (v4.6.71 phase B)` `fde3ce3` +- **On-demand worker labels and a server-canonical predictor store (v4.6.70)** `e1d1167` + - Two decisions recorded by the operator on 2026-10-23, implemented. + - 6 — spawn-on-demand is the design, so stop reporting it as a defect: + - OrchestratorModule counts delegations inside delegateTask() + - status prints "Mode: on-demand — N/M worker(s) available, X delegation(s) this + - session" and drops the "spawning is not dispatching" warning + - per-agent lines read "available, no delegations yet (spawned ...)" / "N + - delegation(s), last ..." + - 10 — per-user, server file canonical, browser VFS a TTL'd read-through cache: + - … run `git show <sha>` for the rest +- **Token-scoped chat endpoints, contract, host CLI, old POST retired (v4.6.71 phase B)** `fde3ce3` + - api/chat/{lib,post,poll}.php: thread = sha256(token)[0:16]; flock read-modify-write; + - tmp+rename; ms epoch cursor; per-thread + per-IP windows with retry_after_ms; 401/413/507/405 carry a machine code, only rate_limited is retryable + - _docs/chat-contract.md: frozen shapes, incl. why the cursor is ms and not ts + - scripts/chat.py: list/show/reply, UTC, locked, atomic replace + - api/debug/agent-replies.php POST -> 410 Gone with pointers (one write path) + - Measured: 401 no-token, 201+id, poll 1 msg, poll>since 1 new, data/chat 403, 4001 chars 413 (codepoints), 429 with Retry-After 576, round trip via chat.py. ## v4.6.67 — 2026-10-23 _Touches modules (3), help.html (1), main.html (1) · fix 1_ -- `Grep, head and tail take every path argument (v4.6.67)` `8ab3972` +- **Grep, head and tail take every path argument (v4.6.67)** `8ab3972` + - Packet 1's multi-path ask was only honoured by wc in v4.6.58 — grep read only args[ai+1] and head/tail only positional[0], so `grep x a b` searched `a`, printed one file's hits, and exited 0. Same silent drop, three more commands. + - grep: loops every path; multiple files print `file:line: text`, `-c` prints one + - count per file, missing files are reported per file and excluded from the summary count; single-file output unchanged + - head: every path, `==> file <==` header when more than one; `head f 40` still works + - tail: every path with headers; a numeric second positional is still the legacy + - `tail <file> <count>`; `-f` warns and follows only the first file instead of printing all of them and claiming otherwise + - … run `git show <sha>` for the rest ## v4.6.65 — 2026-10-23 _Touches skills (3), modules (2), main.html (1) · docs 1_ -- `Merge the two shell-composition skills into one seeded skill (v4.6.65)` `bfa1ffa` +- **Merge the two shell-composition skills into one seeded skill (v4.6.65)** `bfa1ffa` + - Packet 9. `terminal-commands-silently-failing` and `command-composition-rules` described the same bug family from two angles, and since v4.6.58 both were describing behaviour that is now refused outright. + - new seeded skill `command-composition-rules` in skill-pack-core's _buildSkills(): + - the rule, why the refusal exists (with the 42-42 / 34k-a measurement), what works (one command per call, wc multi-path, interactive-only pipes, quoted operators), how to verify a plausible-looking result, and the pitfalls + - its History section names both retired skills so a reader arriving from either + - search finds the surviving one + - mirrored onto disk as skills/command-composition-rules.md + .json and added to + - skills/index.json (18 entries) so the repo copy matches what sessions receive + - … run `git show <sha>` for the rest ## v4.6.64 — 2026-10-23 _Touches modules (4), help.html (1), main.html (1) · fix 1_ -- `An invalid grep pattern returns a message, not an exception (v4.6.64)` `b45f13f` +- **An invalid grep pattern returns a message, not an exception (v4.6.64)** `b45f13f` + - Packet 8. `new RegExp(pattern)` sat unguarded in the terminal grep handler and in VFS find()/grep(), so a bad pattern threw out of the command handler into the error ledger -- the same ledger packet 2 had to stop trusting. + - module-fs.js grep: compile in try/catch, print + - "grep: invalid pattern: <pattern> (<engine message>)" as a warning and return + - virtual-fs.js find()/grep(): same guard, return [] and stash the message on + - _lastPatternError instead of throwing into the module error wrapper + - Verified live on v4.6.64 with the packet's own repro: `grep "/\bug_id\" file` prints the message, the error counter stays 0 (was: one ledger entry per call), a valid pattern still matches, and fs.grep('[') returns [] instead of throwing. ## v4.6.63 — 2026-10-23 _Touches modules (4), help.html (1), main.html (1) · release 1_ -- `Agent status says never-scheduled vs waiting; `guides` command (v4.6.63)` `1615b27` +- **Agent status says never-scheduled vs waiting; `guides` command (v4.6.63)** `1615b27` + - Packet 6 and 7. + - 6 — four agents showed `sleeping` and nothing distinguished "waiting for work" from "never handed a task". Added tasksAssigned/lastTaskAt to the agent record (counted where a task is actually assigned), and the status line now reads idle — NEVER SCHEDULED (0 tasks since 2026-10-23 16:22) idle — waiting for work (3 task(s), last 2026-10-23 16:22) parked — NEVER SCHEDULED ... plus "Dispatch: 0/3 agent(s) have ever been given a task" and a warning that spawning is not dispatching. + - 7 — /guides/ was reachable by nobody. New `guides` command lists it newest-first with sizes and the preview/cat hints; help.html gained a Guides nav entry and section with links to the published guides. + - … run `git show <sha>` for the rest ## v4.6.62 — 2026-10-23 _Touches modules (3), help.html (1), main.html (1) · fix 1_ -- `Tokenised body-aware skill search for load_skills (v4.6.62)` `3194d1c` +- **Tokenised body-aware skill search for load_skills (v4.6.62)** `3194d1c` + - Packet 4. The matcher tested the whole query as one substring against name+description+tags: word order decided the outcome ("telemetry dashboard" = 0 while "telemetry" = 1), a sentence could never match, and skill bodies were never searched at all. + - _skillTokens(): lowercase, split on non-word chars, drop <3-char words and a + - stopword list; a query made only of stopwords keeps its literal form + - scoring: name 3, description 2, tags 2, body 1 + up to 1.0 for repeated hits, + - multiplied by token coverage (0.5 + 0.5 * matched/total), +3 phrase bonus so word order can never zero a real hit + - results carry score and matchedTokens ("2/3"); top 3 by score, name as tiebreak + - zero results now name the tokens searched and the closest skill names instead of + - "No matching skills found" + - … run `git show <sha>` for the rest ## v4.6.61 — 2026-10-23 _Touches modules (6), help.html (1), main.html (1) · fix 1_ -- `One error counter for errors/dashboard/world-model (v4.6.61)` `36450fa` +- **One error counter for errors/dashboard/world-model (v4.6.61)** `36450fa` + - Packet 2 (three surfaces, three answers). Measured first: `errors` scanned /logs/current.log for [ERROR] lines, but that file only ever receives explicit terminal.log('ERROR', ...) calls -- exactly one call site in the whole app -- so it read 0. The dashboard added DebugSupervisor._errorDB plus failed tool calls (18), and the world model read the debug DB alone (11). + - terminal.countErrors() / countLogErrors(): the single counter, exposed on the one + - object all three surfaces already hold + - `errors` prints the canonical total with its breakdown, the entry list (hash, + - count, severity, message), and reports the log scan separately as a mirror + - dashboard health.errors and health.errorBreakdown derive from it + - world model telemetry.recentErrors derives from it, plus errorsBySource + - terminal.log('ERROR', ...) now also records into DebugSupervisor, so the log is a + - … run `git show <sha>` for the rest ## v4.6.58 — 2026-10-23 _Touches modules (4), help.html (1), main.html (1) · fix 1_ -- `Refuse shell composition, wc takes multiple paths (v4.6.58)` `24d189d` +- **Refuse shell composition, wc takes multiple paths (v4.6.58)** `24d189d` + - Packet 1 and 3 (silent wrong answers from a shell-less terminal). + - detectComposition() in the security gate refuses ; && || | before dispatch. + - `;` used to fail loudly, but `&&`/`||` folded the right-hand side into the left command's argv (wc -l f && grep -c x f returned "42 42"), and a `|` arriving from the AI run_command tool bypassed splitPipe entirely and degraded the pattern to empty -- which matches every line. Operators inside quotes are untouched. + - wc accepts multiple paths: one line per file plus a total, per-file errors for + - missing paths. `wc -c a b` used to print only `a` and exit 0. + - help.html: "There is no shell" callout at the top of the command reference, + - plus the v4.6.57/58 changelog entry. + - … run `git show <sha>` for the rest ## v4.6.57 — 2026-10-23 _Touches modules (6), main.html (1) · fix 1_ -- `Predictor arithmetic, deployed-source viewer, ls text output (v4.6.57)` `bece3bc` +- **Predictor arithmetic, deployed-source viewer, ls text output (v4.6.57)** `bece3bc` + - Acting on the "Request to the newOS4 team" packet (spaces/agando/guides/, re-verified by its author on 4.6.56). Three verified defects fixed: + - Packet 5 (predictor arithmetic) - module-predictor.js + - the persisted field named mape held a SUM while predictor-status divided by the + - paired count: renamed to errorSum, older payloads read through + - errorPct is stored raw; the 500% clamp is now render-only, so a 475x miss stays + - visible instead of being flattened to the ceiling + - predictions are floored at 1 s and marked floored; a floored prediction is + - consumed but never scored as 100% error (a 0 s prediction is not a prediction) + - … run `git show <sha>` for the rest ## v4.6.56 — 2026-10-23 _Touches modules (5), downloads (1), help.html (1), main.html (1) · fix 1_ -- `Measure predictor accuracy over paired predictions (v4.6.56)` `e3157bb` +- **Measure predictor accuracy over paired predictions (v4.6.56)** `e3157bb` + - The accuracy readout was structurally 0%: module-predictor.js:187 incremented the denominator on every observed run while the numerator only moved when a stored prediction existed, and predictions were stored only by the manual `predict` command (the AI tool predict_task computed one and discarded it). Replaying the AI loop's own call path with 6 tasks printed "Predictions made: 0, Accuracy: 0/6". + - pending predictions with a 15m TTL, consumed by threaded id -> task hash -> + - cli-only similarity >= 0.5; unpaired runs never enter the denominator + - duration scored within 30% or 3s (integer predictions vs fractional observations) + - errorPct capped at 500% so one miss cannot poison the mean error + - schema v2: v1 correct/total counters dropped on load, observed runs and the + - prediction counter kept; task text stored as an FNV-1a hash only + - tuning (confidence-threshold, min-data-points, pair-ttl) persists + - … run `git show <sha>` for the rest ## v4.6.55 — 2026-10-23 _Touches modules (6), api (5), data (3), scripts (3) · fix 1, release 1_ -- `Empty-pipeline crash, stacks in reports, frame in digest + dashboard (v4.6.55)` `2855c63` -- `Fix ledger + norm clustering — the loop now knows what a fix was worth` `eec18ba` +- **Empty-pipeline crash, stacks in reports, frame in digest + dashboard (v4.6.55)** `2855c63` + - Root cause: splitPipe() splits on every '|', so `help |`, `| wc -l`, `a || b` produced a stage with no argv and executePipeChain() called parts[0].toLowerCase() on undefined — 989 occurrences, first filed 2026-10-20, unfixable from the report because bug reports dropped the stack that named executePipeChain:3955. Same unguarded read existed in _classify(). + - terminal.js: _classify() returns an inert classification for empty argv; executePipeChain() + - parses each stage once, filters stages with no argv (so the last real stage stays final), warns only when the whole pipeline is empty. Last-stage test now compares against the filtered length. + - module-system.js: `bug` diagnostics include the stack (10 frames, 1200 chars); auto-filed reports + - carry it too — they are the majority of the inbox and were previously message-only. + - … run `git show <sha>` for the rest +- **Fix ledger + norm clustering — the loop now knows what a fix was worth** `eec18ba` + - Lifetime counts ranked an 8-day-old outage above today's live crash, and nothing asserted that a shipped fix actually held: a regression of a fixed bug looked exactly like a new bug. + - api/debug/ingest.php: norm_key() stores `norm` (host/port/scheme/`?v=`/`//` stripped) — the client + - hash covers the raw URL, so one outage became two signatures (`/ollama//api/tags` 18 203x and `/ollama/api/tags` 16 540x). PHP + Python implementations verified byte-identical on 5 real messages. + - scripts/newos4-ledger.py: data/maintenance/fixes.json maps norm -> {commit, fixed_at, note}, and + - verify() returns REGRESSED / WATCHING / CLOSED / UNSEEN. "Seen after the fix" alone is not a regression (a fix shipped 10d ago with one sighting 9d ago has been silent since) — regressed means seen after the fix AND recent; otherwise the silence wins. + - … run `git show <sha>` for the rest ## v4.6.54 — 2026-10-23 _Touches modules (3), data (2), downloads (2), main.html (1) · fix 1, project 1_ -- `Define VirtualFS.emptyTrash() — empty-trash threw since 2026-10-18` `563a9be` -- `Archive the delta-sync probe report (test artifact)` `235ab7c` +- **Define VirtualFS.emptyTrash() — empty-trash threw since 2026-10-18** `563a9be` + - Report RPT-20261018-164646-89352963 ("this.fs.emptyTrash is not a function") was filed by the auto-filer and never fixed: terminal.js:1777 called a method VirtualFS never defined, so 'empty-trash --confirm' threw instead of emptying. Added emptyTrash() returning true only when it removed something. + - Verified live on v4.6.54: put -> rm -> empty-trash --confirm => 'Trash emptied.'; second run => 'Trash is already empty.'; .trash entries 0. +- **Archive the delta-sync probe report (test artifact)** `235ab7c` ## v4.6.53 — 2026-10-23 _Touches data (9), api (7), modules (7), scripts (5) · release 3, fix 2, project 1_ -- `Idempotent ingest, admin-gated reads, honest counts (v4.6.52-53)` `d86d508` -- `Maintenance agent channel — triage engine, two-way replies, cron` `8a2abfb` -- `Unmeasured skills are untracked, world-model writes verified, skill audit` `6a30129` -- `Drop the one-off delta-sync probe row` `1a3dc45` -- `Dashboard triage + thread panels, mobile layout, gate error.php` `0f5fdbd` -- `Operator steering queue for the maintenance jobs` `c117065` +- **Idempotent ingest, admin-gated reads, honest counts (v4.6.52-53)** `d86d508` + - Root cause: _syncToBackend() re-sent the whole error DB with cumulative counts every 300s, and ingest.php did count += client count per batch. 33 real signatures had accumulated 116,696 "occurrences" (one self-referential row alone: 46,133). + - Backend + - ingest.php: per-(session,hash) synced map, adds only the delta, resets prev when the + - client count shrank (cleared localStorage), caps delta at 5000, bounds synced at 500 hashes and sessions at 200, normalises timestamps to +00:00, 30-day retention that also purges dropped hashes from every synced map, returns `accepted` (entries) next to `ingested` (occurrences). + - stats.php/dashboard.php: total_errors = occurrences, unique_errors = signatures, + - last-ingest card. The two numbers were inconsistent by ~3,500x. + - health.php: reports self_referential count and status=degraded instead of ok. + - … run `git show <sha>` for the rest +- **Maintenance agent channel — triage engine, two-way replies, cron** `8a2abfb` + - scripts/newos4-triage.py: deterministic collector (records vs occurrences, real vs + - noise vs self-referential, new reports since last run, checker WARN/FAIL). Untrusted strings are sanitised and prefixed DATA| before they can reach an agent prompt; state + output written under flock with mode 644. + - scripts/newos4-reply.py: appends one reply to data/maintenance/replies.json under + - flock. Hermes writes here instead of POSTing with an admin cookie, so an expired session can never look like a delivered reply. + - api/debug/agent-replies.php: GET/POST (admin-gated by nginx) half of the thread. + - api/debug/report.php: accepts thread + kind=maintenance so in-app feedback is + - distinguishable from operator bug reports. + - terminal: `feedback <message>` and `feedback-inbox` commands; help.html rows added + - … run `git show <sha>` for the rest +- **Unmeasured skills are untracked, world-model writes verified, skill audit** `6a30129` + - External review packet (os4-external-review-v4.6.51.html) triaged against the code: 2 findings confirmed and fixed, 4 could not be reproduced and were recorded as such. + - module-memory-graph.js: getStaleSkills() treated a missing lastUsed as 999 days + - unused, so skill-audit recommended archiving every skill (22 of 22). Skills with no usage telemetry are now collected by getUntrackedSkills() and never counted stale. Live: "Stale: 0" + "Untracked: 22 (no usage telemetry — NOT stale)". + - module-world-model.js: _saveToVFS() ignored fsWriteJSON()'s return value and promoted + - whatever a truthy read returned. Now write -> read-back -> byte compare -> promote, with every return checked. Regression test modules/test/test-world-model-save.js (red on the old code: promoted=true with errs=[]; green now). + - scripts/newos4-skill-audit.py: 5 mechanical probes (version x3, read_file paging + + - … run `git show <sha>` for the rest +- **Drop the one-off delta-sync probe row** `1a3dc45` +- **Dashboard triage + thread panels, mobile layout, gate error.php** `0f5fdbd` + - Triage card reads admin/docs/newos4-triage-latest.json defensively: missing/unreadable + - renders as a warning pill, never as zeros (a zero here reads as "nothing is wrong"). Freshness pill goes stale past 26h so a dead collector cannot look healthy. + - Maintenance thread merges in-app `feedback` reports (kind=maintenance) with agent + - replies from data/maintenance/replies.json into one chronological view. + - Mobile: <=640px single column, smaller type, .table-scroll wrappers, Type column + - hidden, max-width clamp. verify-mobile.py: 360/390px, light+dark -> ALL VIEWPORTS OK, page width == viewport (no horizontal overflow). + - "Last ingest" now uses max(ingested_at) instead of end($errors): after the prune the + - array is not chronological and the header contradicted the triage card by 3 days. + - … run `git show <sha>` for the rest +- **Operator steering queue for the maintenance jobs** `c117065` + - newos4-steer.py add|list|done|clear appends directives under flock with unique ids; both cron jobs read it first and mark entries consumed. Verified: two adds with distinct ids, done only affects the named entry. ## v4.6.51 — 2026-10-22 _Touches modules (4), downloads (1), main.html (1), course (1) · fix 1, docs 1_ -- `Dashboard shows the real world model and stops dying after 10 s (v4.6.51)` `c54ca24` -- `Clear the help.html command backlog, and make it detectable` `8fc4e01` +- **Dashboard shows the real world model and stops dying after 10 s (v4.6.51)** `c54ca24` + - Two defects, both verified live before/after: + - 1. The World Model panel always said "Not synthesized yet". The page had a renderer for data.worldModel and _collectDashboardData() never set it -- the model was right there in WorldModelModule._model. Now collected and mapped to the renderer's exact keys (model has modules.loaded/skills.count/memory.nodes, the page reads modules/skills/memoryNodes), plus changesSinceLastSnapshot and the recent-error sample, with an honest "not synthesized" fallback. + - 2. The page discarded any payload older than 10 s and its own 5 s auto-refresh then showed "Cannot connect to newOS4". Stale payloads are now rendered with their age ("Data as of 1:20:14 AM - 3m ago"), labelled amber past 5 min. + - … run `git show <sha>` for the rest +- **Clear the help.html command backlog, and make it detectable** `8fc4e01` + - help.html had not been touched since 2026-08-02. Auditing the page against the code found 74 commands the runtime registers but the docs never mentioned, and 4 rows documenting commands that no longer exist. + - Documented (by section): terminal 11 (dashboard --watch/--stop, usage, ps, top, df, free, history, run, sysmsg, view_module), a2a 6, AI 14 (webxai/webx/wx, context*/smart-ask, commit-*, ai-model/ai-ignore), vaultmind 4, debug 2, filesystem 2, sessions 1, memory-graph 3, modules 4, neural-vfs 1, agents 3, web 1, voice 3, config 3, extras 8 (kanban, three, course, gzip, wget, gui-*), vault 2 (encrypt/decrypt), skills 5, webdav 1, plus an alias row for h/s/ll. + - Removed ghost rows: commands, check, sandbox-kill, webdav-explorer -- none are registered anywhere in modules/; the sandbox prose no longer promises sandbox-kill. + - … run `git show <sha>` for the rest ## v4.6.50 — 2026-10-22 _Touches modules (5), main.html (1) · release 1_ -- `World-model-ask answers with the LLM, grounded in the model (v4.6.50)` `6ddaadd` +- **World-model-ask answers with the LLM, grounded in the model (v4.6.50)** `6ddaadd` + - `world-model-ask` was a 10-keyword lookup: any real question returned "Unknown query. Try: modules, skills, ...". The documented example ("what changed since yesterday") compared nothing — it restated the current snapshot. + - _modelDigest(): whitelisted snapshot (measured 942-965 chars on fixtures, + - ~3.3 KB live) + recent error sample + changesSinceLastSnapshot + - _buildAskPrompt(): "answer only from this JSON, never invent numbers, name the + - command that would produce missing data" + - ask(): connects Ollama on demand, answers via chat(), falls back to the facts + - path with a visible warning; --facts keeps the instant lookup + - _diffSincePrev(): keeps one previous snapshot in /system/world-model.prev.json + - … run `git show <sha>` for the rest ## v4.6.49 — 2026-10-22 _Touches modules (4), main.html (1) · fix 1_ -- `Coreutils flags + loop-guard arg key, both found by the agent's live run (v4.6.49)` `7f3508b` +- **Coreutils flags + loop-guard arg key, both found by the agent's live run (v4.6.49)** `7f3508b` + - Agent session surfaced two real defects: + - 1. The A4 prompt told it to use grep -n / sed -n 'N,Mp' / wc -l / head -N — none of those flags parsed: grep treated '-n' as the pattern (6/6 variants failed), wc read '-l' as a filename, head looked for a file named '-2', sed had no range parser. Fixed the tools, not just the text: grep now accepts -n/-i/-c/-v/-l (line numbers were already printed), wc accepts -l/-w/-c, head accepts -N and -n N, sed accepts Np and N,Mp, tail no longer prints a blank line for a trailing newline. Prompt re-ranked to the working forms. + - … run `git show <sha>` for the rest ## v4.6.47 — 2026-10-21 _Touches modules (3), main.html (1) · fix 1_ -- `GLM review follow-ups for AI read/context (v4.6.47)` `3fed8df` +- **GLM review follow-ups for AI read/context (v4.6.47)** `3fed8df` + - loop guard: a mutation unblocks the replay but no longer erases the repeat counter + - (identical mutating calls could loop forever) + - tool traces are per agent tag, flushed in a catch as well as on success, ring-buffered + - (first 2 + last 10) so verification reads survive + - read_file notes are metadata only (path/range/next_offset); no file bodies, no secrets, + - and web_search/web_fetch output is excluded from durable context (injection surface) + - trace is role:'system' (was 'assistant' — the model re-read it as its own answer) + - surrogate-safe pagination, 500 floor / 16000 cap, prompt limits aligned + - … run `git show <sha>` for the rest ## v4.6.46 — 2026-10-21 _Touches modules (3), main.html (1) · release 1_ -- `AI paged file reads, durable tool traces, 12k context budget, read/tool/verify prompt rules (v4.6.46)` `44d023d` +- **AI paged file reads, durable tool traces, 12k context budget, read/tool/verify prompt rules (v4.6.46)** `44d023d` + - read_file: offset/limit + JSON {total_chars,truncated,next_offset} (was a hard 8000-char cut with no resume) + - tool results now leave a per-turn trace that is persisted into session context and survives compression + - maxContextTokens 2000 -> 12000; compression keeps 8 exchanges and 400 chars/msg + - system prompt: page big files, prefer grep -n / sed -n / head, re-read + verify after every edit + - verified in the live browser: 30k file pages 0->8000->16000, prompt section present, v4.6.46 served ## v4.6.45 — 2026-10-21 _Touches modules (7), main.html (1) · fix 1_ -- `Download link uses the user's space; WebDAV share copies verified file links; preview fullscreen+share; files->WebDAV upload button (v4.6.45)` `8e7b3c5` +- **Download link uses the user's space; WebDAV share copies verified file links; preview fullscreen+share; files->WebDAV upload button (v4.6.45)** `8e7b3c5` + - download: URL built from _resolveSpaceName() (config url = logged-in user), not the page space + - WebDAV browser 🔗: shares selected FILES only, URL-encoded, HEAD-verified (200) before claiming success; + - folders offer a tar fallback instead of a guaranteed-403 link + - file manager preview: ⛶ fullscreen + 🔗 copy-link toolbar (was none) + - file manager toolbar: ☁ WebDAV publishes selected/current file via webdav-push + - modules/test/test-download-url.js: 4 PASS + - rebuild bundles, bump v4.6.44 -> v4.6.45 (loader + 3 script tags) ## v4.6.44 — 2026-10-18 _Touches modules (3), main.html (1), scripts (1) · fix 1_ -- `Stop agent accumulation on reload — stable role ids + boot reconciliation` `9034ff3` +- **Stop agent accumulation on reload — stable role ids + boot reconciliation** `9034ff3` + - spawnAgent() accepts an idHint and is idempotent; boot team uses agent-<role> + - _reanimateFromVFS() adopts newest dir per default role, fs.rm()s duplicates/orphans + - _bootAgents() awaits async VFS hydration before reconciling, then spawns only gaps + - scripts/test-agents-reload.js: 5-boot regression + hydration-race test + - v4.6.44 bundle rebuild + cache-bust ## v4.6.40 — 2026-10-18 _Touches modules (7), api (1), main.html (1) · project 1_ -- `Review fixes (bug diagnostics, trash tree, dir paste, stale capture, preview)` `05257d7` +- **Review fixes (bug diagnostics, trash tree, dir paste, stale capture, preview)** `05257d7` ## v4.6.38 — 2026-10-18 _Touches modules (3), main.html (1) · project 1_ -- `Auto-file fatal errors with 3/day quota (A5)` `dd73eee` +- **Auto-file fatal errors with 3/day quota (A5)** `dd73eee` ## v4.6.37 — 2026-10-18 _Touches modules (4), api (3), downloads (1), main.html (1) · project 2_ -- `Bug-report pipeline (report.php, bug command, HTML generator, telemetry-loop fix)` `ea96355` -- `Regenerate course atlas (adds bug command)` `923bdbd` +- **Bug-report pipeline (report.php, bug command, HTML generator, telemetry-loop fix)** `ea96355` +- **Regenerate course atlas (adds bug command)** `923bdbd` ## v4.6.36 — 2026-10-18 _Touches modules (2), main.html (1) · project 1_ -- `Fix CourseModule print-capture swallowing all command output` `3686850` +- **Fix CourseModule print-capture swallowing all command output** `3686850` ## v4.6.35 — 2026-10-18 _Touches modules (3), main.html (1) · project 1_ -- `File manager multi-select + safe paste/rename/delete (Part B)` `7823821` +- **File manager multi-select + safe paste/rename/delete (Part B)** `7823821` ## v4.6.34 — 2026-10-18 _Touches modules (3), main.html (1) · project 1_ -- `Running tool ledger in the prompt (D4)` `2f84d27` +- **Running tool ledger in the prompt (D4)** `2f84d27` ## v4.6.33 — 2026-10-18 _Touches modules (3), main.html (1) · project 1_ -- `Progress-based turn budget (D3)` `b777a5a` +- **Progress-based turn budget (D3)** `b777a5a` ## v4.6.31 — 2026-10-18 _Touches modules (3), main.html (1) · project 1_ -- `Tool-result memo cache with epoch invalidation (D2)` `fe6d198` +- **Tool-result memo cache with epoch invalidation (D2)** `fe6d198` ## v4.6.30 — 2026-10-18 _Touches modules (4), main.html (1) · project 1_ -- `Per-agent session isolation (D1)` `1c02f4c` +- **Per-agent session isolation (D1)** `1c02f4c` ## v4.6.29 — 2026-10-18 _Touches modules (4), main.html (1) · project 1_ -- `Progress-aware loop guard + agent attribution` `29de5fe` +- **Progress-aware loop guard + agent attribution** `29de5fe` ## v4.6.28 — 2026-10-18 _Touches course (6), course.html (2), scripts (2), main.html (1) · project 4_ -- `Cache-bust course.js after review fixes` `2c2461f` -- `Exclude auto-open from lesson_open telemetry, add session counts, filter synthetic sids` `9457e6c` -- `Weekly telemetry report script + Monday 09:00 cron` `3bf3694` -- `Fix P0 content errors + completion/mobile/keyboard UX (review A)` `486b10b` +- **Cache-bust course.js after review fixes** `2c2461f` +- **Exclude auto-open from lesson_open telemetry, add session counts, filter synthetic sids** `9457e6c` +- **Weekly telemetry report script + Monday 09:00 cron** `3bf3694` +- **Fix P0 content errors + completion/mobile/keyboard UX (review A)** `486b10b` ## v4.6.27 — 2026-10-18 _Touches course (20), scripts (8), modules (5), api (2) · project 3_ -- `Interactive AI course (terminal module, portal, atlas, telemetry, daily maintenance+cron) + free store card` `1bc6369` -- `First daily lesson generated, atlas 345 commands, modules-index refreshed` `7221eeb` -- `Post-exec review fixes (telemetry detail sanitize, skill-sync retry, atlas check)` `ccbf471` +- **Interactive AI course (terminal module, portal, atlas, telemetry, daily maintenance+cron) + free store card** `1bc6369` +- **First daily lesson generated, atlas 345 commands, modules-index refreshed** `7221eeb` +- **Post-exec review fixes (telemetry detail sanitize, skill-sync retry, atlas check)** `ccbf471` ## v4.6.26 — 2026-10-17 _Touches data (3), modules (2), downloads (2), scripts (2) · project 2_ -- `2026-07-29_20:45 changes` `e55f2fe` -- `Floating-ui-designer v3 — repair 12 escape-mangled JS sites, rebuild 344-cmd registry (124->344), quoting/import/preset UX fixes; add Node harness + registry generator; floating-ui skill JSON de-piped` `bb41664` +- **2026-07-29_20:45 changes** `e55f2fe` +- **Floating-ui-designer v3 — repair 12 escape-mangled JS sites, rebuild 344-cmd registry (124->344), quoting/import/preset UX fixes; add Node harness + registry generator; floating-ui skill JSON de-piped** `bb41664` ## v4.6.25 — 2026-10-08 _Touches data (3), modules (2), api (1), main.html (1) · project 1_ -- `2026-07-29_20:45 changes` `eb7462a` +- **2026-07-29_20:45 changes** `eb7462a` ## v4.6.18 — 2026-10-02 _Touches modules (2), main.html (1), scripts (1) · project 1_ -- `Nginx dav trailing-slash semantics` `89c5beb` +- **Nginx dav trailing-slash semantics** `89c5beb` + - Discovered by dav-health-check.sh E2E (10/10 pass now): + - nginx dav module REQUIRES trailing slash on MKCOL (no-slash → 409) + - and on directory DELETE (no-slash → 409/404); existing dir + slash MKCOL → 405 (the real 'already exists' signal) + - _normalizeRemotePath now PRESERVES trailing slashes (collection ops) + - _ensureDir MKCOLs with trailing slash + - _deleteFile retries with slash on 409/404 (collection delete) + - health-check expectations corrected (405 = existing, slash required) ## v4.6.16 — 2026-10-02 _Touches modules (2), main.html (1), scripts (1) · project 1_ -- `NewOS4 webdav path fix + WebX11 resilience package (2026-10-02)` `d3e5af7` +- **NewOS4 webdav path fix + WebX11 resilience package (2026-10-02)** `d3e5af7` + - Root cause fixes (mindtech incident): + - webdav.js: _normalizeRemotePath strips full URLs/space prefixes, applied + - to all request entry points + _absoluteUrl for MOVE/COPY Destinations + - _ensureDir: __list__ existence fast-path (kills MKCOL 409 storms) + - dashboard-files.js: updateFilesJson parses __list__ autoindex (no more + - '#' entries), ensureSpaceExists only re-inits on genuine 404 + - 20-test regression suite: scripts/test-webdav-paths.mjs + - Resilience: + - … run `git show <sha>` for the rest ## v4.6.10 — 2026-10-01 _Touches modules (55), skills (36), downloads (32), data (11) · project 11, fix 4, release 2_ -- `2026-07-09_06:00 changes` `97db1b5` -- `2026-07-29_20:50 changes — gunes sponsorluk page, bunq payment modal, AI platforms, news W30` `8dbbab4` -- `Add kanban module to newOS4 — todo/doing/done board with priorities` `4a3012a` -- `Register kanban in module-loader, bump to v4.6.11` `aee87f5` -- `Add kanban to packages.json, update cache version to 2026-07-29` `c1b96f7` -- `2026-07-29_20:45 changes` `79f682c` -- `Tail -n/-N flags + live follow mode (-f) with test harness` `1692f20` -- `Exact-delta assertions for tail -f follow/stop semantics` `5f17863` -- `Sed command (s///, /pat/d, Nd, -i, -n, pipe input) + help entries` `d8c63a9` -- `Ctrl+C/Esc stops tail -f follow; security whitelist gains sed` `69f1fc5` -- `Rebuilt bundles with tail -f and sed` `ec02916` -- `Typo suggestion bar (one-tap fix), consistent unknown-cmd messages, AI loop guard` `2cf0d3d` -- `Race-safe AI loop guard (depth counter), cover _capture* entry points, structured JSON tool error` `8f2fb31` -- `Parse per-call captured output for unknown-cmd error (race-free under Promise.all)` `212c05d` -- …and 4 more commit(s) in this build +- …and 4 earlier commit(s) in this build +- **Add kanban to packages.json, update cache version to 2026-07-29** `c1b96f7` +- **2026-07-29_20:45 changes** `79f682c` +- **Tail -n/-N flags + live follow mode (-f) with test harness** `1692f20` +- **Exact-delta assertions for tail -f follow/stop semantics** `5f17863` +- **Sed command (s///, /pat/d, Nd, -i, -n, pipe input) + help entries** `d8c63a9` +- **Ctrl+C/Esc stops tail -f follow; security whitelist gains sed** `69f1fc5` +- **Rebuilt bundles with tail -f and sed** `ec02916` +- **Typo suggestion bar (one-tap fix), consistent unknown-cmd messages, AI loop guard** `2cf0d3d` +- **Race-safe AI loop guard (depth counter), cover _capture* entry points, structured JSON tool error** `8f2fb31` +- **Parse per-call captured output for unknown-cmd error (race-free under Promise.all)** `212c05d` +- **Regex captures full command name + suggestion in unknown-cmd tool error** `00a6f24` +- **Reentrant print-capture stack, AI tail -f block, read-only sed in safeRead, security test harness (21 checks)** `4e57424` +- **Sequential safe-tool execution — parallel fan-out corrupted print-capture attribution + shadow diff pairing** `0c5316e` +- **2026-07-29_20:45 changes** `b1fb657` ## before v4.6.10 — 2026-07-02 _Touches modules (317), skills (161), backups (49), downloads (40) · project 47_ -- `2026-05-25_18:02 changes` `aa7bb1a` -- `2026-05-26_06:00 changes` `f37a1a8` -- `2026-05-27_06:00 changes` `d632dbc` -- `2026-05-28_06:02 changes` `c8c4089` -- `2026-05-28_18:00 changes` `0407f24` -- `2026-05-29_06:01 changes` `685a33d` -- `2026-05-29_18:01 changes` `2fb6b53` -- `2026-05-30_06:00 changes` `79e9df7` -- `2026-05-30_18:00 changes` `69d6d95` -- `2026-05-31_06:00 changes` `5128c54` -- `2026-06-01_06:00 changes` `c9a346a` -- `2026-06-01_18:00 changes` `ac50dcf` -- `2026-06-02_06:00 changes` `b086c2b` -- `2026-06-02_18:00 changes` `6ad20b8` -- …and 33 more commit(s) in this build +- …and 33 earlier commit(s) in this build +- **2026-06-15_06:00 changes** `1e964c9` +- **2026-06-15_18:00 changes** `a0afeba` +- **2026-06-16_06:00 changes** `bd9db0e` +- **2026-06-17_06:01 changes** `75e5278` +- **2026-06-17_18:01 changes** `9f82275` +- **2026-06-18_06:00 changes** `b348bd5` +- **2026-06-18_18:00 changes** `7f5129f` +- **2026-06-19_18:01 changes** `c83f38a` +- **2026-06-22_18:00 changes** `536c2ad` +- **2026-06-23_06:00 changes** `e50d590` +- **2026-06-23_18:00 changes** `bdab7cb` +- **2026-06-24_06:01 changes** `c2c71ba` +- **2026-06-25_06:00 changes** `e683a33` +- **2026-07-02_06:00 changes** `ae177bd` <!-- generated:end --> diff --git a/docs/usage.html b/docs/usage.html index f25a074..52314fa 100644 --- a/docs/usage.html +++ b/docs/usage.html @@ -291,7 +291,7 @@ can never wedge. Nothing here can push — the repository refuses <code>receive- generated from the platform's monorepo and a push to it would be erased by the next publish. Pushes go to a separate host that asks for credentials; the read-only host in your <code>origin</code> stays anonymous and read-only.</p> -<pre><code># 1. one token per account, issued by the platform operator +<pre><code># 1. one token per account: Dashboard -> "Git push token" -> Create (shown once, revocable) # (looks like: https://<user>:wx11gp_…@push.newos.webx11.duckdns.org/newos4.git) git remote add push https://push.newos.webx11.duckdns.org/newos4.git @@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.146"></script> +<script src="modules/core/core-bundle.js?v=v4.6.151"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.146"></script> +<script src="modules/core/module-loader.js?v=v4.6.151"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.146"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.151"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 8b6e4cb..4483709 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -7773,8 +7773,10 @@ class OllamaModule { // Billing / pricing surface for newOS4 (2026-10-28) // // Why: the terminal could show a model's name but never what it costs. Prices and the anonymous -// message allowance are public data, so they need no login — the account BALANCE does, and newOS4 -// has no session layer, so that is reported honestly as "not signed in" rather than guessed. +// message allowance are public data, so they need no login — the account BALANCE does. +// 2026-11-03: newOS4 DOES have a session — the browser sends the HttpOnly cookie and +// parse_bearer_token() reads it — so the balance below is real when logged in. The old comment +// claimed otherwise and made the 401 look expected. // // Commands: cost [on|off], usage class BillingModule { @@ -7828,7 +7830,9 @@ class BillingModule { } async _loadBalance() { - // Only answers when a session exists; newOS4 currently has none, so this is expected to 401. + // Cookie auth is what makes this work: the browser sends webx11_session_token and + // parse_bearer_token() falls back to it. If this 401s, the user is logged out — say so, + // do not assume newOS4 cannot authenticate (that comment was wrong until 2026-11-03). try { const r = await fetch('/api/billing/subscription', { credentials: 'include' }); if (!r.ok) return null; @@ -7839,6 +7843,16 @@ class BillingModule { } } + async _loadUsage(days) { + try { + const r = await fetch('/api/billing/usage?days=' + (days || 1), { credentials: 'include' }); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + // --- session spend, measured where the tokens actually are ---------------- _hookUsage() { const om = this.terminal && this.terminal.ollamaManager; @@ -7881,8 +7895,10 @@ class BillingModule { } } + // On by default since 2026-11-03: "the terminal shows tokens but not cost" was the default, + // not a bug — the flag was opt-in and nobody knew the command existed. `cost off` opts out. _costLineOn() { - try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; } + try { return localStorage.getItem('webx11_cost_line') !== '0'; } catch (e) { return true; } } // --- the command ---------------------------------------------------------- @@ -7910,13 +7926,20 @@ class BillingModule { p('📊 Free allowance: unavailable (billing API did not answer)', 'warning'); } - p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info'); + p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — log in at /spaces/dashboard.html to see your balance'}`, 'info'); if (bal && bal.plan_allowance) { const pa = bal.plan_allowance; p(`🎁 Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} ${pa.period === 'day' ? 'today' : 'this month'} — $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info'); } + const u = await this._loadUsage(1); + if (u && u.totals) { + p(`📅 Today (all newOS4 sessions): ${u.totals.requests} call(s), ` + + `${((u.totals.prompt_tokens + u.totals.completion_tokens) / 1000).toFixed(1)}k tokens, ` + + `≈ $${Number(u.totals.retail_usd).toFixed(2)} retail`, 'info'); + } + if (s && s.msgs) { p(`🧮 This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out ≈ $${s.cost.toFixed(4)} retail`, 'info'); Object.entries(s.byModel) @@ -9121,7 +9144,8 @@ buildSystemPrompt() { method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' }, body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' }, content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' }, -max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; max 50000)' }, +max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; a page is capped by the tool-result budget, so page with offset/next_offset for longer pages)' }, + offset: { type: 'integer', description: 'Start character offset into the extracted text (default 0). Use the next_offset from the previous reply to continue.' }, }, required: ['url'] } @@ -10350,6 +10374,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re if (keep < obj.content.length) { const c = obj.content.charCodeAt(keep - 1); if (c >= 0xD800 && c <= 0xDBFF) keep -= 1; + } else if (obj.content.length <= keep) { + // The page fits; only the envelope is fat. Do not claim a loss that + // did not happen (measured 2026-11-06: "clipped: 0 of 11800 withheld" + // appended while every character was returned). + return JSON.stringify(obj); } const hadMore = (obj.next_offset !== null && obj.next_offset !== undefined); const full = obj.content.length; @@ -11601,9 +11630,14 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re const contentType = a.content_type || (method === 'POST' ? 'application/json' : ''); try { // Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content) - const maxChars = Math.max(500, Math.min(parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'), 50000)); + // A page larger than toolResultChars-200 is a page the model never sees + // (measured 2026-11-06: asked 50 000, received 11 800, no next_offset). + const wanted = parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'); + const clipMax = this._toolLimit('toolResultChars'); + const limit = Math.max(500, Math.min(wanted, clipMax - 200)); // request window + const offset = Math.max(0, parseInt(a.offset, 10) || 0); const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract'; - const params = new URLSearchParams({ url, max_chars: String(maxChars) }); + const params = new URLSearchParams({ url, max_chars: String(Math.min(100000, offset + limit)) }); if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); } const resp = await fetch(endpoint + '?' + params.toString()); if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' }); @@ -11611,18 +11645,47 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re const err = await resp.json().catch(() => ({})); return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' }); } - // For POST/route, the response is raw text; for GET/extract it's JSON + // POST: /api/fetch returns raw text, so page it by characters. if (method === 'POST') { const text = await resp.text(); - return JSON.stringify({ method, content: text.substring(0, maxChars), content_chars: text.length, - returned_chars: Math.min(text.length, maxChars), truncated: text.length > maxChars }); + const start = Math.min(offset, text.length); + let lim = Math.max(500, Math.min(limit, clipMax - 200)); + let env; + do { + const slice = text.slice(start, start + lim); + const nextOffset = start + slice.length < text.length ? start + slice.length : null; + env = { method, offset: start, content: slice, returned_chars: slice.length, + content_chars: text.length, total_chars: text.length, source_bytes: text.length, + truncated: nextOffset !== null, next_offset: nextOffset }; + if (JSON.stringify(env).length <= clipMax || lim <= 500) break; + lim -= Math.max(1, JSON.stringify(env).length - clipMax); + } while (true); + return JSON.stringify(env); } + // GET: /api/extract carries content_chars = the true length of the extracted + // text, so offset/next_offset are exact rather than hopeful. const data = await resp.json(); - return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, maxChars), - content_chars: data.content_chars || (data.content || '').length, - returned_chars: Math.min((data.content || '').length, maxChars), - truncated: !!(data.truncated || (data.content_chars > maxChars)), - word_count: data.word_count || 0, method_proxy: data.method || 'extract' }); + const full = data.content || ''; + // UTF-16 length when the proxy supplies it: Python len() counts code points, + // JS counts UTF-16 units, and 7 astral chars were enough to leave a 7-char + // gap at the end of a 90 KB page (measured 2026-11-06). + const totalChars = Number(data.content_chars_utf16) || Number(data.content_chars) || full.length; + let start = Math.min(offset, totalChars); + if (start > 0) { const prev = full.charCodeAt(start - 1); if (prev >= 0xD800 && prev <= 0xDBFF) start += 1; } + let lim = Math.max(500, Math.min(limit, clipMax - 200)); + let env; + do { + const slice = full.slice(start, start + lim); + const nextOffset = start + slice.length < totalChars ? start + slice.length : null; + env = { method, title: data.title || '', offset: start, content: slice, returned_chars: slice.length, + content_chars: totalChars, total_chars: totalChars, + source_bytes: (data.source_bytes === undefined ? null : data.source_bytes), + truncated: nextOffset !== null, next_offset: nextOffset, + word_count: data.word_count || 0, method_proxy: data.method || 'extract' }; + if (JSON.stringify(env).length <= clipMax || lim <= 500) break; + lim -= Math.max(1, JSON.stringify(env).length - clipMax); + } while (true); + return JSON.stringify(env); } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } } case 'wpm_install': { @@ -14039,7 +14102,7 @@ class WebFetcherModule { } getHelpInfo() { return { - 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content', + 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] [--save=FILE] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content, --save writes it to the VFS', 'extract': 'extract <url> — Extract readable article content (title + body text) from a URL', }; } @@ -14103,6 +14166,21 @@ class WebFetcherModule { return; } const text = await resp.text(); + // --save: the only terminal path from a remote URL to the VFS. Without it a + // 100 KB source page can only be read through 3 000-char scrollback, which is + // what sent the in-OS agent looking for a shell redirect (reported 2026-11-06). + if (flags.save) { + const path = String(flags.save === true ? (url.split('/').pop().split('?')[0] || 'fetch.txt') : flags.save); + const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} } + const wrote = this.terminal.fs.writeFile(path, text); + if (!wrote || this.terminal.fs.readFile(path) !== text) { + this.terminal.print(`Save failed: could not write ${path}`, 'error'); + return; + } + this.terminal.print(`💾 Saved ${text.length} chars to ${path}`, 'success'); + return; + } const limit = showRaw ? text.length : 3000; this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success'); this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : '')); @@ -17464,6 +17542,18 @@ class OrchestratorModule { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); this._delegations = (this._delegations || 0) + 1; + // Parallel fan-out bookkeeping. A sub-task carries its own id, and the batch that + // spawned it hands in a sink, so the harvest below matches results by identity — + // never by a task string that a later delegation on the same agent can overwrite. + const taskId = opts.taskId || null; + const resultSink = opts.resultSink || null; + const recordResult = (status, payload) => { + if (!taskId || !resultSink || typeof resultSink.set !== 'function') return; + resultSink.set(taskId, Object.assign({ + taskId, task, status, at: new Date().toISOString() + }, payload || {})); + }; + // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { const parts = this._decomposeGoal(task); @@ -17484,38 +17574,45 @@ class OrchestratorModule { } } - const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal))); - const successes = results.filter(r => r.status === 'fulfilled').length; - const failures = results.filter(r => r.status === 'rejected').length; + const runId = 'pb' + Date.now().toString(36) + '-' + (this._parallelSeq = (this._parallelSeq || 0) + 1); + const resultSink = new Map(); + const results = await Promise.allSettled(parts.map((sub, i) => + this.delegateTask(sub.goal, { taskId: runId + ':' + i, resultSink }))); - // Result synthesis: collect outbox results from each sub-task's agent + // Result synthesis: one row per sub-task, looked up by the id handed to it. const summaries = []; for (let i = 0; i < parts.length; i++) { const sub = parts[i]; const r = results[i]; - if (r.status === 'fulfilled') { - // Find the agent that handled this sub-task - const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); - if (!agent) { - // "ok / completed (agent recycled)" told the caller a task succeeded - // when we could not even find its agent. - summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' }); - continue; - } - const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); - const body = outbox && typeof outbox.result === 'string' ? outbox.result : ''; - const snippet = body.substring(0, 200); + const subTaskId = runId + ':' + i; + const rec = resultSink.get(subTaskId); + if (rec) { summaries.push({ - task: sub.goal, agent: agent.id, - status: body ? 'ok' : 'failed', - result: body || null, - error: body ? undefined : (outbox && outbox.error) || 'no result in outbox' + task: sub.goal, + taskId: subTaskId, + agent: rec.agentId, + status: rec.status === 'ok' ? 'ok' : 'failed', + result: rec.status === 'ok' ? (rec.result || null) : null, + error: rec.status === 'ok' ? undefined : (rec.error || 'no result recorded') }); - } else { - summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); + continue; } + // The call died before it could report itself (a throw from a path that never + // reached a recordResult call): the promise state is the only evidence left. + summaries.push({ + task: sub.goal, + taskId: subTaskId, + status: 'failed', + error: (r.status === 'rejected' && ((r.reason && r.reason.message) || String(r.reason))) || 'no result recorded' + }); } + // The tally IS the summaries — never the promise states. allSettled counts + // resolutions, and a spawn refusal returns terminal.print() (undefined), which + // FULFILS: that is how "3/3 succeeded" printed over three failed rows. + const successes = summaries.filter(s => s.status === 'ok').length; + const failures = summaries.length - successes; + // Store merged result to VFS for persistence const merged = { type: 'parallel_result', @@ -17537,7 +17634,8 @@ class OrchestratorModule { const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : ''; this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info'); } - this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header'); + // The header must agree with the rows above it: ✅ only when nothing failed. + this.terminal.print((failures === 0 ? '✅' : '❌') + ' Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), failures === 0 ? 'header' : 'error'); return merged; } // Single sub-task — fall through to normal path @@ -17556,8 +17654,10 @@ class OrchestratorModule { // Auto-spawn with role-derived capabilities if (!agent) { if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { - // Try finding any non-failed agent to reuse - const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping'); + // Reuse an agent that is NOT running. A BUSY agent must never be handed a + // second task: concurrent sub-tasks then overwrite currentTask/leaseId and + // erase each other's results (reproduced 4/4 — Defect 2b). + const reuse = Object.values(this._agents).find(a => a.status !== 'busy' && a.status !== 'failed'); if (reuse) { reuse.status = 'busy'; agent = reuse; @@ -17567,11 +17667,18 @@ class OrchestratorModule { const role = this._deriveRole(task); const caps = this._deriveCapabilities(task); agent = this.spawnAgent(role, caps); - if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error'); + if (!agent) { + const msg = 'no idle agent — ' + Object.keys(this._agents).length + '/' + this._SPAWN_LIMIT + ' agents busy, refusing to share one'; + recordResult('failed', { error: msg }); + return this.terminal.print('❌ ' + msg, 'error'); + } agent.status = 'busy'; // claim immediately after spawn } } - if (!agent) return this.terminal.print('❌ No available agents', 'error'); + if (!agent) { + recordResult('failed', { error: 'no agent available' }); + return this.terminal.print('❌ No available agents', 'error'); + } // A2A Contract handshake const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; @@ -17641,6 +17748,9 @@ class OrchestratorModule { // Return result for callers that await delegateTask outboxResult = _answer; // '' when the worker returned nothing; callers see that + recordResult(_answer ? 'ok' : 'failed', _answer + ? { result: _answer, agentId: agent.id } + : { agentId: agent.id, error: 'agent produced no answer' }); // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { @@ -17652,8 +17762,10 @@ class OrchestratorModule { } else { this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info'); outboxResult = 'queued — AI module not loaded, nothing executed'; + recordResult('failed', { agentId: agent.id, error: 'AI module not loaded — nothing executed' }); } } catch (e) { + recordResult('failed', { agentId: agent.id, error: e.message || String(e) }); this._handleAgentFailure(agent.id, e.message); } diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index 0a0ea71..a3ffb6f 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -226,7 +226,8 @@ buildSystemPrompt() { method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' }, body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' }, content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' }, -max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; max 50000)' }, +max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; a page is capped by the tool-result budget, so page with offset/next_offset for longer pages)' }, + offset: { type: 'integer', description: 'Start character offset into the extracted text (default 0). Use the next_offset from the previous reply to continue.' }, }, required: ['url'] } @@ -1455,6 +1456,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re if (keep < obj.content.length) { const c = obj.content.charCodeAt(keep - 1); if (c >= 0xD800 && c <= 0xDBFF) keep -= 1; + } else if (obj.content.length <= keep) { + // The page fits; only the envelope is fat. Do not claim a loss that + // did not happen (measured 2026-11-06: "clipped: 0 of 11800 withheld" + // appended while every character was returned). + return JSON.stringify(obj); } const hadMore = (obj.next_offset !== null && obj.next_offset !== undefined); const full = obj.content.length; @@ -2706,9 +2712,14 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re const contentType = a.content_type || (method === 'POST' ? 'application/json' : ''); try { // Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content) - const maxChars = Math.max(500, Math.min(parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'), 50000)); + // A page larger than toolResultChars-200 is a page the model never sees + // (measured 2026-11-06: asked 50 000, received 11 800, no next_offset). + const wanted = parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'); + const clipMax = this._toolLimit('toolResultChars'); + const limit = Math.max(500, Math.min(wanted, clipMax - 200)); // request window + const offset = Math.max(0, parseInt(a.offset, 10) || 0); const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract'; - const params = new URLSearchParams({ url, max_chars: String(maxChars) }); + const params = new URLSearchParams({ url, max_chars: String(Math.min(100000, offset + limit)) }); if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); } const resp = await fetch(endpoint + '?' + params.toString()); if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' }); @@ -2716,18 +2727,47 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re const err = await resp.json().catch(() => ({})); return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' }); } - // For POST/route, the response is raw text; for GET/extract it's JSON + // POST: /api/fetch returns raw text, so page it by characters. if (method === 'POST') { const text = await resp.text(); - return JSON.stringify({ method, content: text.substring(0, maxChars), content_chars: text.length, - returned_chars: Math.min(text.length, maxChars), truncated: text.length > maxChars }); + const start = Math.min(offset, text.length); + let lim = Math.max(500, Math.min(limit, clipMax - 200)); + let env; + do { + const slice = text.slice(start, start + lim); + const nextOffset = start + slice.length < text.length ? start + slice.length : null; + env = { method, offset: start, content: slice, returned_chars: slice.length, + content_chars: text.length, total_chars: text.length, source_bytes: text.length, + truncated: nextOffset !== null, next_offset: nextOffset }; + if (JSON.stringify(env).length <= clipMax || lim <= 500) break; + lim -= Math.max(1, JSON.stringify(env).length - clipMax); + } while (true); + return JSON.stringify(env); } + // GET: /api/extract carries content_chars = the true length of the extracted + // text, so offset/next_offset are exact rather than hopeful. const data = await resp.json(); - return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, maxChars), - content_chars: data.content_chars || (data.content || '').length, - returned_chars: Math.min((data.content || '').length, maxChars), - truncated: !!(data.truncated || (data.content_chars > maxChars)), - word_count: data.word_count || 0, method_proxy: data.method || 'extract' }); + const full = data.content || ''; + // UTF-16 length when the proxy supplies it: Python len() counts code points, + // JS counts UTF-16 units, and 7 astral chars were enough to leave a 7-char + // gap at the end of a 90 KB page (measured 2026-11-06). + const totalChars = Number(data.content_chars_utf16) || Number(data.content_chars) || full.length; + let start = Math.min(offset, totalChars); + if (start > 0) { const prev = full.charCodeAt(start - 1); if (prev >= 0xD800 && prev <= 0xDBFF) start += 1; } + let lim = Math.max(500, Math.min(limit, clipMax - 200)); + let env; + do { + const slice = full.slice(start, start + lim); + const nextOffset = start + slice.length < totalChars ? start + slice.length : null; + env = { method, title: data.title || '', offset: start, content: slice, returned_chars: slice.length, + content_chars: totalChars, total_chars: totalChars, + source_bytes: (data.source_bytes === undefined ? null : data.source_bytes), + truncated: nextOffset !== null, next_offset: nextOffset, + word_count: data.word_count || 0, method_proxy: data.method || 'extract' }; + if (JSON.stringify(env).length <= clipMax || lim <= 500) break; + lim -= Math.max(1, JSON.stringify(env).length - clipMax); + } while (true); + return JSON.stringify(env); } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } } case 'wpm_install': { diff --git a/modules/core/module-billing.js b/modules/core/module-billing.js index 87f8dc3..4ccf196 100644 --- a/modules/core/module-billing.js +++ b/modules/core/module-billing.js @@ -1,8 +1,10 @@ // Billing / pricing surface for newOS4 (2026-10-28) // // Why: the terminal could show a model's name but never what it costs. Prices and the anonymous -// message allowance are public data, so they need no login — the account BALANCE does, and newOS4 -// has no session layer, so that is reported honestly as "not signed in" rather than guessed. +// message allowance are public data, so they need no login — the account BALANCE does. +// 2026-11-03: newOS4 DOES have a session — the browser sends the HttpOnly cookie and +// parse_bearer_token() reads it — so the balance below is real when logged in. The old comment +// claimed otherwise and made the 401 look expected. // // Commands: cost [on|off], usage class BillingModule { @@ -56,7 +58,9 @@ class BillingModule { } async _loadBalance() { - // Only answers when a session exists; newOS4 currently has none, so this is expected to 401. + // Cookie auth is what makes this work: the browser sends webx11_session_token and + // parse_bearer_token() falls back to it. If this 401s, the user is logged out — say so, + // do not assume newOS4 cannot authenticate (that comment was wrong until 2026-11-03). try { const r = await fetch('/api/billing/subscription', { credentials: 'include' }); if (!r.ok) return null; @@ -67,6 +71,16 @@ class BillingModule { } } + async _loadUsage(days) { + try { + const r = await fetch('/api/billing/usage?days=' + (days || 1), { credentials: 'include' }); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + // --- session spend, measured where the tokens actually are ---------------- _hookUsage() { const om = this.terminal && this.terminal.ollamaManager; @@ -109,8 +123,10 @@ class BillingModule { } } + // On by default since 2026-11-03: "the terminal shows tokens but not cost" was the default, + // not a bug — the flag was opt-in and nobody knew the command existed. `cost off` opts out. _costLineOn() { - try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; } + try { return localStorage.getItem('webx11_cost_line') !== '0'; } catch (e) { return true; } } // --- the command ---------------------------------------------------------- @@ -138,13 +154,20 @@ class BillingModule { p('📊 Free allowance: unavailable (billing API did not answer)', 'warning'); } - p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info'); + p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — log in at /spaces/dashboard.html to see your balance'}`, 'info'); if (bal && bal.plan_allowance) { const pa = bal.plan_allowance; p(`🎁 Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} ${pa.period === 'day' ? 'today' : 'this month'} — $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info'); } + const u = await this._loadUsage(1); + if (u && u.totals) { + p(`📅 Today (all newOS4 sessions): ${u.totals.requests} call(s), ` + + `${((u.totals.prompt_tokens + u.totals.completion_tokens) / 1000).toFixed(1)}k tokens, ` + + `≈ $${Number(u.totals.retail_usd).toFixed(2)} retail`, 'info'); + } + if (s && s.msgs) { p(`🧮 This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out ≈ $${s.cost.toFixed(4)} retail`, 'info'); Object.entries(s.byModel) diff --git a/modules/core/module-fetcher.js b/modules/core/module-fetcher.js index 45505a7..d1b7bcc 100644 --- a/modules/core/module-fetcher.js +++ b/modules/core/module-fetcher.js @@ -5,7 +5,7 @@ class WebFetcherModule { } getHelpInfo() { return { - 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content', + 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] [--save=FILE] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content, --save writes it to the VFS', 'extract': 'extract <url> — Extract readable article content (title + body text) from a URL', }; } @@ -69,6 +69,21 @@ class WebFetcherModule { return; } const text = await resp.text(); + // --save: the only terminal path from a remote URL to the VFS. Without it a + // 100 KB source page can only be read through 3 000-char scrollback, which is + // what sent the in-OS agent looking for a shell redirect (reported 2026-11-06). + if (flags.save) { + const path = String(flags.save === true ? (url.split('/').pop().split('?')[0] || 'fetch.txt') : flags.save); + const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} } + const wrote = this.terminal.fs.writeFile(path, text); + if (!wrote || this.terminal.fs.readFile(path) !== text) { + this.terminal.print(`Save failed: could not write ${path}`, 'error'); + return; + } + this.terminal.print(`💾 Saved ${text.length} chars to ${path}`, 'success'); + return; + } const limit = showRaw ? text.length : 3000; this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success'); this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : '')); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index c9162d0..1f931fd 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.146'; + const LATEST_VER = 'v4.6.151'; const CACHE = '?v=' + LATEST_VER; // Exposed so ext modules can register their own URL with the same cache-busting version // instead of an unversioned path the browser may already have cached (shell.js did). diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js index 6b8ee18..05d7392 100644 --- a/modules/core/module-orchestrator.js +++ b/modules/core/module-orchestrator.js @@ -1084,6 +1084,18 @@ class OrchestratorModule { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); this._delegations = (this._delegations || 0) + 1; + // Parallel fan-out bookkeeping. A sub-task carries its own id, and the batch that + // spawned it hands in a sink, so the harvest below matches results by identity — + // never by a task string that a later delegation on the same agent can overwrite. + const taskId = opts.taskId || null; + const resultSink = opts.resultSink || null; + const recordResult = (status, payload) => { + if (!taskId || !resultSink || typeof resultSink.set !== 'function') return; + resultSink.set(taskId, Object.assign({ + taskId, task, status, at: new Date().toISOString() + }, payload || {})); + }; + // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { const parts = this._decomposeGoal(task); @@ -1104,38 +1116,45 @@ class OrchestratorModule { } } - const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal))); - const successes = results.filter(r => r.status === 'fulfilled').length; - const failures = results.filter(r => r.status === 'rejected').length; + const runId = 'pb' + Date.now().toString(36) + '-' + (this._parallelSeq = (this._parallelSeq || 0) + 1); + const resultSink = new Map(); + const results = await Promise.allSettled(parts.map((sub, i) => + this.delegateTask(sub.goal, { taskId: runId + ':' + i, resultSink }))); - // Result synthesis: collect outbox results from each sub-task's agent + // Result synthesis: one row per sub-task, looked up by the id handed to it. const summaries = []; for (let i = 0; i < parts.length; i++) { const sub = parts[i]; const r = results[i]; - if (r.status === 'fulfilled') { - // Find the agent that handled this sub-task - const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); - if (!agent) { - // "ok / completed (agent recycled)" told the caller a task succeeded - // when we could not even find its agent. - summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' }); - continue; - } - const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); - const body = outbox && typeof outbox.result === 'string' ? outbox.result : ''; - const snippet = body.substring(0, 200); + const subTaskId = runId + ':' + i; + const rec = resultSink.get(subTaskId); + if (rec) { summaries.push({ - task: sub.goal, agent: agent.id, - status: body ? 'ok' : 'failed', - result: body || null, - error: body ? undefined : (outbox && outbox.error) || 'no result in outbox' + task: sub.goal, + taskId: subTaskId, + agent: rec.agentId, + status: rec.status === 'ok' ? 'ok' : 'failed', + result: rec.status === 'ok' ? (rec.result || null) : null, + error: rec.status === 'ok' ? undefined : (rec.error || 'no result recorded') }); - } else { - summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); + continue; } + // The call died before it could report itself (a throw from a path that never + // reached a recordResult call): the promise state is the only evidence left. + summaries.push({ + task: sub.goal, + taskId: subTaskId, + status: 'failed', + error: (r.status === 'rejected' && ((r.reason && r.reason.message) || String(r.reason))) || 'no result recorded' + }); } + // The tally IS the summaries — never the promise states. allSettled counts + // resolutions, and a spawn refusal returns terminal.print() (undefined), which + // FULFILS: that is how "3/3 succeeded" printed over three failed rows. + const successes = summaries.filter(s => s.status === 'ok').length; + const failures = summaries.length - successes; + // Store merged result to VFS for persistence const merged = { type: 'parallel_result', @@ -1157,7 +1176,8 @@ class OrchestratorModule { const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : ''; this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info'); } - this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header'); + // The header must agree with the rows above it: ✅ only when nothing failed. + this.terminal.print((failures === 0 ? '✅' : '❌') + ' Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), failures === 0 ? 'header' : 'error'); return merged; } // Single sub-task — fall through to normal path @@ -1176,8 +1196,10 @@ class OrchestratorModule { // Auto-spawn with role-derived capabilities if (!agent) { if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { - // Try finding any non-failed agent to reuse - const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping'); + // Reuse an agent that is NOT running. A BUSY agent must never be handed a + // second task: concurrent sub-tasks then overwrite currentTask/leaseId and + // erase each other's results (reproduced 4/4 — Defect 2b). + const reuse = Object.values(this._agents).find(a => a.status !== 'busy' && a.status !== 'failed'); if (reuse) { reuse.status = 'busy'; agent = reuse; @@ -1187,11 +1209,18 @@ class OrchestratorModule { const role = this._deriveRole(task); const caps = this._deriveCapabilities(task); agent = this.spawnAgent(role, caps); - if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error'); + if (!agent) { + const msg = 'no idle agent — ' + Object.keys(this._agents).length + '/' + this._SPAWN_LIMIT + ' agents busy, refusing to share one'; + recordResult('failed', { error: msg }); + return this.terminal.print('❌ ' + msg, 'error'); + } agent.status = 'busy'; // claim immediately after spawn } } - if (!agent) return this.terminal.print('❌ No available agents', 'error'); + if (!agent) { + recordResult('failed', { error: 'no agent available' }); + return this.terminal.print('❌ No available agents', 'error'); + } // A2A Contract handshake const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; @@ -1261,6 +1290,9 @@ class OrchestratorModule { // Return result for callers that await delegateTask outboxResult = _answer; // '' when the worker returned nothing; callers see that + recordResult(_answer ? 'ok' : 'failed', _answer + ? { result: _answer, agentId: agent.id } + : { agentId: agent.id, error: 'agent produced no answer' }); // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { @@ -1272,8 +1304,10 @@ class OrchestratorModule { } else { this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info'); outboxResult = 'queued — AI module not loaded, nothing executed'; + recordResult('failed', { agentId: agent.id, error: 'AI module not loaded — nothing executed' }); } } catch (e) { + recordResult('failed', { agentId: agent.id, error: e.message || String(e) }); this._handleAgentFailure(agent.id, e.message); } diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js index 200abdd..cc275df 100644 --- a/modules/ext/cryptogzip.js +++ b/modules/ext/cryptogzip.js @@ -15,7 +15,8 @@ class CryptoGzipModule { 'decrypt': 'decrypt <path.enc> <passphrase> — Decrypt a .enc file', 'tar': 'tar <src-dir> <out.tar[.gz]> — Bundle a VFS directory into a tar archive (gzip if .gz)', 'untar': 'untar <path.tar[.gz]> [dest-dir] — Extract a tar archive into VFS (auto-detects gzip)', - 'wget': 'wget <url> [filename] — Download a URL and save to VFS' + 'wget': 'wget <url> [filename] — Download a URL (via /api/fetch) and save it to VFS', + 'curl': 'curl [-s] [-o FILE] <url> — Same as wget: proxy fetch + verified VFS write' }; } @@ -342,7 +343,10 @@ class CryptoGzipModule { } // ============================================================ - // WGET — fetch URL and save to VFS (binary-safe) + // WGET — fetch a URL through the /api/fetch proxy and save to VFS + // A direct browser fetch() only works same-origin: cross-origin URLs + // throw "Failed to fetch" (CORS), measured 2026-11-06 on the served + // v4.6.150 page. /api/fetch returns the raw source as UTF-8 text. // ============================================================ async _cmdWget(args) { @@ -350,20 +354,40 @@ class CryptoGzipModule { this.terminal.print('Usage: wget <url> [filename] — Download a URL and save to VFS', 'warning'); return; } - const url = args[0]; - const filename = args[1] || url.split('/').pop() || 'download.bin'; + const url = /^https?:\/\//i.test(args[0]) ? args[0] : 'https://' + args[0]; + const filename = args[1] || url.split('/').pop().split('?')[0] || 'download.bin'; this.terminal.print(`Downloading ${url}...`, 'info'); + let text; try { - const resp = await fetch(url); - if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - const buf = await resp.arrayBuffer(); - const bytes = new Uint8Array(buf); - this._vfsWriteBytes(filename, bytes); - const sizeKb = (bytes.length / 1024).toFixed(1); - this.terminal.print(`✅ wget: ${filename} (${sizeKb} KB, ${bytes.length} bytes)`, 'success'); + const resp = await fetch('/api/fetch?url=' + encodeURIComponent(url)); + if (resp.status === 403) { + this.terminal.print('wget error: URL blocked — internal network', 'error'); + return; + } + if (!resp.ok) { + let detail = 'HTTP ' + resp.status; + try { const j = await resp.json(); detail = j.error || detail; } catch (e) {} + this.terminal.print('wget error: ' + detail, 'error'); + return; + } + text = await resp.text(); } catch (e) { - this.terminal.print(`wget error: ${e.message}`, 'error'); + this.terminal.print('wget error: ' + e.message, 'error'); + return; + } + // writeFile() returns false rather than throwing when the parent directory is + // missing — the old code printed ✅ on that path (measured: writeFile('nosuchdir/x.txt') === false). + const dir = filename.includes('/') ? filename.slice(0, filename.lastIndexOf('/')) : ''; + if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} } + if (!this.terminal.fs.writeFile(filename, text)) { + this.terminal.print(`wget error: could not write ${filename} (missing directory, or path is a directory)`, 'error'); + return; } + if (this.terminal.fs.readFile(filename) !== text) { + this.terminal.print(`wget error: write verification failed for ${filename}`, 'error'); + return; + } + this.terminal.print(`✅ wget: ${filename} (${(text.length / 1024).toFixed(1)} KB, ${text.length} chars)`, 'success'); } // ============================================================ @@ -582,6 +606,22 @@ class CryptoGzipModule { this.commands['tar'] = async (args) => self._cmdTar(args); this.commands['untar'] = async (args) => self._cmdUntar(args); this.commands['wget'] = async (args) => self._cmdWget(args); + // curl — the flags an agent actually types. No shell semantics: -s/-L/-f are + // accepted and ignored, -o/--output selects the VFS target. Delegates to the + // single verified writer above so the two cannot drift. + this.commands['curl'] = async (args) => { + const positional = []; + let out = null; + for (let i = 0; i < args.length; i++) { + const a = String(args[i]); + if (a === '-o' || a === '-O' || a === '--output') { out = args[++i]; } + else if (a.startsWith('-')) { /* accepted, ignored */ } + else positional.push(a); + } + if (!positional[0]) { this.terminal.print('Usage: curl [-s] [-o FILE] <url>', 'warning'); return; } + const target = out || (positional[0].split('/').pop().split('?')[0] || 'download.bin'); + return self._cmdWget([positional[0], target]); + }; this.commandsMeta = { gzip: { argType: 'file', description: 'Compress a VFS file to .gz' }, gunzip: { argType: 'file', description: 'Decompress a .gz file (binary-safe)' }, @@ -589,7 +629,8 @@ class CryptoGzipModule { decrypt: { argType: 'file', description: 'Decrypt a .enc file' }, tar: { argType: 'file', description: 'Bundle a VFS directory into a tar archive' }, untar: { argType: 'file', description: 'Extract a tar archive into VFS' }, - wget: { argType: 'url', description: 'Download a URL and save to VFS' } + wget: { argType: 'url', description: 'Download a URL and save to VFS' }, + curl: { argType: 'url', description: 'curl [-s] [-o FILE] <url> — same as wget' } }; } } diff --git a/modules/ext/media.js b/modules/ext/media.js index 5723e27..bdc50e2 100644 --- a/modules/ext/media.js +++ b/modules/ext/media.js @@ -1,13 +1,13 @@ // newOS4 Media Analysis Module (v4.5.79) // Commands: analyze <path> [--flags] // Uses gemini-3-flash-preview:cloud for vision (images, video frames) -// Uses deepseek-v4-flash:cloud for text analysis (audio features, summaries) +// Uses deepseek-v4.1-flash:cloud for text analysis (audio features, summaries) class MediaModule { constructor() { this.commands = {}; this.terminal = null; this._visionModel = 'gemini-3-flash-preview:cloud'; - this._textModel = 'deepseek-v4-flash:cloud'; + this._textModel = 'deepseek-v4.1-flash:cloud'; this._maxFrames = 10; // max video frames to analyze this._frameInterval = 5; // seconds between video frames } @@ -118,7 +118,7 @@ class MediaModule { } // Send features to text model for AI interpretation - this.terminal.print('\n🤖 AI interpretation (deepseek-v4-flash)...', 'info'); + this.terminal.print('\n🤖 AI interpretation (deepseek-v4.1-flash)...', 'info'); const prompt = `Analyze these audio features and describe what the audio likely sounds like. Consider the genre, mood, and characteristics: Duration: ${features.duration.toFixed(1)}s diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index a97a8bc..fa42b96 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -976,12 +976,20 @@ class WebDAVModule { }; const mount = document.getElementById('wdav-tg-mount'); // A Telegram widget cannot log anyone in inside an Android WebView or an in-app - // browser: it needs a popup that can postMessage back to the opener, and those hosts - // hand the link to the system browser, which has no opener. Measured 2026-11-02 (the - // operator's phone UA ends "...; wv)"): every attempt landed in Chrome and died there. - // In that case skip the widget entirely and start the code flow, which needs no popup. - const _tgWV = /;\s*wv\)/.test(navigator.userAgent || '') || - /\bTelegram\b/i.test(navigator.userAgent || ''); + // browser: tapping it hands the whole tab to oauth.telegram.org and must come back to + // this page with #tgAuthResult. Those hosts either hand the navigation to the system + // browser (no way back) or never return. Measured 2026-11-03, operator's phone. + // Fallback only: modules/wx11-tg-webview.js is the source of truth when the host page + // loaded it (the dashboard does). This inline copy missed "Version/4.0" WebViews — the + // operator's own phone — until 2026-11-03, and the Meta/Line classes until the review + // that followed. Probe: /tg-widget-test.html. + const _ua = navigator.userAgent || ''; + const _tgWV = (window.WX11TG && window.WX11TG.isWebView) + ? window.WX11TG.isWebView() + : (/;\s*wv\)/.test(_ua) || + (/\bVersion\/4\.0\b/.test(_ua) && /Android/i.test(_ua) && !/\bSamsungBrowser\b/.test(_ua)) || + /\bTelegram\b/i.test(_ua) || + /\bFBAN\b|\bFBAV\b|\bInstagram\b|\bLine\//i.test(_ua)); const tw = document.createElement('script'); tw.src = 'https://telegram.org/js/telegram-widget.js?22'; tw.setAttribute('data-telegram-login', 'roducbot'); diff --git a/modules/test/test-orchestrator-parallel-harvest.js b/modules/test/test-orchestrator-parallel-harvest.js new file mode 100644 index 0000000..4381699 --- /dev/null +++ b/modules/test/test-orchestrator-parallel-harvest.js @@ -0,0 +1,132 @@ +// node modules/test/test-orchestrator-parallel-harvest.js +// Defect 2b (2026-11-02/03): orchestrate-parallel reported success it could not prove. +// +// Three separate lies, all in one block: +// 1. The harvest re-found each sub-task's agent by task STRING (`currentTask === sub.goal || +// lastTask === sub.goal`). An agent that had already moved on to another sub-task no longer +// carried the old goal, so a task that had demonstrably finished was dropped as +// "agent not found — result unverifiable". +// 2. The "N/N succeeded" header counted Promise.allSettled resolutions, not outcomes — a spawn +// refusal returns terminal.print() (undefined), which FULFILS, so a batch where nothing ran +// printed green. +// 3. At pool cap, delegateTask stole a BUSY agent, so two sub-tasks overwrote +// currentTask/leaseId and erased each other's results. +// +// This file pins the contract: results are harvested by sub-task id, the tally comes from the +// summaries, and a sub-task that could not get an agent is a failure, never a green row. +const fs = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..', 'core'); +// Point ORCH_SRC at modules/core/core-bundle.js to run these assertions against the DEPLOYED +// artifact instead of the source file — a green source test says nothing about the bundle. +const SRC_FILE = process.env.ORCH_SRC ? path.resolve(process.env.ORCH_SRC) : path.join(ROOT, 'module-orchestrator.js'); + +const store = {}; +global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; +global.window = global; +global.addEventListener = () => {}; + +const Orch = new Function(fs.readFileSync(SRC_FILE, 'utf8') + '\nreturn OrchestratorModule;')(); + +let fails = 0, passes = 0; +const ok = (n, c, d) => { if (c) { passes++; return; } fails++; console.log('FAIL ' + n + (d ? ' — ' + d : '')); }; + +// A VFS stub that behaves like the real one where it matters: paths normalise, and +// fsReadJSON returns null (never throws) for a missing/corrupt file. +function mkEnv(opts) { + const prints = []; + const files = new Map(); + const norm = p => String(p).replace(/^\/+/, ''); + const term = { + prints, + print(msg, type) { prints.push({ msg: String(msg), type: type || '' }); }, + fs: { + readFile: p => (files.has(norm(p)) ? files.get(norm(p)) : null), + writeFile: (p, c) => { files.set(norm(p), c); return true; }, + readDir: () => true, + mkdir: () => true, + rm: () => true, + fsReadJSON: p => { try { const raw = files.get(norm(p)); return raw === undefined ? null : JSON.parse(raw); } catch (e) { return null; } }, + }, + moduleManager: { modules: {}, _setInterval: () => 0, _setTimeout: () => 0 }, + ollamaManager: null, + }; + const ai = { _sessions: new Map(), chat: opts.chat, terminal: term }; + term.moduleManager.modules['WebX11AIAssistantModule'] = ai; + + const orch = Object.create(Orch.prototype); + orch.terminal = term; + orch.commands = {}; + orch._agents = {}; + orch._a2aMessages = {}; + orch._eventBus = {}; + orch._restartBackoffs = {}; + orch._delegations = 0; + orch._SPAWN_LIMIT = opts.spawnLimit || 5; + orch._RESTART_POLICY = 'none'; + return { orch, term, files, prints }; +} + +const GOAL = 'read alpha log and write beta report and check gamma status'; +const tick = (ms) => new Promise(r => setTimeout(r, ms || 5)); + +(async () => { + // ── T1: three independent sub-tasks are harvested by identity, not by task string ── + { + const { orch, files, prints } = mkEnv({ + chat: async (msgs) => { await tick(); return { content: 'ANSWER[' + msgs[0] + ']', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const decomposed = prints.some(p => /Decomposed into 3 sub-tasks/.test(p.msg)); + ok('T1a the goal decomposes into 3 sub-tasks (harness precondition)', decomposed, prints.map(p => p.msg).join(' | ')); + ok('T1b all three sub-tasks are harvested', merged.summaries.length === 3, 'summaries=' + merged.summaries.length); + ok('T1c every sub-task carries its own answer', merged.summaries.every(s => s.status === 'ok' && /^ANSWER\[\[DELEGATED\]/.test(s.result || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.result]))); + ok('T1d no sub-task is discarded as unverifiable', !merged.summaries.some(s => /agent not found/.test(s.error || '')), JSON.stringify(merged.summaries.map(s => s.error))); + ok('T1e successes come from the summaries', merged.successes === 3 && merged.failures === 0, 'successes=' + merged.successes + ' failures=' + merged.failures); + const onDisk = JSON.parse(files.get('orchestrator/parallel-results.json')); + ok('T1f the persisted payload agrees with the returned tally', onDisk.successes === merged.successes && onDisk.summaries.length === 3, JSON.stringify({ onDisk: onDisk.successes, merged: merged.successes })); + ok('T1g each summary is keyed by its own sub-task id', new Set(merged.summaries.map(s => s.taskId)).size === 3, JSON.stringify(merged.summaries.map(s => s.taskId))); + } + + // ── T2: at pool cap a sub-task is REFUSED, never run on a busy agent ── + // Old behaviour: all three ran on the one agent, clobbering each other, and the header + // still read "3/3 succeeded" while two summaries said failed. + { + const { orch, prints } = mkEnv({ + spawnLimit: 1, + chat: async () => { await tick(10); return { content: 'single-agent answer', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const oks = merged.summaries.filter(s => s.status === 'ok').length; + ok('T2a only the one agent that was free produced a result', oks === 1 && merged.successes === 1, 'ok=' + oks + ' successes=' + merged.successes); + ok('T2b the refused sub-tasks are reported as failures with a reason', merged.failures === 2 && merged.summaries.filter(s => s.status === 'failed').every(s => /idle|busy|limit/i.test(s.error || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.error]))); + ok('T2c the tally never exceeds the green rows', !merged.summaries.some(s => s.status !== 'ok') || merged.successes < merged.total, 'successes=' + merged.successes + '/' + merged.total); + const header = prints.map(p => p.msg).find(m => /Parallel batch:/.test(m)) || ''; + ok('T2d the header reports the real outcome', /1\/3 succeeded, 2 failed/.test(header), header); + ok('T2e a batch with failures is not printed as a green header', !/^✅/.test(header), header); + } + + // ── T3: a worker that answers nothing is a failure, not a success ── + { + const { orch } = mkEnv({ + chat: async (msgs) => { await tick(); return { content: /beta/.test(msgs[0]) ? '' : 'ok answer', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const silent = merged.summaries.filter(s => /beta/.test(s.task)); + ok('T3a the silent worker is a failed row', silent.length === 1 && silent[0].status === 'failed', JSON.stringify(silent)); + ok('T3b successes count the real answers only', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures); + } + + // ── T4: a thrown sub-task is not swallowed, and does not inflate the tally ── + { + const { orch } = mkEnv({ + chat: async (msgs) => { await tick(); if (/gamma/.test(msgs[0])) throw new Error('worker exploded'); return { content: 'fine', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const bad = merged.summaries.filter(s => /gamma/.test(s.task)); + ok('T4a the throwing sub-task is a failed row with its error', bad.length === 1 && bad[0].status === 'failed' && /worker exploded/.test(bad[0].error || ''), JSON.stringify(bad)); + ok('T4b the tally reflects 2 real answers out of 3', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures); + } + + console.log('test-orchestrator-parallel-harvest: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py index 8973761..391581b 100644 --- a/scripts/build-changelog.py +++ b/scripts/build-changelog.py @@ -54,20 +54,44 @@ def git(*args): def commits(): - """Oldest first: [{sha, date, subject, files:[...]}]""" + """Oldest first: [{sha, date, subject, body, files:[...]}] + + `%b` is read, not just `%s`: the shipped fixes carry their symptom -> cause -> fix -> + verified prose in the commit BODY (5b55af9 is a typical one), so a subject-only changelog + rendered "one-line summaries" for every entry and made README's claim about this file + false as served. The body is terminated by %x02 because it is multi-line and the parser + below is line-based; without a terminator a body line is indistinguishable from a path. + """ raw = git('log', '--reverse', '--date=iso-strict', - '--format=%x01%H%x01%aI%x01%s', '--name-only', '--', APP_REL) - out, cur = [], None + '--format=%x01%H%x01%aI%x01%s%x01%b%x02', '--name-only', '--', APP_REL) + out, cur, where = [], None, 'body' for line in raw.split('\n'): if line.startswith('\x01'): - _, sha, iso, subject = line.split('\x01', 3) - cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, 'files': []} + parts = line.split('\x01', 4) + if len(parts) < 5: + continue + _, sha, iso, subject, line = parts + cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, + 'body': [], 'files': []} out.append(cur) - elif line.strip() and cur is not None: + where = 'body' + if cur is None: + continue + if where == 'body': + if '\x02' in line: + head = line.split('\x02', 1)[0] + if head.strip(): + cur['body'].append(head) + where = 'files' + else: + # Blank lines are kept: they are the paragraph boundary the renderer splits on. + cur['body'].append(line.rstrip()) + elif line.strip(): cur['files'].append(line.strip()) + for row in out: + row['body'] = '\n'.join(row['body']).strip() return out - _TAGMAP = None @@ -132,8 +156,84 @@ def clean(subject): """ body = subject.split(':', 1)[1].strip() if ':' in subject else subject body = body[0].upper() + body[1:] if body else subject - return re.sub(r'`([^`]+)`', lambda m: '<code>' + html.escape(m.group(1)) + '</code>', - html.escape(body)) + return inline(body) + + +def inline(text): + """Escape, then backticks -> <code>. Shared by subjects and body prose. + + Escape ONCE, before wrapping: escaping the captured group again double-encodes any + `&`/`<`/`>` that sits inside a code span (measured: the "run `git show <sha>`" marker + shipped as &lt;sha&gt; and reached CHANGELOG.md as <sha>). + """ + return re.sub(r'`([^`]+)`', lambda m: '<code>' + m.group(1) + '</code>', html.escape(text)) + + +# A commit body can run to 2 KB and this page lists every build, so body detail is capped and +# the cut is MARKED: a reader must be able to tell a summary from a full transcript. +DETAIL_MAX_CHARS = 900 +DETAIL_MAX_BLOCKS = 8 + + +def detail_blocks(text): + """Commit body -> [('p'|'li', plain text)], blank-line separated. + + Bullet runs become list items (they read as the symptom/cause/fix/verified lines this + project writes); wrapped prose lines are rejoined into one paragraph. Without %b the + changelog could only ever show a subject, whatever the commit actually documented. + """ + blocks = [] + for chunk in re.split(r'\n\s*\n', (text or '').strip()): + lines = [ln.strip() for ln in chunk.splitlines() if ln.strip()] + if not lines: + continue + if all(re.match(r'^[-*\u2022]\s+', ln) for ln in lines): + for ln in lines: + blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln))) + continue + prose = [] + for ln in lines: + if re.match(r'^[-*\u2022]\s+', ln): + if prose: + blocks.append(('p', ' '.join(prose))) + prose = [] + blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln))) + else: + prose.append(ln) + if prose: + blocks.append(('p', ' '.join(prose))) + + out, used = [], 0 + for kind, body in blocks: + if len(out) >= DETAIL_MAX_BLOCKS or used + len(body) > DETAIL_MAX_CHARS: + out.append(('p', '\u2026 run `git show <sha>` for the rest')) + break + out.append((kind, body)) + used += len(body) + return out + + +def detail_html(blocks): + """Collapsible detail inside the list item: native <details>, so the page's renderer + (item.innerHTML = html) needs no change and a reader can ignore it.""" + if not blocks: + return '' + out, in_ul = ['<details><summary>detail</summary>'], False + for kind, body in blocks: + if kind == 'li': + if not in_ul: + out.append('<ul>') + in_ul = True + out.append('<li>%s</li>' % inline(body)) + else: + if in_ul: + out.append('</ul>') + in_ul = False + out.append('<p>%s</p>' % inline(body)) + if in_ul: + out.append('</ul>') + out.append('</details>') + return ''.join(out) def build(): @@ -168,11 +268,16 @@ def build(): entries = [] for ver, group in releases.items(): items = [] - for row in group[:MAX_ITEMS]: - items.append('<strong>%s</strong> <code>%s</code>' % ( - clean(row['subject']), html.escape(row['sha'][:7]))) + # The NEWEST commits of a build, not the oldest: group is chronological, so group[:N] + # showed the beginning of a 17-commit build and dropped the fixes that shipped last — + # measured 2026-11-06 when this fix's own commit was cut off its own entry. + shown = group[-MAX_ITEMS:] if len(group) > MAX_ITEMS: - items.append('…and %d more commit(s) in this build' % (len(group) - MAX_ITEMS)) + items.append('…and %d earlier commit(s) in this build' % (len(group) - MAX_ITEMS)) + for row in shown: + items.append('<strong>%s</strong> <code>%s</code>%s' % ( + clean(row['subject']), html.escape(row['sha'][:7]), + detail_html(detail_blocks(row.get('body'))))) kinds = Counter(kind_of(r['subject']) for r in group) # The dominant kind makes the chip honest: a build that was 9 fixes and 1 # feature is a fix build, not a feature release. @@ -189,7 +294,7 @@ def build(): 'note': 'Touches ' + ', '.join('%s (%d)' % (a, n) for a, n in areas.most_common(4)) + ' · ' + ', '.join('%s %d' % (k, n) for k, n in kinds.most_common(3)), 'sources': sorted({f for r in group for f in r['files'][:1]})[:4], - 'commits': [r['sha'][:7] for r in group[:MAX_ITEMS]], + 'commits': [r['sha'][:7] for r in shown], }) entries.reverse() # newest build first @@ -206,9 +311,11 @@ def build(): 'builds whose tree reached a commit can appear here. Read a jump in the ' 'version chip as "those bumps were superseded before commit".'}) gaps.append({'range': 'every build', - 'why': 'Entries summarise commit subjects, not diffs. A build with one squashed ' - 'commit shows one line even if it changed ten files; run ' - '`git show <sha>` for the change itself.'}) + 'why': 'Entries carry the commit subject and, where the commit body has ' + 'substance, its symptom / cause / fix / verification text (folded open ' + 'per line on the page, nested under the line in CHANGELOG.md). A build ' + 'whose work was squashed into one commit still shows one line; the diff ' + 'itself is always `git show <sha>`.'}) now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') prev = existing_island() @@ -232,7 +339,8 @@ def build(): DEK = ('Every change that has a commit behind it. Each line names the build it landed in and the ' - 'commit it came from, so any claim here can be checked with <code>git show</code>. Where ' + 'commit it came from, and folds open to the commit body — the symptom, cause, fix and how ' + 'it was verified — so any claim here can be checked with <code>git show</code>. Where ' 'the repository has no record, the gap is written down instead of filled in.') GAP_INTRO = ('This page is generated from the repository\'s own history for ' '<code>spaces/newos4</code> — the same source the running build comes from, so ' @@ -257,6 +365,27 @@ def existing_island(): return None +# Rendered as native <details> inside each list item, so the page's own JS needs no change. +# Injected once (sentinel-guarded) because the page is a static document: a builder that only +# rewrites its JSON island cannot restyle anything else. +DETAIL_CSS = """ /* wx11-changelog-detail:BEGIN */ + .entry li>details{margin:.3rem 0 0} + .entry li>details>summary{ + cursor:pointer; font-family:var(--mono); font-size:.7rem; letter-spacing:.08em; + text-transform:uppercase; color:var(--ink-3); padding:.45rem 0; + } + .entry li>details>summary:hover{color:var(--cyan)} + .entry li>details p{margin:.35rem 0 0; font-size:.88rem; color:var(--ink-2); line-height:1.55; overflow-wrap:anywhere} + .entry li>details ul{margin:.3rem 0 0; padding-left:1rem; font-size:.88rem; color:var(--ink-2); line-height:1.55; overflow-wrap:anywhere} + /* wx11-changelog-detail:END */ +""" +DETAIL_CSS_RX = re.compile(r'/\* wx11-changelog-detail:BEGIN \*/[\s\S]*?/\* wx11-changelog-detail:END \*/\n?') +# The first version shipped a single-line sentinel and no end marker; strip it or the page +# would keep both copies forever. +LEGACY_CSS_RX = re.compile(r'\s*/\* wx11-changelog-detail: collapsible commit body \*/' + r'[\s\S]*?\.entry li>details ul\{[^}]*\}\n') + + def write_page(page): with open(PAGE, encoding='utf-8') as fh: doc = fh.read() @@ -271,6 +400,14 @@ def write_page(page): new = re.sub(r'(<section class="panel" aria-labelledby="gaps-h">[\s\S]*?<h2 id="gaps-h">[^<]*</h2>\s*<p>)[\s\S]*?(</p>)', lambda m: m.group(1) + GAP_INTRO + m.group(2), new, count=1) new = re.sub(r'(<b id="cur-ver">)[^<]*(</b>)', lambda m: m.group(1) + html.escape(page['currentVersion']) + m.group(2), new, count=1) + new = LEGACY_CSS_RX.sub('\n', new, count=1) + if DETAIL_CSS_RX.search(new): + # Owned block: re-emitted every run, so the rules can be edited here (the page is + # static; a stale copy on disk would silently win). + new = DETAIL_CSS_RX.sub(lambda m: DETAIL_CSS, new, count=1) + else: + new, n = re.subn(r'(\n\s*</style>)', lambda m: '\n' + DETAIL_CSS + m.group(1), new, count=1) + assert n == 1, 'no </style> to inject the detail CSS before - page layout changed' write_atomic(PAGE, new) return len(new) @@ -286,6 +423,38 @@ PREAMBLE_RE = re.compile(re.escape('# newOS4 changelog') + r'do not edit by hand between the markers\.\n+') +DETAIL_RX = re.compile(r'<details[^>]*>\s*<summary>[^<]*</summary>(.*?)</details>\s*$', re.S) + + +def md_inline(markup): + """Our own generated item markup -> markdown. + + The old line replaced EVERY tag with a backtick, so `<strong>` became a stray tick and a + body's paragraph tags would have shredded the file. Explicit rules beat a generic strip: + <code>/<strong> survive as markdown, everything else becomes a space. + """ + text = re.sub(r'<code>(.*?)</code>', lambda m: '`' + m.group(1) + '`', markup, flags=re.S) + text = re.sub(r'<strong>(.*?)</strong>', lambda m: '**' + m.group(1) + '**', text, flags=re.S) + text = re.sub(r'<[^>]+>', ' ', text) + return re.sub(r'\s+', ' ', html.unescape(text)).strip() + + +def md_item(item): + """One changelog line, with the commit body as nested bullets. + + This is the file the public mirror serves, so the README claim "symptom -> cause -> fix -> + verified" has to be true HERE, not only on the rendered page. + """ + m = DETAIL_RX.search(item) + lines = ['- ' + md_inline(item[:m.start()] if m else item)] + if m: + for _tag, body in re.findall(r'<(p|li)>(.*?)</\1>', m.group(1), re.S): + body = md_inline(body) + if body: + lines.append(' - ' + body) + return lines + + def write_md(page, rows): """App-side Keep-a-Changelog history; only the region between the markers is generated.""" region = ['<!-- generated:start -->', ''] @@ -295,7 +464,7 @@ def write_md(page, rows): if entry['note']: region.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', '']) for item in entry['items']: - region.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`')) + region.extend(md_item(item)) region.append('') region.extend(['<!-- generated:end -->', '']) region_text = '\n'.join(region) diff --git a/trade-history.json b/trade-history.json deleted file mode 100644 index 6840cb5..0000000 --- a/trade-history.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "address": "0xA12b3c4d5e6f7890abcdef1234567890a1b2c3d4", - "fetchedAt": "2026-07-04T13:45:00Z", - "summary": { - "totalTrades": 18, - "buyCount": 11, - "sellCount": 7, - "totalVolume": 12483.42, - "totalFees": 8.74, - "totalRealizedPnl": 6.23, - "byCoin": { - "BTC": { - "trades": 4, - "buys": 2, - "sells": 2, - "volume": 4120.00, - "fees": 2.88, - "realizedPnl": 2.14, - "wins": 2, - "losses": 1, - "flat": 0, - "avgPrice": 68500.0, - "winRate": 66.7 - }, - "ETH": { - "trades": 5, - "buys": 3, - "sells": 2, - "volume": 2780.00, - "fees": 1.95, - "realizedPnl": -0.42, - "wins": 1, - "losses": 2, - "flat": 0, - "avgPrice": 3650.0, - "winRate": 33.3 - }, - "HYPE": { - "trades": 9, - "buys": 6, - "sells": 3, - "volume": 5583.42, - "fees": 3.91, - "realizedPnl": 4.51, - "wins": 5, - "losses": 2, - "flat": 0, - "avgPrice": 28.45, - "winRate": 71.4 - } - } - }, - "recentFills": [ - {"tid":1018,"coin":"HYPE","side":"BUY","price":27.10,"size":2.0,"notional":54.20,"totalFee":0.038,"closedPnl":0,"iso":"2026-07-04T12:30:00Z"}, - {"tid":1017,"coin":"HYPE","side":"SELL","price":29.40,"size":5.0,"notional":147.00,"totalFee":0.103,"closedPnl":2.15,"iso":"2026-07-04T11:15:00Z"}, - {"tid":1016,"coin":"ETH","side":"SELL","price":3690.0,"size":0.089,"notional":328.41,"totalFee":0.230,"closedPnl":-0.45,"iso":"2026-07-04T10:02:00Z"}, - {"tid":1015,"coin":"BTC","side":"BUY","price":67850.0,"size":0.002,"notional":135.70,"totalFee":0.095,"closedPnl":0,"iso":"2026-07-04T09:11:00Z"}, - {"tid":1014,"coin":"HYPE","side":"BUY","price":26.85,"size":8.0,"notional":214.80,"totalFee":0.150,"closedPnl":0,"iso":"2026-07-03T18:44:00Z"} - ], - "note": "Sample data for dashboard rendering. Run tradehistory module to fetch real fills from Hyperliquid." -} diff --git a/wallet-snapshot.json b/wallet-snapshot.json deleted file mode 100644 index 0f4999a..0000000 --- a/wallet-snapshot.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "address": "0xA12b3c4d5e6f7890abcdef1234567890a1b2c3d4", - "accountValue": "80.01", - "totalMarginUsed": "29.07", - "withdrawable": "50.94", - "positions": 3, - "positionDetails": [ - { "coin": "BTC", "szi": "0.00413", "uPnl": "1.49" }, - { "coin": "ETH", "szi": "0.089", "uPnl": "0.39" }, - { "coin": "HYPE", "szi": "15.78", "uPnl": "4.35" } - ], - "lastUpdate": "2026-07-04T13:05:28Z", - "source": "manual-baseline", - "note": "First reference snapshot — future diffs measured against this" -} |