diff options
Diffstat (limited to 'modules/core/core-bundle.js')
| -rw-r--r-- | modules/core/core-bundle.js | 200 |
1 files changed, 156 insertions, 44 deletions
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); } |